feat(admin): RouteDock 页签增强——pathname 唯一 key、20 上限 LRU、关闭其他/左/右/全部、登出清理、持久化校验收紧
This commit is contained in:
41
apps/admin/src/components/RouteDock/dockTabs.test.ts
Normal file
41
apps/admin/src/components/RouteDock/dockTabs.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
31
apps/admin/src/components/RouteDock/dockTabs.ts
Normal file
31
apps/admin/src/components/RouteDock/dockTabs.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -14,10 +14,12 @@ import {
|
|||||||
useSortable,
|
useSortable,
|
||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
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 { Location } from 'react-router';
|
||||||
import type { AppMenuItem } from '../../auth/menu-policy';
|
import type { AppMenuItem } from '../../auth/menu-policy';
|
||||||
import { useAppStore } from '../../store';
|
import { useAppStore } from '../../store';
|
||||||
|
import { upsertDockTab } from './dockTabs';
|
||||||
|
|
||||||
interface RouteDockProps {
|
interface RouteDockProps {
|
||||||
location: Location;
|
location: Location;
|
||||||
@@ -72,20 +74,16 @@ const DraggableTabNode: React.FC<Readonly<DraggableTabNodeProps>> = ({ ...props
|
|||||||
};
|
};
|
||||||
|
|
||||||
const RouteDock: React.FC<RouteDockProps> = ({ location, menuItems, onNavigate, draggable }) => {
|
const RouteDock: React.FC<RouteDockProps> = ({ 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 tabs = useAppStore((state) => state.routeDockTabs);
|
||||||
const setRouteDockTabs = useAppStore((state) => state.setRouteDockTabs);
|
const setRouteDockTabs = useAppStore((state) => state.setRouteDockTabs);
|
||||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (location.pathname === '/') return;
|
if (location.pathname === '/') return;
|
||||||
setRouteDockTabs((currentTabs) => {
|
const label = getRouteLabel(menuItems, location.pathname);
|
||||||
const label = getRouteLabel(menuItems, location.pathname);
|
setRouteDockTabs((currentTabs) => upsertDockTab(currentTabs, activeKey, label));
|
||||||
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));
|
|
||||||
});
|
|
||||||
}, [activeKey, location.pathname, menuItems, setRouteDockTabs]);
|
}, [activeKey, location.pathname, menuItems, setRouteDockTabs]);
|
||||||
|
|
||||||
const tabItems = useMemo<NonNullable<TabsProps['items']>>(
|
const tabItems = useMemo<NonNullable<TabsProps['items']>>(
|
||||||
@@ -109,6 +107,27 @@ const RouteDock: React.FC<RouteDockProps> = ({ 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) => {
|
const handleDragEnd = ({ active, over }: DragEndEvent) => {
|
||||||
if (!over || active.id === over.id) return;
|
if (!over || active.id === over.id) return;
|
||||||
setRouteDockTabs((currentTabs) => {
|
setRouteDockTabs((currentTabs) => {
|
||||||
@@ -166,6 +185,32 @@ const RouteDock: React.FC<RouteDockProps> = ({ location, menuItems, onNavigate,
|
|||||||
if (action === 'remove') closeTab(String(targetKey));
|
if (action === 'remove') closeTab(String(targetKey));
|
||||||
}}
|
}}
|
||||||
renderTabBar={renderTabBar}
|
renderTabBar={renderTabBar}
|
||||||
|
tabBarExtraContent={
|
||||||
|
tabs.length > 1 ? (
|
||||||
|
<Dropdown
|
||||||
|
trigger={['click']}
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
{ key: 'close-others', label: '关闭其他' },
|
||||||
|
{ key: 'close-left', label: '关闭左侧' },
|
||||||
|
{ key: 'close-right', label: '关闭右侧' },
|
||||||
|
{ type: 'divider' },
|
||||||
|
{ key: 'close-all', label: '关闭全部' },
|
||||||
|
],
|
||||||
|
onClick: ({ key }) => {
|
||||||
|
if (key === 'close-others') closeOthers();
|
||||||
|
else if (key === 'close-left') closeLeft();
|
||||||
|
else if (key === 'close-right') closeRight();
|
||||||
|
else if (key === 'close-all') closeAll();
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button type="text" size="small" icon={<DownOutlined />} aria-label="更多页签操作">
|
||||||
|
更多
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ const MainLayout: React.FC = () => {
|
|||||||
const handleLogout = useCallback(() => {
|
const handleLogout = useCallback(() => {
|
||||||
logoutUser();
|
logoutUser();
|
||||||
usePermissionStore.getState().clearPermissions();
|
usePermissionStore.getState().clearPermissions();
|
||||||
|
useAppStore.getState().setRouteDockTabs([]);
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
}, [logoutUser, navigate]);
|
}, [logoutUser, navigate]);
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ function isDockTab(value: unknown): value is DockTab {
|
|||||||
isRecord(value) &&
|
isRecord(value) &&
|
||||||
typeof value.key === 'string' &&
|
typeof value.key === 'string' &&
|
||||||
value.key.startsWith('/') &&
|
value.key.startsWith('/') &&
|
||||||
|
value.key !== '/' &&
|
||||||
|
!value.key.includes('?') &&
|
||||||
typeof value.label === 'string'
|
typeof value.label === 'string'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user