feat: 添加可拖拽的路由停靠组件,支持动态标签管理
Some checks failed
CI 检查 / lint (pull_request) Has been cancelled
CI 检查 / typecheck (pull_request) Has been cancelled
CI 检查 / test (pull_request) Has been cancelled

This commit is contained in:
2026-07-21 10:13:47 +08:00
parent 5e1ba70e59
commit 29c55ebff1
5 changed files with 354 additions and 0 deletions

View File

@@ -0,0 +1,196 @@
import React, { useEffect, useMemo, useState } from 'react';
import type { DragEndEvent } from '@dnd-kit/core';
import { closestCenter, DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import {
arrayMove,
horizontalListSortingStrategy,
SortableContext,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Tabs } from 'antd';
import type { TabsProps } from 'antd';
import type { Location } from 'react-router-dom';
import type { AppMenuItem } from '../../auth/menu-policy';
const STORAGE_KEY = 'gongxue-route-dock';
interface DockTab {
key: string;
label: string;
}
interface RouteDockProps {
location: Location;
menuItems: readonly AppMenuItem[];
onNavigate: (path: string) => void;
draggable: boolean;
}
interface DraggableTabNodeProps extends React.HTMLAttributes<HTMLDivElement> {
'data-node-key': string;
}
function findMenuLabel(items: readonly AppMenuItem[], pathname: string): string | undefined {
for (const item of items) {
if (item.key === pathname) return item.label;
if (item.children) {
const label = findMenuLabel(item.children, pathname);
if (label) return label;
}
}
return undefined;
}
function getRouteLabel(items: readonly AppMenuItem[], pathname: string): string {
const menuLabel = findMenuLabel(items, pathname);
if (menuLabel) return menuLabel;
if (/^\/students\/\d+\/profile$/.test(pathname)) return '学生档案';
if (/^\/classes\/\d+$/.test(pathname)) return '班级详情';
return pathname === '/' ? '首页' : '页面';
}
function readStoredTabs(): DockTab[] {
try {
const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(tab): tab is DockTab =>
typeof tab?.key === 'string' && tab.key.startsWith('/') && typeof tab?.label === 'string',
);
} catch {
return [];
}
}
const DraggableTabNode: React.FC<Readonly<DraggableTabNodeProps>> = ({ ...props }) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: props['data-node-key'],
});
const child = props.children as React.ReactElement<{ style?: React.CSSProperties }>;
return React.cloneElement(child, {
ref: setNodeRef,
style: {
...child.props.style,
transform: CSS.Translate.toString(transform),
transition,
cursor: isDragging ? 'grabbing' : 'grab',
zIndex: isDragging ? 1 : undefined,
opacity: isDragging ? 0.92 : undefined,
boxShadow: isDragging ? '0 8px 20px rgba(29, 29, 31, 0.14)' : undefined,
},
...attributes,
...listeners,
} as React.HTMLAttributes<HTMLElement>);
};
const RouteDock: React.FC<RouteDockProps> = ({ location, menuItems, onNavigate, draggable }) => {
const activeKey = `${location.pathname}${location.search}`;
const [tabs, setTabs] = useState<DockTab[]>(() => {
const storedTabs = readStoredTabs();
if (location.pathname === '/') return storedTabs;
if (storedTabs.some((tab) => tab.key === activeKey)) return storedTabs;
return [...storedTabs, { key: activeKey, label: getRouteLabel(menuItems, location.pathname) }];
});
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
useEffect(() => {
if (location.pathname === '/') return;
setTabs((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));
});
}, [activeKey, location.pathname, menuItems]);
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(tabs));
}, [tabs]);
const tabItems = useMemo<NonNullable<TabsProps['items']>>(
() =>
tabs.map((tab) => ({
key: tab.key,
label: tab.label,
closable: tabs.length > 1,
})),
[tabs],
);
const closeTab = (targetKey: string) => {
const targetIndex = tabs.findIndex((tab) => tab.key === targetKey);
if (targetIndex < 0 || tabs.length === 1) return;
const nextTabs = tabs.filter((tab) => tab.key !== targetKey);
setTabs(nextTabs);
if (targetKey === activeKey) {
const nextActiveTab = nextTabs[Math.min(targetIndex, nextTabs.length - 1)];
if (nextActiveTab) onNavigate(nextActiveTab.key);
}
};
const handleDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return;
setTabs((currentTabs) => {
const activeIndex = currentTabs.findIndex((tab) => tab.key === active.id);
const overIndex = currentTabs.findIndex((tab) => tab.key === over.id);
return activeIndex < 0 || overIndex < 0
? currentTabs
: arrayMove(currentTabs, activeIndex, overIndex);
});
};
const renderTabBar: TabsProps['renderTabBar'] = (tabBarProps, DefaultTabBar) => {
const tabBar = (
<DefaultTabBar {...tabBarProps}>
{(node) => {
if (!draggable) return node;
return (
<DraggableTabNode
{...(node as React.ReactElement<DraggableTabNodeProps>).props}
key={node.key}
>
{node}
</DraggableTabNode>
);
}}
</DefaultTabBar>
);
if (!draggable) return tabBar;
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext
items={tabs.map((tab) => tab.key)}
strategy={horizontalListSortingStrategy}
>
{tabBar}
</SortableContext>
</DndContext>
);
};
if (location.pathname === '/' || tabs.length === 0) return null;
return (
<nav className="route-dock" aria-label="已打开页面">
<Tabs
type="editable-card"
size="small"
hideAdd
activeKey={activeKey}
items={tabItems}
animated={false}
onChange={onNavigate}
onEdit={(targetKey, action) => {
if (action === 'remove') closeTab(String(targetKey));
}}
renderTabBar={renderTabBar}
/>
</nav>
);
};
export default RouteDock;