From 260a7517d24c44b994ea771926ef31d237d047c0 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 17:00:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20RouteDock=20=E9=A1=B5=E7=AD=BE?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E2=80=94=E2=80=94pathname=20=E5=94=AF?= =?UTF-8?q?=E4=B8=80=20key=E3=80=8120=20=E4=B8=8A=E9=99=90=20LRU=E3=80=81?= =?UTF-8?q?=E5=85=B3=E9=97=AD=E5=85=B6=E4=BB=96/=E5=B7=A6/=E5=8F=B3/?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E3=80=81=E7=99=BB=E5=87=BA=E6=B8=85=E7=90=86?= =?UTF-8?q?=E3=80=81=E6=8C=81=E4=B9=85=E5=8C=96=E6=A0=A1=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=E7=B4=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/RouteDock/dockTabs.test.ts | 41 ++++++++++++ .../src/components/RouteDock/dockTabs.ts | 31 +++++++++ apps/admin/src/components/RouteDock/index.tsx | 63 ++++++++++++++++--- apps/admin/src/layouts/MainLayout.tsx | 1 + apps/admin/src/store/middleware/persist.ts | 2 + 5 files changed, 129 insertions(+), 9 deletions(-) create mode 100644 apps/admin/src/components/RouteDock/dockTabs.test.ts create mode 100644 apps/admin/src/components/RouteDock/dockTabs.ts diff --git a/apps/admin/src/components/RouteDock/dockTabs.test.ts b/apps/admin/src/components/RouteDock/dockTabs.test.ts new file mode 100644 index 0000000..17fb365 --- /dev/null +++ b/apps/admin/src/components/RouteDock/dockTabs.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import type { DockTab } from '../../store/app/appTypes'; +import { MAX_DOCK_TABS, upsertDockTab } from './dockTabs'; + +const tab = (key: string, label = key): DockTab => ({ key, label }); + +describe('upsertDockTab', () => { + it('追加新页签', () => { + expect(upsertDockTab([], '/students', '学生管理')).toEqual([tab('/students', '学生管理')]); + }); + + it('标题未变时保持原引用,避免无谓渲染', () => { + const tabs = [tab('/students', '学生管理')]; + expect(upsertDockTab(tabs, '/students', '学生管理')).toBe(tabs); + }); + + it('菜单标题变化时更新页签标题', () => { + const tabs = [tab('/students', '学生管理')]; + expect(upsertDockTab(tabs, '/students', '学生档案')).toEqual([tab('/students', '学生档案')]); + }); + + it('超过上限时保留当前页签 + 最新页签(LRU 淘汰最旧)', () => { + const tabs = Array.from({ length: MAX_DOCK_TABS }, (_, i) => tab(`/p${i + 1}`)); + const next = upsertDockTab(tabs, '/new', '新页'); + expect(next).toHaveLength(MAX_DOCK_TABS); + expect(next[0]).toEqual(tab('/new', '新页')); + expect(next[next.length - 1]).toEqual(tab(`/p${MAX_DOCK_TABS}`)); + expect(next.some((t) => t.key === '/p1')).toBe(false); + }); + + it('恢复持久化的超量页签时压缩到上限,并保留当前页', () => { + const tabs = Array.from({ length: 25 }, (_, i) => tab(`/p${i + 1}`)); + tabs.push(tab('/wallets', '学生余额')); + const next = upsertDockTab(tabs, '/wallets', '学生余额'); + expect(next).toHaveLength(MAX_DOCK_TABS); + expect(next[0]).toEqual(tab('/wallets', '学生余额')); + expect(next.some((t) => t.key === '/p1')).toBe(false); + expect(next.some((t) => t.key === '/p6')).toBe(false); + expect(next.some((t) => t.key === '/p7')).toBe(true); + }); +}); diff --git a/apps/admin/src/components/RouteDock/dockTabs.ts b/apps/admin/src/components/RouteDock/dockTabs.ts new file mode 100644 index 0000000..974b802 --- /dev/null +++ b/apps/admin/src/components/RouteDock/dockTabs.ts @@ -0,0 +1,31 @@ +import type { DockTab } from '../../store/app/appTypes'; + +/** 页签数量上限:超出后淘汰最旧的非当前页签(LRU 式),避免无限堆积。 */ +export const MAX_DOCK_TABS = 20; + +function clampToLimit(list: readonly DockTab[], activeKey: string): DockTab[] { + // 未超限时保留原引用,避免触发无谓的 tab 列表重渲染 + if (list.length <= MAX_DOCK_TABS) return list as DockTab[]; + // 恢复/迁移或新增后超出上限:保留当前页签 + 最新的其余页签(LRU 式淘汰) + const active = list.find((tab) => tab.key === activeKey); + const rest = list.filter((tab) => tab.key !== activeKey); + const keptRest = rest.slice(rest.length - (MAX_DOCK_TABS - 1)); + return active ? [active, ...keptRest] : keptRest.slice(-MAX_DOCK_TABS); +} + +/** + * 路由页签合并:按 pathname 建 tab,重复时更新标题,并始终把列表压回上限。 + */ +export function upsertDockTab( + tabs: readonly DockTab[], + activeKey: string, + label: string, +): DockTab[] { + const existing = tabs.find((tab) => tab.key === activeKey); + if (existing) { + const updated = + existing.label === label ? tabs : tabs.map((tab) => (tab.key === activeKey ? { ...tab, label } : tab)); + return clampToLimit(updated, activeKey); + } + return clampToLimit([...tabs, { key: activeKey, label }], activeKey); +} diff --git a/apps/admin/src/components/RouteDock/index.tsx b/apps/admin/src/components/RouteDock/index.tsx index 14bcacc..9cd6491 100644 --- a/apps/admin/src/components/RouteDock/index.tsx +++ b/apps/admin/src/components/RouteDock/index.tsx @@ -14,10 +14,12 @@ import { useSortable, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import { Tabs, type TabsProps } from 'antd'; +import { Button, Dropdown, Tabs, type TabsProps } from 'antd'; +import { DownOutlined } from '@ant-design/icons'; import type { Location } from 'react-router'; import type { AppMenuItem } from '../../auth/menu-policy'; import { useAppStore } from '../../store'; +import { upsertDockTab } from './dockTabs'; interface RouteDockProps { location: Location; @@ -72,20 +74,16 @@ const DraggableTabNode: React.FC> = ({ ...props }; const RouteDock: React.FC = ({ location, menuItems, onNavigate, draggable }) => { - const activeKey = `${location.pathname}${location.search}`; + // 与 RouteKeeper 缓存 key 保持一致:只按 pathname 建 tab,避免 query 变化产生重复页签。 + const activeKey = location.pathname; const tabs = useAppStore((state) => state.routeDockTabs); const setRouteDockTabs = useAppStore((state) => state.setRouteDockTabs); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } })); useEffect(() => { if (location.pathname === '/') return; - setRouteDockTabs((currentTabs) => { - const label = getRouteLabel(menuItems, location.pathname); - const existing = currentTabs.find((tab) => tab.key === activeKey); - if (!existing) return [...currentTabs, { key: activeKey, label }]; - if (existing.label === label) return currentTabs; - return currentTabs.map((tab) => (tab.key === activeKey ? { ...tab, label } : tab)); - }); + const label = getRouteLabel(menuItems, location.pathname); + setRouteDockTabs((currentTabs) => upsertDockTab(currentTabs, activeKey, label)); }, [activeKey, location.pathname, menuItems, setRouteDockTabs]); const tabItems = useMemo>( @@ -109,6 +107,27 @@ const RouteDock: React.FC = ({ location, menuItems, onNavigate, } }; + const closeOthers = () => { + setRouteDockTabs(tabs.filter((tab) => tab.key === activeKey)); + }; + + const closeLeft = () => { + const index = tabs.findIndex((tab) => tab.key === activeKey); + if (index <= 0) return; + setRouteDockTabs(tabs.filter((_, i) => i >= index)); + }; + + const closeRight = () => { + const index = tabs.findIndex((tab) => tab.key === activeKey); + if (index < 0 || index === tabs.length - 1) return; + setRouteDockTabs(tabs.filter((_, i) => i <= index)); + }; + + const closeAll = () => { + setRouteDockTabs([]); + onNavigate('/dashboard'); + }; + const handleDragEnd = ({ active, over }: DragEndEvent) => { if (!over || active.id === over.id) return; setRouteDockTabs((currentTabs) => { @@ -166,6 +185,32 @@ const RouteDock: React.FC = ({ location, menuItems, onNavigate, if (action === 'remove') closeTab(String(targetKey)); }} renderTabBar={renderTabBar} + tabBarExtraContent={ + tabs.length > 1 ? ( + { + if (key === 'close-others') closeOthers(); + else if (key === 'close-left') closeLeft(); + else if (key === 'close-right') closeRight(); + else if (key === 'close-all') closeAll(); + }, + }} + > + + + ) : undefined + } /> ); diff --git a/apps/admin/src/layouts/MainLayout.tsx b/apps/admin/src/layouts/MainLayout.tsx index 5c7f907..2cb465c 100644 --- a/apps/admin/src/layouts/MainLayout.tsx +++ b/apps/admin/src/layouts/MainLayout.tsx @@ -196,6 +196,7 @@ const MainLayout: React.FC = () => { const handleLogout = useCallback(() => { logoutUser(); usePermissionStore.getState().clearPermissions(); + useAppStore.getState().setRouteDockTabs([]); navigate('/login'); }, [logoutUser, navigate]); diff --git a/apps/admin/src/store/middleware/persist.ts b/apps/admin/src/store/middleware/persist.ts index 3fe7c13..963fe49 100644 --- a/apps/admin/src/store/middleware/persist.ts +++ b/apps/admin/src/store/middleware/persist.ts @@ -36,6 +36,8 @@ function isDockTab(value: unknown): value is DockTab { isRecord(value) && typeof value.key === 'string' && value.key.startsWith('/') && + value.key !== '/' && + !value.key.includes('?') && typeof value.label === 'string' ); }