220 lines
7.1 KiB
TypeScript
220 lines
7.1 KiB
TypeScript
import React, { useEffect, useMemo } from 'react';
|
||
import {
|
||
closestCenter,
|
||
DndContext,
|
||
PointerSensor,
|
||
useSensor,
|
||
useSensors,
|
||
type DragEndEvent,
|
||
} from '@dnd-kit/core';
|
||
import {
|
||
arrayMove,
|
||
horizontalListSortingStrategy,
|
||
SortableContext,
|
||
useSortable,
|
||
} from '@dnd-kit/sortable';
|
||
import { CSS } from '@dnd-kit/utilities';
|
||
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;
|
||
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 === '/' ? '首页' : '页面';
|
||
}
|
||
|
||
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 }) => {
|
||
// 与 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;
|
||
const label = getRouteLabel(menuItems, location.pathname);
|
||
setRouteDockTabs((currentTabs) => upsertDockTab(currentTabs, activeKey, label));
|
||
}, [activeKey, location.pathname, menuItems, setRouteDockTabs]);
|
||
|
||
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);
|
||
setRouteDockTabs(nextTabs);
|
||
if (targetKey === activeKey) {
|
||
const nextActiveTab = nextTabs[Math.min(targetIndex, nextTabs.length - 1)];
|
||
if (nextActiveTab) onNavigate(nextActiveTab.key);
|
||
}
|
||
};
|
||
|
||
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) => {
|
||
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}
|
||
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>
|
||
);
|
||
};
|
||
|
||
export default RouteDock;
|