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

@@ -14,6 +14,9 @@
},
"dependencies": {
"@ant-design/icons": "^6.1.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"antd": "^6.3.6",
"axios": "^1.15.1",
"dayjs": "^1.11.20",

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;

View File

@@ -59,6 +59,88 @@ canvas {
z-index: 100;
}
.route-dock {
position: sticky;
top: 64px;
z-index: 90;
min-width: 0;
height: 44px;
padding: 6px 12px;
overflow: hidden;
background: #f5f5f7;
border-bottom: 1px solid #e5e5e7;
}
.route-dock .ant-tabs {
height: 32px;
}
.route-dock .ant-tabs-nav {
height: 32px;
margin: 0;
}
.route-dock .ant-tabs-nav::before {
border-bottom: 0;
}
.route-dock .ant-tabs-tab {
min-width: 112px;
max-width: 220px;
height: 32px;
margin: 0 6px 0 0 !important;
padding: 0 10px 0 12px !important;
overflow: hidden;
background: rgba(255, 255, 255, 0.58) !important;
border: 1px solid transparent !important;
border-radius: 7px !important;
transition:
background-color 160ms ease,
border-color 160ms ease,
box-shadow 160ms ease !important;
}
.route-dock .ant-tabs-tab:hover {
background: rgba(255, 255, 255, 0.9) !important;
border-color: #dedee2 !important;
}
.route-dock .ant-tabs-tab-active {
background: #fff !important;
border-color: #d8d8dc !important;
box-shadow:
inset 0 2px 0 #1677ff,
0 2px 7px rgba(29, 29, 31, 0.08);
}
.route-dock .ant-tabs-tab-btn {
min-width: 0;
overflow: hidden;
color: #4d4d4d;
text-overflow: ellipsis;
white-space: nowrap;
}
.route-dock .ant-tabs-tab-remove {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 20px;
height: 20px;
margin-left: 8px;
border-radius: 50%;
transition: background-color 140ms ease;
}
.route-dock .ant-tabs-tab-remove:hover {
background: #ededf0;
}
.route-dock .ant-tabs-content-holder {
display: none;
}
/* Shared responsive toolbar: add these classes to page filter/action rows. */
.responsive-toolbar {
display: flex;
@@ -135,6 +217,16 @@ canvas {
line-height: 56px;
}
.route-dock {
top: 56px;
height: 42px;
padding: 5px 8px;
}
.route-dock .ant-tabs-tab {
min-width: 104px;
}
.app-header .ant-btn {
width: 40px;
min-height: 40px;

View File

@@ -32,6 +32,7 @@ import { usePermission } from '../hooks/usePermission';
import api from '../api';
import { writePermissions } from '../auth/permission-store';
import NotificationBell from '../components/NotificationBell';
import RouteDock from '../components/RouteDock';
import { buildMenu, type AppMenuItem } from '../auth/menu-policy';
const { Header, Sider, Content } = Layout;
@@ -287,6 +288,12 @@ const MainLayout: React.FC = () => {
</Dropdown>
</div>
</Header>
<RouteDock
location={location}
menuItems={menuItems}
onNavigate={navigate}
draggable={isDesktop}
/>
<Content
className="app-content"
style={{

56
package-lock.json generated
View File

@@ -26,6 +26,9 @@
"version": "0.0.0",
"dependencies": {
"@ant-design/icons": "^6.1.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"antd": "^6.3.6",
"axios": "^1.15.1",
"dayjs": "^1.11.20",
@@ -1089,6 +1092,59 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.10.0.tgz",