feat: 添加可拖拽的路由停靠组件,支持动态标签管理
This commit is contained in:
196
apps/admin/src/components/RouteDock/index.tsx
Normal file
196
apps/admin/src/components/RouteDock/index.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user