forked from wangziqi/gongxue-base
feat: 添加可拖拽的路由停靠组件,支持动态标签管理
This commit is contained in:
@@ -14,6 +14,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^6.1.1",
|
"@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",
|
"antd": "^6.3.6",
|
||||||
"axios": "^1.15.1",
|
"axios": "^1.15.1",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
|
|||||||
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;
|
||||||
@@ -59,6 +59,88 @@ canvas {
|
|||||||
z-index: 100;
|
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. */
|
/* Shared responsive toolbar: add these classes to page filter/action rows. */
|
||||||
.responsive-toolbar {
|
.responsive-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -135,6 +217,16 @@ canvas {
|
|||||||
line-height: 56px;
|
line-height: 56px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.route-dock {
|
||||||
|
top: 56px;
|
||||||
|
height: 42px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-dock .ant-tabs-tab {
|
||||||
|
min-width: 104px;
|
||||||
|
}
|
||||||
|
|
||||||
.app-header .ant-btn {
|
.app-header .ant-btn {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { usePermission } from '../hooks/usePermission';
|
|||||||
import api from '../api';
|
import api from '../api';
|
||||||
import { writePermissions } from '../auth/permission-store';
|
import { writePermissions } from '../auth/permission-store';
|
||||||
import NotificationBell from '../components/NotificationBell';
|
import NotificationBell from '../components/NotificationBell';
|
||||||
|
import RouteDock from '../components/RouteDock';
|
||||||
import { buildMenu, type AppMenuItem } from '../auth/menu-policy';
|
import { buildMenu, type AppMenuItem } from '../auth/menu-policy';
|
||||||
|
|
||||||
const { Header, Sider, Content } = Layout;
|
const { Header, Sider, Content } = Layout;
|
||||||
@@ -287,6 +288,12 @@ const MainLayout: React.FC = () => {
|
|||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
</Header>
|
</Header>
|
||||||
|
<RouteDock
|
||||||
|
location={location}
|
||||||
|
menuItems={menuItems}
|
||||||
|
onNavigate={navigate}
|
||||||
|
draggable={isDesktop}
|
||||||
|
/>
|
||||||
<Content
|
<Content
|
||||||
className="app-content"
|
className="app-content"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
56
package-lock.json
generated
56
package-lock.json
generated
@@ -26,6 +26,9 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^6.1.1",
|
"@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",
|
"antd": "^6.3.6",
|
||||||
"axios": "^1.15.1",
|
"axios": "^1.15.1",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
@@ -1089,6 +1092,59 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
"@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": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.10.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user