Files
gongxue-base/apps/admin/src/components/RouteDock/index.tsx

220 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;