@tker-react/layout 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +501 -0
- package/dist/index.d.mts +106 -0
- package/dist/index.d.ts +106 -0
- package/dist/index.mjs +492 -0
- package/dist/layout.css +93 -0
- package/package.json +50 -0
package/README.md
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
# @tker-react/layout
|
|
2
|
+
|
|
3
|
+
React 后台管理布局框架,提供菜单、面包屑、标签页、侧边栏等布局能力。
|
|
4
|
+
|
|
5
|
+
- **无内置 adapter**:不依赖特定 UI 库,所有 UI 渲染由使用者通过 adapter 组件控制
|
|
6
|
+
- **React Context 共享状态**:`<Layout>` 内置 Provider,通过 `useLayout()` 管理状态
|
|
7
|
+
- **路由解耦**:不直接依赖路由库,通过 `setNavigateAdapter` 和 `setActivePath` 与路由层对接
|
|
8
|
+
|
|
9
|
+
## 安装
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm add @tker-react/layout
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
如果生产构建时样式丢失,手动导入 CSS:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import "@tker-react/layout/layout.css";
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## 快速开始
|
|
22
|
+
|
|
23
|
+
使用 TanStack Router 的完整示例,展示 App 入口、布局配置和路由同步。
|
|
24
|
+
|
|
25
|
+
### 1. 创建 AppLayout
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
// layouts/AppLayout.tsx
|
|
29
|
+
import { Layout, useLayout } from "@tker-react/layout";
|
|
30
|
+
import { Outlet, useRouter, useRouterState } from "@tanstack/react-router";
|
|
31
|
+
import { useLayoutEffect } from "react";
|
|
32
|
+
|
|
33
|
+
export function AppLayout() {
|
|
34
|
+
return (
|
|
35
|
+
<Layout>
|
|
36
|
+
<SetupLayout />
|
|
37
|
+
<Outlet />
|
|
38
|
+
</Layout>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function SetupLayout() {
|
|
43
|
+
const layout = useLayout();
|
|
44
|
+
const router = useRouter();
|
|
45
|
+
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
|
46
|
+
const search = useRouterState({ select: (s) => s.location.searchStr });
|
|
47
|
+
|
|
48
|
+
// adapter 注册和菜单设置必须在 useLayoutEffect 中调用
|
|
49
|
+
useLayoutEffect(() => {
|
|
50
|
+
layout.setMenuAdapter(MenuAdapter);
|
|
51
|
+
layout.setBreadcrumbAdapter(BreadcrumbAdapter);
|
|
52
|
+
layout.setTabAdapter(TabAdapter);
|
|
53
|
+
layout.setLogoAdapter(LogoAdapter);
|
|
54
|
+
layout.setToolbarAdapter(ToolbarAdapter);
|
|
55
|
+
layout.setUserAvatarAdapter(UserAvatarAdapter);
|
|
56
|
+
layout.setMenus([
|
|
57
|
+
{ path: "/dashboard", title: "仪表盘" },
|
|
58
|
+
{
|
|
59
|
+
path: "/users",
|
|
60
|
+
title: "用户管理",
|
|
61
|
+
children: [
|
|
62
|
+
{ path: "/users", title: "用户列表" },
|
|
63
|
+
{ path: "/users/detail", title: "用户详情" },
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
]);
|
|
67
|
+
layout.setHomePath("/dashboard");
|
|
68
|
+
}, []);
|
|
69
|
+
|
|
70
|
+
// setNavigateAdapter 存储 ref,可以直接在 render 中调用
|
|
71
|
+
layout.setNavigateAdapter((path) => router.navigate({ to: path }));
|
|
72
|
+
|
|
73
|
+
useLayoutEffect(() => {
|
|
74
|
+
layout.setActivePath(pathname, pathname + search);
|
|
75
|
+
}, [pathname, search]);
|
|
76
|
+
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
> **注意**:`setXxxAdapter`、`setMenus` 等方法内部会调用 `setState`,必须在 `useLayoutEffect`(或 `useEffect`)中调用,不能在 render 期间直接调用,否则 React 会报 "Cannot update a component while rendering" 错误。`setNavigateAdapter` 只写 ref,可以在 render 中直接调用。
|
|
82
|
+
|
|
83
|
+
### 2. 定义路由并挂载
|
|
84
|
+
|
|
85
|
+
```tsx
|
|
86
|
+
// router.tsx
|
|
87
|
+
import { createRootRoute, createRoute, createRouter } from "@tanstack/react-router";
|
|
88
|
+
import { AppLayout } from "./layouts/AppLayout";
|
|
89
|
+
|
|
90
|
+
const rootRoute = createRootRoute({ component: AppLayout });
|
|
91
|
+
|
|
92
|
+
const indexRoute = createRoute({
|
|
93
|
+
getParentRoute: () => rootRoute,
|
|
94
|
+
path: "/",
|
|
95
|
+
component: Dashboard,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const dashboardRoute = createRoute({
|
|
99
|
+
getParentRoute: () => rootRoute,
|
|
100
|
+
path: "/dashboard",
|
|
101
|
+
component: Dashboard,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const routeTree = rootRoute.addChildren([indexRoute, dashboardRoute]);
|
|
105
|
+
|
|
106
|
+
export const router = createRouter({ routeTree });
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
```tsx
|
|
110
|
+
// App.tsx
|
|
111
|
+
import { RouterProvider } from "@tanstack/react-router";
|
|
112
|
+
import { router } from "./router";
|
|
113
|
+
|
|
114
|
+
export function App() {
|
|
115
|
+
return <RouterProvider router={router} />;
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### 3. 编写 adapter 组件
|
|
120
|
+
|
|
121
|
+
adapter 组件接收 Layout 传入的 props,内部使用任意 UI 库渲染。以下是使用 shadcn/ui 的完整示例。
|
|
122
|
+
|
|
123
|
+
#### Logo adapter
|
|
124
|
+
|
|
125
|
+
```tsx
|
|
126
|
+
// LogoAdapter.tsx
|
|
127
|
+
import type { LogoAdapterProps } from "@tker-react/layout";
|
|
128
|
+
|
|
129
|
+
function LogoAdapter({ collapsed }: LogoAdapterProps) {
|
|
130
|
+
return collapsed ? (
|
|
131
|
+
<div className="flex items-center justify-center h-full px-2">
|
|
132
|
+
<img src="/logo-sm.svg" alt="logo" className="w-6 h-6" />
|
|
133
|
+
</div>
|
|
134
|
+
) : (
|
|
135
|
+
<div className="flex items-center gap-2 px-4 h-full">
|
|
136
|
+
<img src="/logo.svg" alt="logo" className="w-6 h-6" />
|
|
137
|
+
<span className="font-semibold text-sm">Admin</span>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
#### Menu adapter
|
|
144
|
+
|
|
145
|
+
```tsx
|
|
146
|
+
// MenuAdapter.tsx
|
|
147
|
+
import type { MenuAdapterProps, MenuItem } from "@tker-react/layout";
|
|
148
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
|
149
|
+
import { cn } from "@/lib/utils";
|
|
150
|
+
|
|
151
|
+
function MenuAdapter({ menuData, activePath, collapsed, mode, onSelect }: MenuAdapterProps) {
|
|
152
|
+
if (mode === "top") {
|
|
153
|
+
return (
|
|
154
|
+
<nav className="flex items-center gap-1 h-full">
|
|
155
|
+
{menuData.map((item) => (
|
|
156
|
+
<button
|
|
157
|
+
key={item.path}
|
|
158
|
+
onClick={() => onSelect(item.path)}
|
|
159
|
+
className={cn(
|
|
160
|
+
"px-3 py-1 text-sm rounded-md",
|
|
161
|
+
activePath === item.path ? "bg-accent text-accent-foreground" : "hover:bg-muted"
|
|
162
|
+
)}
|
|
163
|
+
>
|
|
164
|
+
{item.icon && <item.icon className="w-4 h-4 inline mr-1" />}
|
|
165
|
+
{item.title || item.path}
|
|
166
|
+
</button>
|
|
167
|
+
))}
|
|
168
|
+
</nav>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<nav className="flex flex-col gap-1 p-2 overflow-y-auto">
|
|
174
|
+
{menuData.map((item) => (
|
|
175
|
+
<MenuItemRenderer
|
|
176
|
+
key={item.path}
|
|
177
|
+
item={item}
|
|
178
|
+
activePath={activePath}
|
|
179
|
+
collapsed={collapsed}
|
|
180
|
+
onSelect={onSelect}
|
|
181
|
+
/>
|
|
182
|
+
))}
|
|
183
|
+
</nav>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function MenuItemRenderer({
|
|
188
|
+
item,
|
|
189
|
+
activePath,
|
|
190
|
+
collapsed,
|
|
191
|
+
onSelect,
|
|
192
|
+
depth = 0,
|
|
193
|
+
}: {
|
|
194
|
+
item: MenuItem;
|
|
195
|
+
activePath: string;
|
|
196
|
+
collapsed: boolean;
|
|
197
|
+
onSelect: (path: string) => void;
|
|
198
|
+
depth?: number;
|
|
199
|
+
}) {
|
|
200
|
+
const hasChildren = item.children && item.children.length > 0;
|
|
201
|
+
|
|
202
|
+
if (hasChildren) {
|
|
203
|
+
return (
|
|
204
|
+
<Collapsible>
|
|
205
|
+
<CollapsibleTrigger
|
|
206
|
+
className={cn(
|
|
207
|
+
"flex items-center w-full rounded-md text-sm hover:bg-muted px-2 py-1.5",
|
|
208
|
+
!collapsed && "gap-2"
|
|
209
|
+
)}
|
|
210
|
+
style={{ paddingLeft: collapsed ? undefined : `${8 + depth * 12}px` }}
|
|
211
|
+
>
|
|
212
|
+
{item.icon && <item.icon className="w-4 h-4 shrink-0" />}
|
|
213
|
+
{!collapsed && (
|
|
214
|
+
<>
|
|
215
|
+
<span className="flex-1 text-left">{item.title || item.path}</span>
|
|
216
|
+
<ChevronDown className="w-3 h-3" />
|
|
217
|
+
</>
|
|
218
|
+
)}
|
|
219
|
+
</CollapsibleTrigger>
|
|
220
|
+
<CollapsibleContent>
|
|
221
|
+
{item.children!.map((child) => (
|
|
222
|
+
<MenuItemRenderer
|
|
223
|
+
key={child.path}
|
|
224
|
+
item={child}
|
|
225
|
+
activePath={activePath}
|
|
226
|
+
collapsed={collapsed}
|
|
227
|
+
onSelect={onSelect}
|
|
228
|
+
depth={depth + 1}
|
|
229
|
+
/>
|
|
230
|
+
))}
|
|
231
|
+
</CollapsibleContent>
|
|
232
|
+
</Collapsible>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return (
|
|
237
|
+
<button
|
|
238
|
+
onClick={() => onSelect(item.path)}
|
|
239
|
+
className={cn(
|
|
240
|
+
"flex items-center rounded-md text-sm px-2 py-1.5",
|
|
241
|
+
activePath === item.path
|
|
242
|
+
? "bg-accent text-accent-foreground"
|
|
243
|
+
: "hover:bg-muted",
|
|
244
|
+
!collapsed && "gap-2"
|
|
245
|
+
)}
|
|
246
|
+
style={{ paddingLeft: collapsed ? undefined : `${8 + depth * 12}px` }}
|
|
247
|
+
>
|
|
248
|
+
{item.icon && <item.icon className="w-4 h-4 shrink-0" />}
|
|
249
|
+
{!collapsed && (item.title || item.path)}
|
|
250
|
+
</button>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
#### Breadcrumb adapter
|
|
256
|
+
|
|
257
|
+
```tsx
|
|
258
|
+
// BreadcrumbAdapter.tsx
|
|
259
|
+
import type { BreadcrumbAdapterProps } from "@tker-react/layout";
|
|
260
|
+
import {
|
|
261
|
+
Breadcrumb,
|
|
262
|
+
BreadcrumbItem,
|
|
263
|
+
BreadcrumbLink,
|
|
264
|
+
BreadcrumbList,
|
|
265
|
+
BreadcrumbSeparator,
|
|
266
|
+
} from "@/components/ui/breadcrumb";
|
|
267
|
+
|
|
268
|
+
function BreadcrumbAdapter({ breadcrumbData, onSelect }: BreadcrumbAdapterProps) {
|
|
269
|
+
return (
|
|
270
|
+
<Breadcrumb>
|
|
271
|
+
<BreadcrumbList>
|
|
272
|
+
{breadcrumbData.map((item, index) => (
|
|
273
|
+
<div key={item.path} className="flex items-center gap-1">
|
|
274
|
+
<BreadcrumbItem>
|
|
275
|
+
<BreadcrumbLink
|
|
276
|
+
asChild
|
|
277
|
+
className={index === breadcrumbData.length - 1 ? "text-foreground" : ""}
|
|
278
|
+
>
|
|
279
|
+
<button
|
|
280
|
+
onClick={() => onSelect(item.path)}
|
|
281
|
+
disabled={index === breadcrumbData.length - 1}
|
|
282
|
+
>
|
|
283
|
+
{item.title}
|
|
284
|
+
</button>
|
|
285
|
+
</BreadcrumbLink>
|
|
286
|
+
</BreadcrumbItem>
|
|
287
|
+
{index < breadcrumbData.length - 1 && <BreadcrumbSeparator />}
|
|
288
|
+
</div>
|
|
289
|
+
))}
|
|
290
|
+
</BreadcrumbList>
|
|
291
|
+
</Breadcrumb>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
#### Tab adapter
|
|
297
|
+
|
|
298
|
+
```tsx
|
|
299
|
+
// TabAdapter.tsx
|
|
300
|
+
import type { TabAdapterProps } from "@tker-react/layout";
|
|
301
|
+
import { cn } from "@/lib/utils";
|
|
302
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
|
303
|
+
|
|
304
|
+
function TabAdapter({
|
|
305
|
+
tabData,
|
|
306
|
+
activeTab,
|
|
307
|
+
onSelect,
|
|
308
|
+
onClose,
|
|
309
|
+
onCloseOther,
|
|
310
|
+
onCloseAll,
|
|
311
|
+
onRefresh,
|
|
312
|
+
}: TabAdapterProps) {
|
|
313
|
+
return (
|
|
314
|
+
<div className="flex items-center h-9 bg-muted border-b">
|
|
315
|
+
<div className="flex-1 flex overflow-x-auto scrollbar-none">
|
|
316
|
+
{tabData.map((tab) => (
|
|
317
|
+
<div
|
|
318
|
+
key={tab.path}
|
|
319
|
+
onClick={() => onSelect(tab.path)}
|
|
320
|
+
className={cn(
|
|
321
|
+
"flex items-center gap-1 px-3 py-1 text-sm cursor-pointer border-r shrink-0",
|
|
322
|
+
tab.path === activeTab
|
|
323
|
+
? "bg-background text-foreground"
|
|
324
|
+
: "text-muted-foreground hover:bg-muted-foreground/10"
|
|
325
|
+
)}
|
|
326
|
+
>
|
|
327
|
+
{tab.icon && <tab.icon className="w-3.5 h-3.5" />}
|
|
328
|
+
<span>{tab.title}</span>
|
|
329
|
+
{!tab.fixed && (
|
|
330
|
+
<button
|
|
331
|
+
onClick={(e) => { e.stopPropagation(); onClose(tab.path); }}
|
|
332
|
+
className="ml-1 rounded-full hover:bg-muted p-0.5"
|
|
333
|
+
>
|
|
334
|
+
<X className="w-3 h-3" />
|
|
335
|
+
</button>
|
|
336
|
+
)}
|
|
337
|
+
</div>
|
|
338
|
+
))}
|
|
339
|
+
</div>
|
|
340
|
+
<button onClick={onRefresh} className="p-2 hover:bg-muted shrink-0">
|
|
341
|
+
<RotateCw className="w-3.5 h-3.5" />
|
|
342
|
+
</button>
|
|
343
|
+
<DropdownMenu>
|
|
344
|
+
<DropdownMenuTrigger className="p-2 hover:bg-muted shrink-0">
|
|
345
|
+
<ChevronDown className="w-3.5 h-3.5" />
|
|
346
|
+
</DropdownMenuTrigger>
|
|
347
|
+
<DropdownMenuContent align="end">
|
|
348
|
+
<DropdownMenuItem onClick={() => onCloseOther(activeTab)}>
|
|
349
|
+
关闭其他
|
|
350
|
+
</DropdownMenuItem>
|
|
351
|
+
<DropdownMenuItem onClick={() => onCloseAll()}>
|
|
352
|
+
关闭全部
|
|
353
|
+
</DropdownMenuItem>
|
|
354
|
+
</DropdownMenuContent>
|
|
355
|
+
</DropdownMenu>
|
|
356
|
+
</div>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
#### Toolbar adapter
|
|
362
|
+
|
|
363
|
+
```tsx
|
|
364
|
+
// ToolbarAdapter.tsx
|
|
365
|
+
import { Button } from "@/components/ui/button";
|
|
366
|
+
|
|
367
|
+
function ToolbarAdapter() {
|
|
368
|
+
return (
|
|
369
|
+
<div className="flex items-center gap-1 px-2">
|
|
370
|
+
<Button variant="ghost" size="icon" className="h-7 w-7">
|
|
371
|
+
<Search className="w-4 h-4" />
|
|
372
|
+
</Button>
|
|
373
|
+
<Button variant="ghost" size="icon" className="h-7 w-7">
|
|
374
|
+
<Bell className="w-4 h-4" />
|
|
375
|
+
</Button>
|
|
376
|
+
<Button variant="ghost" size="icon" className="h-7 w-7">
|
|
377
|
+
<Settings className="w-4 h-4" />
|
|
378
|
+
</Button>
|
|
379
|
+
</div>
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
#### UserAvatar adapter
|
|
385
|
+
|
|
386
|
+
```tsx
|
|
387
|
+
// UserAvatarAdapter.tsx
|
|
388
|
+
import type { UserAvatarAdapterProps } from "@tker-react/layout";
|
|
389
|
+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
390
|
+
import {
|
|
391
|
+
DropdownMenu,
|
|
392
|
+
DropdownMenuContent,
|
|
393
|
+
DropdownMenuItem,
|
|
394
|
+
DropdownMenuSeparator,
|
|
395
|
+
DropdownMenuTrigger,
|
|
396
|
+
} from "@/components/ui/dropdown-menu";
|
|
397
|
+
|
|
398
|
+
function UserAvatarAdapter({ onSettings, onLogout }: UserAvatarAdapterProps) {
|
|
399
|
+
return (
|
|
400
|
+
<DropdownMenu>
|
|
401
|
+
<DropdownMenuTrigger className="outline-none">
|
|
402
|
+
<Avatar className="h-7 w-7 cursor-pointer">
|
|
403
|
+
<AvatarImage src="/avatar.png" />
|
|
404
|
+
<AvatarFallback>U</AvatarFallback>
|
|
405
|
+
</Avatar>
|
|
406
|
+
</DropdownMenuTrigger>
|
|
407
|
+
<DropdownMenuContent align="end" className="w-40">
|
|
408
|
+
<DropdownMenuItem onClick={onSettings}>
|
|
409
|
+
设置
|
|
410
|
+
</DropdownMenuItem>
|
|
411
|
+
<DropdownMenuSeparator />
|
|
412
|
+
<DropdownMenuItem onClick={onLogout}>
|
|
413
|
+
退出登录
|
|
414
|
+
</DropdownMenuItem>
|
|
415
|
+
</DropdownMenuContent>
|
|
416
|
+
</DropdownMenu>
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
## 使用不同路由库
|
|
422
|
+
|
|
423
|
+
以下展示 `SetupLayout` 组件中路由同步部分的写法。
|
|
424
|
+
|
|
425
|
+
### TanStack Router
|
|
426
|
+
|
|
427
|
+
```tsx
|
|
428
|
+
// SetupLayout 中的路由同步部分
|
|
429
|
+
const router = useRouter();
|
|
430
|
+
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
|
431
|
+
const search = useRouterState({ select: (s) => s.location.searchStr });
|
|
432
|
+
|
|
433
|
+
layout.setNavigateAdapter((path) => router.navigate({ to: path }));
|
|
434
|
+
|
|
435
|
+
useLayoutEffect(() => {
|
|
436
|
+
layout.setActivePath(pathname, pathname + search);
|
|
437
|
+
}, [pathname, search]);
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
### React Router v6+
|
|
441
|
+
|
|
442
|
+
```tsx
|
|
443
|
+
// SetupLayout 中的路由同步部分
|
|
444
|
+
const location = useLocation();
|
|
445
|
+
const navigate = useNavigate();
|
|
446
|
+
|
|
447
|
+
layout.setNavigateAdapter((path) => navigate(path));
|
|
448
|
+
|
|
449
|
+
useLayoutEffect(() => {
|
|
450
|
+
layout.setActivePath(location.pathname, location.pathname + location.search);
|
|
451
|
+
}, [location.pathname, location.search]);
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
### Next.js App Router
|
|
455
|
+
|
|
456
|
+
```tsx
|
|
457
|
+
// SetupLayout 中的路由同步部分
|
|
458
|
+
const pathname = usePathname();
|
|
459
|
+
const searchParams = useSearchParams();
|
|
460
|
+
const router = useRouter();
|
|
461
|
+
|
|
462
|
+
layout.setNavigateAdapter((path) => router.push(path));
|
|
463
|
+
|
|
464
|
+
const fullPath = pathname + (searchParams.toString() ? `?${searchParams}` : "");
|
|
465
|
+
|
|
466
|
+
useLayoutEffect(() => {
|
|
467
|
+
layout.setActivePath(pathname, fullPath);
|
|
468
|
+
}, [pathname, fullPath]);
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
## API
|
|
472
|
+
|
|
473
|
+
### `useLayout()`
|
|
474
|
+
|
|
475
|
+
| 方法 | 说明 |
|
|
476
|
+
|------|------|
|
|
477
|
+
| `setMenus(data)` | 设置菜单数据 |
|
|
478
|
+
| `setActivePath(path, fullPath?)` | 设置当前激活路径,自动触发面包屑更新和标签页打开 |
|
|
479
|
+
| `setMenuAdapter(component)` | 注册菜单适配器组件 |
|
|
480
|
+
| `setTabAdapter(component)` | 注册标签页适配器组件 |
|
|
481
|
+
| `setBreadcrumbAdapter(component)` | 注册面包屑适配器组件 |
|
|
482
|
+
| `setLogoAdapter(component)` | 注册 Logo 适配器组件 |
|
|
483
|
+
| `setToolbarAdapter(component)` | 注册工具栏适配器组件 |
|
|
484
|
+
| `setUserAvatarAdapter(component)` | 注册用户头像适配器组件 |
|
|
485
|
+
| `setNavigateAdapter(fn)` | 设置导航回调,Layout 内部点击菜单/tab/面包屑时调用 |
|
|
486
|
+
| `toggleCollapse()` | 切换侧边栏折叠 |
|
|
487
|
+
| `setLayoutMode(mode)` | 设置布局模式:`"side-menu"` 或 `"top-menu"` |
|
|
488
|
+
| `setHomePath(path)` | 设置首页路径,首页 tab 固定在最左侧 |
|
|
489
|
+
| `setMaxTabs(n)` | 设置最大标签页数量,超出后关闭最早的 |
|
|
490
|
+
| `getFullPath(path)` | 根据菜单 path 获取存储的完整路径 |
|
|
491
|
+
|
|
492
|
+
### Adapter 组件 Props
|
|
493
|
+
|
|
494
|
+
| Adapter | Props |
|
|
495
|
+
|---------|-------|
|
|
496
|
+
| `menu` | `menuData`, `activePath`, `collapsed`, `mode` (`"side"|"top"`), `width`, `onSelect(path)` |
|
|
497
|
+
| `tab` | `tabData`, `activeTab`, `onSelect(path)`, `onClose(path)`, `onCloseOther(path?)`, `onCloseAll()`, `onRefresh()`, `onSetFixed(path, fixed)` |
|
|
498
|
+
| `breadcrumb` | `breadcrumbData`, `onSelect(path)` |
|
|
499
|
+
| `logo` | `collapsed`, `width` |
|
|
500
|
+
| `toolbar` | 无 props |
|
|
501
|
+
| `userAvatar` | `onSettings?()`, `onLogout?()` |
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import { ReactNode, ComponentType } from 'react';
|
|
3
|
+
|
|
4
|
+
interface LayoutProps {
|
|
5
|
+
children?: ReactNode;
|
|
6
|
+
}
|
|
7
|
+
declare function Layout({ children }: LayoutProps): react_jsx_runtime.JSX.Element;
|
|
8
|
+
|
|
9
|
+
interface BreadcrumbItem {
|
|
10
|
+
path: string;
|
|
11
|
+
title: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface MenuItem {
|
|
15
|
+
path: string;
|
|
16
|
+
title?: string;
|
|
17
|
+
label?: string;
|
|
18
|
+
name?: string;
|
|
19
|
+
icon?: ComponentType | string;
|
|
20
|
+
children?: MenuItem[];
|
|
21
|
+
[key: string]: any;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface TabItem {
|
|
25
|
+
path: string;
|
|
26
|
+
title: string;
|
|
27
|
+
icon?: ComponentType | string;
|
|
28
|
+
fixed?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface LogoAdapterProps {
|
|
32
|
+
collapsed: boolean;
|
|
33
|
+
width: string;
|
|
34
|
+
}
|
|
35
|
+
interface MenuAdapterProps {
|
|
36
|
+
menuData: MenuItem[];
|
|
37
|
+
activePath: string;
|
|
38
|
+
collapsed: boolean;
|
|
39
|
+
mode: "side" | "top";
|
|
40
|
+
width: string;
|
|
41
|
+
onSelect: (path: string) => void;
|
|
42
|
+
}
|
|
43
|
+
interface BreadcrumbAdapterProps {
|
|
44
|
+
breadcrumbData: BreadcrumbItem[];
|
|
45
|
+
onSelect: (path: string) => void;
|
|
46
|
+
}
|
|
47
|
+
interface TabAdapterProps {
|
|
48
|
+
tabData: TabItem[];
|
|
49
|
+
activeTab: string;
|
|
50
|
+
onSelect: (path: string) => void;
|
|
51
|
+
onClose: (path: string) => void;
|
|
52
|
+
onCloseOther: (path?: string) => void;
|
|
53
|
+
onCloseAll: () => void;
|
|
54
|
+
onRefresh: () => void;
|
|
55
|
+
onSetFixed: (path: string, fixed: boolean) => void;
|
|
56
|
+
}
|
|
57
|
+
interface UserAvatarAdapterProps {
|
|
58
|
+
onSettings?: () => void;
|
|
59
|
+
onLogout?: () => void;
|
|
60
|
+
}
|
|
61
|
+
interface LayoutAdapters {
|
|
62
|
+
logo?: ComponentType<LogoAdapterProps>;
|
|
63
|
+
menu?: ComponentType<MenuAdapterProps>;
|
|
64
|
+
breadcrumb?: ComponentType<BreadcrumbAdapterProps>;
|
|
65
|
+
tab?: ComponentType<TabAdapterProps>;
|
|
66
|
+
toolbar?: ComponentType;
|
|
67
|
+
userAvatar?: ComponentType<UserAvatarAdapterProps>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface LayoutContextValue {
|
|
71
|
+
adapters: LayoutAdapters;
|
|
72
|
+
setMenuAdapter: (component: ComponentType<MenuAdapterProps>) => void;
|
|
73
|
+
setTabAdapter: (component: ComponentType<TabAdapterProps>) => void;
|
|
74
|
+
setBreadcrumbAdapter: (component: ComponentType<BreadcrumbAdapterProps>) => void;
|
|
75
|
+
setLogoAdapter: (component: ComponentType<LogoAdapterProps>) => void;
|
|
76
|
+
setToolbarAdapter: (component: ComponentType) => void;
|
|
77
|
+
setUserAvatarAdapter: (component: ComponentType<UserAvatarAdapterProps>) => void;
|
|
78
|
+
menuData: MenuItem[];
|
|
79
|
+
activePath: string;
|
|
80
|
+
collapsed: boolean;
|
|
81
|
+
expandedWidth: string;
|
|
82
|
+
collapsedWidth: string;
|
|
83
|
+
layoutMode: "side-menu" | "top-menu";
|
|
84
|
+
setMenus: (data: MenuItem[]) => void;
|
|
85
|
+
setActivePath: (path: string, fullPath?: string) => void;
|
|
86
|
+
toggleCollapse: () => void;
|
|
87
|
+
setCollapsed: (collapsed: boolean) => void;
|
|
88
|
+
setExpandedWidth: (width: string) => void;
|
|
89
|
+
setCollapsedWidth: (width: string) => void;
|
|
90
|
+
setLayoutMode: (mode: "side-menu" | "top-menu") => void;
|
|
91
|
+
isConcretePage: (path: string) => boolean;
|
|
92
|
+
getFullPath: (path: string) => string;
|
|
93
|
+
setNavigateAdapter: (fn: (path: string) => void) => void;
|
|
94
|
+
navigate: (path: string) => void;
|
|
95
|
+
homePath: string;
|
|
96
|
+
setHomePath: (path: string) => void;
|
|
97
|
+
maxTabs: number;
|
|
98
|
+
setMaxTabs: (max: number) => void;
|
|
99
|
+
}
|
|
100
|
+
declare function useLayoutContext(): LayoutContextValue;
|
|
101
|
+
declare function LayoutProvider({ children }: {
|
|
102
|
+
children: ReactNode;
|
|
103
|
+
}): react_jsx_runtime.JSX.Element;
|
|
104
|
+
|
|
105
|
+
export { Layout, LayoutProvider, useLayoutContext as useLayout, useLayoutContext };
|
|
106
|
+
export type { BreadcrumbAdapterProps, BreadcrumbItem, LayoutAdapters, LogoAdapterProps, MenuAdapterProps, MenuItem, TabAdapterProps, TabItem, UserAvatarAdapterProps };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import { ReactNode, ComponentType } from 'react';
|
|
3
|
+
|
|
4
|
+
interface LayoutProps {
|
|
5
|
+
children?: ReactNode;
|
|
6
|
+
}
|
|
7
|
+
declare function Layout({ children }: LayoutProps): react_jsx_runtime.JSX.Element;
|
|
8
|
+
|
|
9
|
+
interface BreadcrumbItem {
|
|
10
|
+
path: string;
|
|
11
|
+
title: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface MenuItem {
|
|
15
|
+
path: string;
|
|
16
|
+
title?: string;
|
|
17
|
+
label?: string;
|
|
18
|
+
name?: string;
|
|
19
|
+
icon?: ComponentType | string;
|
|
20
|
+
children?: MenuItem[];
|
|
21
|
+
[key: string]: any;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface TabItem {
|
|
25
|
+
path: string;
|
|
26
|
+
title: string;
|
|
27
|
+
icon?: ComponentType | string;
|
|
28
|
+
fixed?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface LogoAdapterProps {
|
|
32
|
+
collapsed: boolean;
|
|
33
|
+
width: string;
|
|
34
|
+
}
|
|
35
|
+
interface MenuAdapterProps {
|
|
36
|
+
menuData: MenuItem[];
|
|
37
|
+
activePath: string;
|
|
38
|
+
collapsed: boolean;
|
|
39
|
+
mode: "side" | "top";
|
|
40
|
+
width: string;
|
|
41
|
+
onSelect: (path: string) => void;
|
|
42
|
+
}
|
|
43
|
+
interface BreadcrumbAdapterProps {
|
|
44
|
+
breadcrumbData: BreadcrumbItem[];
|
|
45
|
+
onSelect: (path: string) => void;
|
|
46
|
+
}
|
|
47
|
+
interface TabAdapterProps {
|
|
48
|
+
tabData: TabItem[];
|
|
49
|
+
activeTab: string;
|
|
50
|
+
onSelect: (path: string) => void;
|
|
51
|
+
onClose: (path: string) => void;
|
|
52
|
+
onCloseOther: (path?: string) => void;
|
|
53
|
+
onCloseAll: () => void;
|
|
54
|
+
onRefresh: () => void;
|
|
55
|
+
onSetFixed: (path: string, fixed: boolean) => void;
|
|
56
|
+
}
|
|
57
|
+
interface UserAvatarAdapterProps {
|
|
58
|
+
onSettings?: () => void;
|
|
59
|
+
onLogout?: () => void;
|
|
60
|
+
}
|
|
61
|
+
interface LayoutAdapters {
|
|
62
|
+
logo?: ComponentType<LogoAdapterProps>;
|
|
63
|
+
menu?: ComponentType<MenuAdapterProps>;
|
|
64
|
+
breadcrumb?: ComponentType<BreadcrumbAdapterProps>;
|
|
65
|
+
tab?: ComponentType<TabAdapterProps>;
|
|
66
|
+
toolbar?: ComponentType;
|
|
67
|
+
userAvatar?: ComponentType<UserAvatarAdapterProps>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface LayoutContextValue {
|
|
71
|
+
adapters: LayoutAdapters;
|
|
72
|
+
setMenuAdapter: (component: ComponentType<MenuAdapterProps>) => void;
|
|
73
|
+
setTabAdapter: (component: ComponentType<TabAdapterProps>) => void;
|
|
74
|
+
setBreadcrumbAdapter: (component: ComponentType<BreadcrumbAdapterProps>) => void;
|
|
75
|
+
setLogoAdapter: (component: ComponentType<LogoAdapterProps>) => void;
|
|
76
|
+
setToolbarAdapter: (component: ComponentType) => void;
|
|
77
|
+
setUserAvatarAdapter: (component: ComponentType<UserAvatarAdapterProps>) => void;
|
|
78
|
+
menuData: MenuItem[];
|
|
79
|
+
activePath: string;
|
|
80
|
+
collapsed: boolean;
|
|
81
|
+
expandedWidth: string;
|
|
82
|
+
collapsedWidth: string;
|
|
83
|
+
layoutMode: "side-menu" | "top-menu";
|
|
84
|
+
setMenus: (data: MenuItem[]) => void;
|
|
85
|
+
setActivePath: (path: string, fullPath?: string) => void;
|
|
86
|
+
toggleCollapse: () => void;
|
|
87
|
+
setCollapsed: (collapsed: boolean) => void;
|
|
88
|
+
setExpandedWidth: (width: string) => void;
|
|
89
|
+
setCollapsedWidth: (width: string) => void;
|
|
90
|
+
setLayoutMode: (mode: "side-menu" | "top-menu") => void;
|
|
91
|
+
isConcretePage: (path: string) => boolean;
|
|
92
|
+
getFullPath: (path: string) => string;
|
|
93
|
+
setNavigateAdapter: (fn: (path: string) => void) => void;
|
|
94
|
+
navigate: (path: string) => void;
|
|
95
|
+
homePath: string;
|
|
96
|
+
setHomePath: (path: string) => void;
|
|
97
|
+
maxTabs: number;
|
|
98
|
+
setMaxTabs: (max: number) => void;
|
|
99
|
+
}
|
|
100
|
+
declare function useLayoutContext(): LayoutContextValue;
|
|
101
|
+
declare function LayoutProvider({ children }: {
|
|
102
|
+
children: ReactNode;
|
|
103
|
+
}): react_jsx_runtime.JSX.Element;
|
|
104
|
+
|
|
105
|
+
export { Layout, LayoutProvider, useLayoutContext as useLayout, useLayoutContext };
|
|
106
|
+
export type { BreadcrumbAdapterProps, BreadcrumbItem, LayoutAdapters, LogoAdapterProps, MenuAdapterProps, MenuItem, TabAdapterProps, TabItem, UserAvatarAdapterProps };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
import "./layout.css";
|
|
2
|
+
import { createContext, useContext, useRef, useCallback, useState, useMemo, useEffect } from 'react';
|
|
3
|
+
|
|
4
|
+
function findMenuItemByPath(menuData, path) {
|
|
5
|
+
for (const item of menuData) {
|
|
6
|
+
if (item.path === path) {
|
|
7
|
+
return item;
|
|
8
|
+
}
|
|
9
|
+
if (item.children) {
|
|
10
|
+
const found = findMenuItemByPath(item.children, path);
|
|
11
|
+
if (found) return found;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
function getParentChain(menuData, path) {
|
|
17
|
+
const chain = [];
|
|
18
|
+
function traverse(items, target, ancestors) {
|
|
19
|
+
for (const item of items) {
|
|
20
|
+
if (item.path === target) {
|
|
21
|
+
chain.push(...ancestors, item);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
if (item.children && traverse(item.children, target, [...ancestors, item])) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
traverse(menuData, path, []);
|
|
31
|
+
return chain;
|
|
32
|
+
}
|
|
33
|
+
function getMenuItemTitle(item) {
|
|
34
|
+
return item.title || item.label || item.name || item.path;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function generateBreadcrumb(menuData, activePath) {
|
|
38
|
+
const chain = getParentChain(menuData, activePath);
|
|
39
|
+
return chain.map((item) => ({
|
|
40
|
+
path: item.path,
|
|
41
|
+
title: getMenuItemTitle(item)
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const LayoutContext = createContext(null);
|
|
46
|
+
function useLayoutContext() {
|
|
47
|
+
const ctx = useContext(LayoutContext);
|
|
48
|
+
if (!ctx) {
|
|
49
|
+
throw new Error("useLayoutContext \u5FC5\u987B\u5728 <Layout> \u5185\u90E8\u4F7F\u7528");
|
|
50
|
+
}
|
|
51
|
+
return ctx;
|
|
52
|
+
}
|
|
53
|
+
function LayoutProvider({ children }) {
|
|
54
|
+
const navigateRef = useRef(null);
|
|
55
|
+
const setNavigateAdapter = useCallback(
|
|
56
|
+
(fn) => {
|
|
57
|
+
navigateRef.current = fn;
|
|
58
|
+
},
|
|
59
|
+
[]
|
|
60
|
+
);
|
|
61
|
+
const navigate = useCallback(
|
|
62
|
+
(path) => {
|
|
63
|
+
navigateRef.current?.(path);
|
|
64
|
+
},
|
|
65
|
+
[]
|
|
66
|
+
);
|
|
67
|
+
const [adapters, setAdapters] = useState({});
|
|
68
|
+
const setMenuAdapter = useCallback(
|
|
69
|
+
(c) => setAdapters((prev) => prev.menu === c ? prev : { ...prev, menu: c }),
|
|
70
|
+
[]
|
|
71
|
+
);
|
|
72
|
+
const setTabAdapter = useCallback(
|
|
73
|
+
(c) => setAdapters((prev) => prev.tab === c ? prev : { ...prev, tab: c }),
|
|
74
|
+
[]
|
|
75
|
+
);
|
|
76
|
+
const setBreadcrumbAdapter = useCallback(
|
|
77
|
+
(c) => setAdapters((prev) => prev.breadcrumb === c ? prev : { ...prev, breadcrumb: c }),
|
|
78
|
+
[]
|
|
79
|
+
);
|
|
80
|
+
const setLogoAdapter = useCallback(
|
|
81
|
+
(c) => setAdapters((prev) => prev.logo === c ? prev : { ...prev, logo: c }),
|
|
82
|
+
[]
|
|
83
|
+
);
|
|
84
|
+
const setToolbarAdapter = useCallback(
|
|
85
|
+
(c) => setAdapters((prev) => prev.toolbar === c ? prev : { ...prev, toolbar: c }),
|
|
86
|
+
[]
|
|
87
|
+
);
|
|
88
|
+
const setUserAvatarAdapter = useCallback(
|
|
89
|
+
(c) => setAdapters((prev) => prev.userAvatar === c ? prev : { ...prev, userAvatar: c }),
|
|
90
|
+
[]
|
|
91
|
+
);
|
|
92
|
+
const [menuData, setMenuData] = useState([]);
|
|
93
|
+
const [activePath, setActivePathState] = useState("");
|
|
94
|
+
const [collapsed, setCollapsedState] = useState(false);
|
|
95
|
+
const [expandedWidth, setExpandedWidth] = useState("200px");
|
|
96
|
+
const [collapsedWidth, setCollapsedWidth] = useState("64px");
|
|
97
|
+
const [layoutMode, setLayoutMode] = useState(
|
|
98
|
+
"side-menu"
|
|
99
|
+
);
|
|
100
|
+
const setMenus = useCallback((data) => setMenuData(data), []);
|
|
101
|
+
const toggleCollapse = useCallback(
|
|
102
|
+
() => setCollapsedState((p) => !p),
|
|
103
|
+
[]
|
|
104
|
+
);
|
|
105
|
+
const pathParamsMap = useRef(/* @__PURE__ */ new Map());
|
|
106
|
+
const storePathParams = useCallback((path, fullPath) => {
|
|
107
|
+
if (fullPath && fullPath !== path) {
|
|
108
|
+
pathParamsMap.current.set(path, fullPath);
|
|
109
|
+
}
|
|
110
|
+
}, []);
|
|
111
|
+
const getFullPath = useCallback(
|
|
112
|
+
(path) => pathParamsMap.current.get(path) || path,
|
|
113
|
+
[]
|
|
114
|
+
);
|
|
115
|
+
const setActivePath = useCallback(
|
|
116
|
+
(path, fullPath) => {
|
|
117
|
+
setActivePathState(path);
|
|
118
|
+
if (fullPath) storePathParams(path, fullPath);
|
|
119
|
+
},
|
|
120
|
+
[storePathParams]
|
|
121
|
+
);
|
|
122
|
+
const isConcretePage = useCallback(
|
|
123
|
+
(path) => {
|
|
124
|
+
const item = findMenuItemByPath(menuData, path);
|
|
125
|
+
return !item?.children || item.children.length === 0;
|
|
126
|
+
},
|
|
127
|
+
[menuData]
|
|
128
|
+
);
|
|
129
|
+
const [homePath, setHomePathState] = useState("");
|
|
130
|
+
const [maxTabs, setMaxTabsState] = useState(10);
|
|
131
|
+
const setHomePath = useCallback((path) => {
|
|
132
|
+
setHomePathState(path);
|
|
133
|
+
}, []);
|
|
134
|
+
const setMaxTabs = useCallback((max) => {
|
|
135
|
+
setMaxTabsState(max);
|
|
136
|
+
}, []);
|
|
137
|
+
const value = useMemo(
|
|
138
|
+
() => ({
|
|
139
|
+
adapters,
|
|
140
|
+
setMenuAdapter,
|
|
141
|
+
setTabAdapter,
|
|
142
|
+
setBreadcrumbAdapter,
|
|
143
|
+
setLogoAdapter,
|
|
144
|
+
setToolbarAdapter,
|
|
145
|
+
setUserAvatarAdapter,
|
|
146
|
+
menuData,
|
|
147
|
+
activePath,
|
|
148
|
+
collapsed,
|
|
149
|
+
expandedWidth,
|
|
150
|
+
collapsedWidth,
|
|
151
|
+
layoutMode,
|
|
152
|
+
setMenus,
|
|
153
|
+
setActivePath,
|
|
154
|
+
toggleCollapse,
|
|
155
|
+
setCollapsed: setCollapsedState,
|
|
156
|
+
setExpandedWidth,
|
|
157
|
+
setCollapsedWidth,
|
|
158
|
+
setLayoutMode,
|
|
159
|
+
isConcretePage,
|
|
160
|
+
getFullPath,
|
|
161
|
+
setNavigateAdapter,
|
|
162
|
+
navigate,
|
|
163
|
+
homePath,
|
|
164
|
+
setHomePath,
|
|
165
|
+
maxTabs,
|
|
166
|
+
setMaxTabs
|
|
167
|
+
}),
|
|
168
|
+
[
|
|
169
|
+
adapters,
|
|
170
|
+
setMenuAdapter,
|
|
171
|
+
setTabAdapter,
|
|
172
|
+
setBreadcrumbAdapter,
|
|
173
|
+
setLogoAdapter,
|
|
174
|
+
setToolbarAdapter,
|
|
175
|
+
setUserAvatarAdapter,
|
|
176
|
+
menuData,
|
|
177
|
+
activePath,
|
|
178
|
+
collapsed,
|
|
179
|
+
expandedWidth,
|
|
180
|
+
collapsedWidth,
|
|
181
|
+
layoutMode,
|
|
182
|
+
setMenus,
|
|
183
|
+
setActivePath,
|
|
184
|
+
toggleCollapse,
|
|
185
|
+
isConcretePage,
|
|
186
|
+
getFullPath,
|
|
187
|
+
setNavigateAdapter,
|
|
188
|
+
navigate,
|
|
189
|
+
homePath,
|
|
190
|
+
setHomePath,
|
|
191
|
+
maxTabs,
|
|
192
|
+
setMaxTabs
|
|
193
|
+
]
|
|
194
|
+
);
|
|
195
|
+
return /* @__PURE__ */ React.createElement(LayoutContext.Provider, { value }, children);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function LayoutAside() {
|
|
199
|
+
const {
|
|
200
|
+
adapters,
|
|
201
|
+
collapsed,
|
|
202
|
+
expandedWidth,
|
|
203
|
+
collapsedWidth,
|
|
204
|
+
menuData,
|
|
205
|
+
activePath,
|
|
206
|
+
layoutMode,
|
|
207
|
+
getFullPath,
|
|
208
|
+
navigate,
|
|
209
|
+
toggleCollapse
|
|
210
|
+
} = useLayoutContext();
|
|
211
|
+
const menuMode = layoutMode === "top-menu" ? "top" : "side";
|
|
212
|
+
const handleMenuSelect = useCallback(
|
|
213
|
+
(path) => {
|
|
214
|
+
const fullPath = getFullPath(path);
|
|
215
|
+
if (window.location.pathname + window.location.search === fullPath) return;
|
|
216
|
+
navigate(fullPath);
|
|
217
|
+
},
|
|
218
|
+
[getFullPath, navigate]
|
|
219
|
+
);
|
|
220
|
+
const MenuAdapter = adapters.menu;
|
|
221
|
+
if (!MenuAdapter) return null;
|
|
222
|
+
const asideWidth = collapsed ? collapsedWidth : expandedWidth;
|
|
223
|
+
return /* @__PURE__ */ React.createElement("aside", { className: "tker-layout-aside", style: { width: asideWidth } }, /* @__PURE__ */ React.createElement(
|
|
224
|
+
MenuAdapter,
|
|
225
|
+
{
|
|
226
|
+
menuData,
|
|
227
|
+
activePath,
|
|
228
|
+
collapsed,
|
|
229
|
+
mode: menuMode,
|
|
230
|
+
width: asideWidth,
|
|
231
|
+
onSelect: handleMenuSelect
|
|
232
|
+
}
|
|
233
|
+
), /* @__PURE__ */ React.createElement(
|
|
234
|
+
"div",
|
|
235
|
+
{
|
|
236
|
+
className: "tker-layout-aside__toggle-btn",
|
|
237
|
+
onClick: toggleCollapse
|
|
238
|
+
},
|
|
239
|
+
collapsed ? /* @__PURE__ */ React.createElement("svg", { viewBox: "0 0 16 16", fill: "none", width: "16", height: "16" }, /* @__PURE__ */ React.createElement(
|
|
240
|
+
"path",
|
|
241
|
+
{
|
|
242
|
+
d: "M5.64645 3.14645C5.45118 3.34171 5.45118 3.65829 5.64645 3.85355L9.79289 8L5.64645 12.1464C5.45118 12.3417 5.45118 12.6583 5.64645 12.8536C5.84171 13.0488 6.15829 13.0488 6.35355 12.8536L10.8536 8.35355C11.0488 8.15829 11.0488 7.84171 10.8536 7.64645L6.35355 3.14645C6.15829 2.95118 5.84171 2.95118 5.64645 3.14645Z",
|
|
243
|
+
fill: "currentColor"
|
|
244
|
+
}
|
|
245
|
+
)) : /* @__PURE__ */ React.createElement("svg", { viewBox: "0 0 16 16", fill: "none", width: "16", height: "16" }, /* @__PURE__ */ React.createElement(
|
|
246
|
+
"path",
|
|
247
|
+
{
|
|
248
|
+
d: "M10.3536 3.14645C10.5488 3.34171 10.5488 3.65829 10.3536 3.85355L6.20711 8L10.3536 12.1464C10.5488 12.3417 10.5488 12.6583 10.3536 12.8536C10.1583 13.0488 9.84171 13.0488 9.64645 12.8536L5.14645 8.35355C4.95118 8.15829 4.95118 7.84171 5.14645 7.64645L9.64645 3.14645C9.84171 2.95118 10.1583 2.95118 10.3536 3.14645Z",
|
|
249
|
+
fill: "currentColor"
|
|
250
|
+
}
|
|
251
|
+
))
|
|
252
|
+
));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function LayoutContent({ children }) {
|
|
256
|
+
const {
|
|
257
|
+
adapters,
|
|
258
|
+
menuData,
|
|
259
|
+
activePath,
|
|
260
|
+
layoutMode,
|
|
261
|
+
homePath,
|
|
262
|
+
maxTabs,
|
|
263
|
+
getFullPath,
|
|
264
|
+
navigate
|
|
265
|
+
} = useLayoutContext();
|
|
266
|
+
const isTopMenu = layoutMode === "top-menu";
|
|
267
|
+
const [tabData, setTabData] = useState([]);
|
|
268
|
+
const [activeTab, setActiveTab] = useState("");
|
|
269
|
+
const [refreshKey, setRefreshKey] = useState(0);
|
|
270
|
+
const menuDataRef = useRef(menuData);
|
|
271
|
+
menuDataRef.current = menuData;
|
|
272
|
+
const activeTabRef = useRef(activeTab);
|
|
273
|
+
activeTabRef.current = activeTab;
|
|
274
|
+
useEffect(() => {
|
|
275
|
+
if (!activePath || activePath === "/") return;
|
|
276
|
+
setTabData((prev) => {
|
|
277
|
+
const exists = prev.find((t) => t.path === activePath);
|
|
278
|
+
if (exists) {
|
|
279
|
+
setActiveTab(activePath);
|
|
280
|
+
return prev;
|
|
281
|
+
}
|
|
282
|
+
return openTabInData(prev, activePath, {
|
|
283
|
+
homePath,
|
|
284
|
+
maxTabs,
|
|
285
|
+
menuData: menuDataRef.current
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
setActiveTab(activePath);
|
|
289
|
+
}, [activePath, homePath, maxTabs]);
|
|
290
|
+
const handleTabSelect = useCallback(
|
|
291
|
+
(path) => {
|
|
292
|
+
const fullPath = getFullPath(path);
|
|
293
|
+
if (window.location.pathname + window.location.search === fullPath) return;
|
|
294
|
+
navigate(fullPath);
|
|
295
|
+
},
|
|
296
|
+
[getFullPath, navigate]
|
|
297
|
+
);
|
|
298
|
+
const handleTabClose = useCallback(
|
|
299
|
+
(path) => {
|
|
300
|
+
setTabData((prev) => {
|
|
301
|
+
const tab = prev.find((t) => t.path === path);
|
|
302
|
+
if (tab?.fixed) return prev;
|
|
303
|
+
const idx = prev.findIndex((t) => t.path === path);
|
|
304
|
+
if (idx === -1) return prev;
|
|
305
|
+
const next = [...prev];
|
|
306
|
+
next.splice(idx, 1);
|
|
307
|
+
if (activeTabRef.current === path) {
|
|
308
|
+
const last = next[next.length - 1];
|
|
309
|
+
if (last) {
|
|
310
|
+
setActiveTab(last.path);
|
|
311
|
+
navigate(getFullPath(last.path));
|
|
312
|
+
} else if (homePath) {
|
|
313
|
+
setActiveTab(homePath);
|
|
314
|
+
navigate(homePath);
|
|
315
|
+
} else {
|
|
316
|
+
setActiveTab("");
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return next;
|
|
320
|
+
});
|
|
321
|
+
},
|
|
322
|
+
[getFullPath, navigate, homePath]
|
|
323
|
+
);
|
|
324
|
+
const handleCloseOtherTabs = useCallback(
|
|
325
|
+
(path) => {
|
|
326
|
+
setTabData((prev) => {
|
|
327
|
+
const target = path || activeTabRef.current;
|
|
328
|
+
const filtered = prev.filter((t) => t.path === target || t.fixed);
|
|
329
|
+
setActiveTab(target);
|
|
330
|
+
return filtered;
|
|
331
|
+
});
|
|
332
|
+
},
|
|
333
|
+
[]
|
|
334
|
+
);
|
|
335
|
+
const handleCloseAllTabs = useCallback(() => {
|
|
336
|
+
setTabData((prev) => {
|
|
337
|
+
const fixed = prev.filter((t) => t.fixed);
|
|
338
|
+
const first = fixed[0];
|
|
339
|
+
if (first) {
|
|
340
|
+
setActiveTab(first.path);
|
|
341
|
+
navigate(getFullPath(first.path));
|
|
342
|
+
} else {
|
|
343
|
+
setActiveTab("");
|
|
344
|
+
}
|
|
345
|
+
return fixed;
|
|
346
|
+
});
|
|
347
|
+
}, [getFullPath, navigate]);
|
|
348
|
+
const handleSetFixed = useCallback((path, fixed) => {
|
|
349
|
+
setTabData(
|
|
350
|
+
(prev) => prev.map((t) => t.path === path ? { ...t, fixed } : t)
|
|
351
|
+
);
|
|
352
|
+
}, []);
|
|
353
|
+
const handleRefresh = useCallback(() => {
|
|
354
|
+
setRefreshKey((k) => k + 1);
|
|
355
|
+
}, []);
|
|
356
|
+
const TabAdapter = adapters.tab;
|
|
357
|
+
return /* @__PURE__ */ React.createElement("main", { className: "tker-layout-content" }, !isTopMenu && TabAdapter && /* @__PURE__ */ React.createElement(
|
|
358
|
+
TabAdapter,
|
|
359
|
+
{
|
|
360
|
+
tabData,
|
|
361
|
+
activeTab,
|
|
362
|
+
onSelect: handleTabSelect,
|
|
363
|
+
onClose: handleTabClose,
|
|
364
|
+
onCloseOther: handleCloseOtherTabs,
|
|
365
|
+
onCloseAll: handleCloseAllTabs,
|
|
366
|
+
onRefresh: handleRefresh,
|
|
367
|
+
onSetFixed: handleSetFixed
|
|
368
|
+
}
|
|
369
|
+
), /* @__PURE__ */ React.createElement("div", { className: "tker-layout-content__body", key: refreshKey }, children));
|
|
370
|
+
}
|
|
371
|
+
function openTabInData(tabData, path, config) {
|
|
372
|
+
const { homePath, maxTabs, menuData } = config;
|
|
373
|
+
const next = [...tabData];
|
|
374
|
+
if (homePath) {
|
|
375
|
+
const homeIdx = next.findIndex((t) => t.path === homePath);
|
|
376
|
+
if (homeIdx === -1) {
|
|
377
|
+
const homeMenuItem = findMenuItemByPath(menuData, homePath);
|
|
378
|
+
const homeTitle = homeMenuItem ? homeMenuItem.title || homePath : "\u9996\u9875";
|
|
379
|
+
next.unshift({
|
|
380
|
+
path: homePath,
|
|
381
|
+
title: homeTitle,
|
|
382
|
+
icon: homeMenuItem?.icon,
|
|
383
|
+
fixed: true
|
|
384
|
+
});
|
|
385
|
+
} else if (homeIdx > 0) {
|
|
386
|
+
const [homeTab] = next.splice(homeIdx, 1);
|
|
387
|
+
next.unshift(homeTab);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const exists = next.find((t) => t.path === path);
|
|
391
|
+
if (exists) return next;
|
|
392
|
+
const menuItem = findMenuItemByPath(menuData, path);
|
|
393
|
+
const title = menuItem ? menuItem.title || menuItem.label || menuItem.name || path : path;
|
|
394
|
+
const icon = menuItem?.icon;
|
|
395
|
+
const isHome = path === homePath;
|
|
396
|
+
if (!isHome) {
|
|
397
|
+
const nonFixed = next.filter((t) => !t.fixed);
|
|
398
|
+
if (nonFixed.length >= maxTabs) {
|
|
399
|
+
const oldest = nonFixed[0];
|
|
400
|
+
if (oldest) {
|
|
401
|
+
const oldestIdx = next.findIndex((t) => t.path === oldest.path);
|
|
402
|
+
next.splice(oldestIdx, 1);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (isHome) {
|
|
407
|
+
next.unshift({ path, title, icon, fixed: true });
|
|
408
|
+
} else {
|
|
409
|
+
next.push({ path, title, icon, fixed: false });
|
|
410
|
+
}
|
|
411
|
+
return next;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function LayoutHeader() {
|
|
415
|
+
const {
|
|
416
|
+
adapters,
|
|
417
|
+
collapsed,
|
|
418
|
+
expandedWidth,
|
|
419
|
+
collapsedWidth,
|
|
420
|
+
menuData,
|
|
421
|
+
activePath,
|
|
422
|
+
layoutMode,
|
|
423
|
+
getFullPath,
|
|
424
|
+
isConcretePage,
|
|
425
|
+
navigate
|
|
426
|
+
} = useLayoutContext();
|
|
427
|
+
const isTopMenu = layoutMode === "top-menu";
|
|
428
|
+
const isSideMenu = layoutMode === "side-menu";
|
|
429
|
+
const breadcrumbData = useMemo(
|
|
430
|
+
() => generateBreadcrumb(menuData, activePath),
|
|
431
|
+
[menuData, activePath]
|
|
432
|
+
);
|
|
433
|
+
const handleMenuSelect = useCallback(
|
|
434
|
+
(path) => {
|
|
435
|
+
const fullPath = getFullPath(path);
|
|
436
|
+
if (window.location.pathname + window.location.search === fullPath) return;
|
|
437
|
+
navigate(fullPath);
|
|
438
|
+
},
|
|
439
|
+
[getFullPath, navigate]
|
|
440
|
+
);
|
|
441
|
+
const handleBreadcrumbSelect = useCallback(
|
|
442
|
+
(path) => {
|
|
443
|
+
if (!isConcretePage(path)) return;
|
|
444
|
+
const fullPath = getFullPath(path);
|
|
445
|
+
if (window.location.pathname + window.location.search === fullPath) return;
|
|
446
|
+
navigate(fullPath);
|
|
447
|
+
},
|
|
448
|
+
[getFullPath, navigate, isConcretePage]
|
|
449
|
+
);
|
|
450
|
+
const leftContent = isTopMenu ? ["logo", "menu"] : ["logo", "breadcrumb"];
|
|
451
|
+
const logoWidth = isSideMenu ? collapsed ? collapsedWidth : expandedWidth : expandedWidth;
|
|
452
|
+
const LogoAdapter = adapters.logo;
|
|
453
|
+
const MenuAdapter = adapters.menu;
|
|
454
|
+
const BreadcrumbAdapter = adapters.breadcrumb;
|
|
455
|
+
const ToolbarAdapter = adapters.toolbar;
|
|
456
|
+
const UserAvatarAdapter = adapters.userAvatar;
|
|
457
|
+
return /* @__PURE__ */ React.createElement("header", { className: "tker-layout-header" }, /* @__PURE__ */ React.createElement("div", { className: "tker-layout-header__left" }, LogoAdapter && leftContent.includes("logo") && /* @__PURE__ */ React.createElement(
|
|
458
|
+
"div",
|
|
459
|
+
{
|
|
460
|
+
className: "tker-layout-header__left-logo",
|
|
461
|
+
style: { width: logoWidth }
|
|
462
|
+
},
|
|
463
|
+
/* @__PURE__ */ React.createElement(LogoAdapter, { collapsed, width: logoWidth })
|
|
464
|
+
), MenuAdapter && isTopMenu && /* @__PURE__ */ React.createElement(
|
|
465
|
+
MenuAdapter,
|
|
466
|
+
{
|
|
467
|
+
menuData,
|
|
468
|
+
activePath,
|
|
469
|
+
collapsed: false,
|
|
470
|
+
mode: "top",
|
|
471
|
+
width: logoWidth,
|
|
472
|
+
onSelect: handleMenuSelect
|
|
473
|
+
}
|
|
474
|
+
), BreadcrumbAdapter && isSideMenu && /* @__PURE__ */ React.createElement(
|
|
475
|
+
BreadcrumbAdapter,
|
|
476
|
+
{
|
|
477
|
+
breadcrumbData,
|
|
478
|
+
onSelect: handleBreadcrumbSelect
|
|
479
|
+
}
|
|
480
|
+
)), /* @__PURE__ */ React.createElement("div", { className: "tker-layout-header__right" }, ToolbarAdapter && /* @__PURE__ */ React.createElement(ToolbarAdapter, null), UserAvatarAdapter && /* @__PURE__ */ React.createElement(UserAvatarAdapter, null)));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function LayoutInner({ children }) {
|
|
484
|
+
const { layoutMode } = useLayoutContext();
|
|
485
|
+
const isSideMenu = layoutMode === "side-menu";
|
|
486
|
+
return /* @__PURE__ */ React.createElement("div", { className: "tker-layout" }, /* @__PURE__ */ React.createElement(LayoutHeader, null), /* @__PURE__ */ React.createElement("div", { className: "tker-layout__body" }, isSideMenu && /* @__PURE__ */ React.createElement(LayoutAside, null), /* @__PURE__ */ React.createElement(LayoutContent, null, children)));
|
|
487
|
+
}
|
|
488
|
+
function Layout({ children }) {
|
|
489
|
+
return /* @__PURE__ */ React.createElement(LayoutProvider, null, /* @__PURE__ */ React.createElement(LayoutInner, null, children));
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export { Layout, LayoutProvider, useLayoutContext as useLayout, useLayoutContext };
|
package/dist/layout.css
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
.tker-layout {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
height: 100vh;
|
|
5
|
+
width: 100%;
|
|
6
|
+
overflow: hidden;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.tker-layout__body {
|
|
10
|
+
display: flex;
|
|
11
|
+
flex: 1;
|
|
12
|
+
overflow: hidden;
|
|
13
|
+
min-height: 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/* === Header === */
|
|
17
|
+
|
|
18
|
+
.tker-layout-header {
|
|
19
|
+
display: flex;
|
|
20
|
+
align-items: center;
|
|
21
|
+
justify-content: space-between;
|
|
22
|
+
height: 48px;
|
|
23
|
+
background: #fff;
|
|
24
|
+
border-bottom: 1px solid #e8e8e8;
|
|
25
|
+
flex-shrink: 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.tker-layout-header__left,
|
|
29
|
+
.tker-layout-header__right {
|
|
30
|
+
display: flex;
|
|
31
|
+
align-items: center;
|
|
32
|
+
gap: 12px;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.tker-layout-header__left-logo {
|
|
36
|
+
border-right: 1px solid #e8e8e8;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* === Aside === */
|
|
40
|
+
|
|
41
|
+
.tker-layout-aside {
|
|
42
|
+
flex-shrink: 0;
|
|
43
|
+
height: 100%;
|
|
44
|
+
background: #fff;
|
|
45
|
+
border-right: 1px solid #e8e8e8;
|
|
46
|
+
transition: width 0.3s;
|
|
47
|
+
position: relative;
|
|
48
|
+
display: flex;
|
|
49
|
+
flex-direction: column;
|
|
50
|
+
overflow: hidden;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.tker-layout-aside__toggle-btn {
|
|
54
|
+
cursor: pointer;
|
|
55
|
+
width: 24px;
|
|
56
|
+
height: 24px;
|
|
57
|
+
position: absolute;
|
|
58
|
+
top: 50%;
|
|
59
|
+
right: -12px;
|
|
60
|
+
border-radius: 50%;
|
|
61
|
+
display: flex;
|
|
62
|
+
align-items: center;
|
|
63
|
+
justify-content: center;
|
|
64
|
+
font-size: 18px;
|
|
65
|
+
color: #333;
|
|
66
|
+
border: 1px solid #e8e8e8;
|
|
67
|
+
background-color: #fff;
|
|
68
|
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.06);
|
|
69
|
+
transform: translateY(-50%);
|
|
70
|
+
z-index: 100;
|
|
71
|
+
transition: all 0.3s;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.tker-layout-aside__toggle-btn:hover {
|
|
75
|
+
color: #18a058;
|
|
76
|
+
border-color: #18a058;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* === Content === */
|
|
80
|
+
|
|
81
|
+
.tker-layout-content {
|
|
82
|
+
flex: 1;
|
|
83
|
+
display: flex;
|
|
84
|
+
flex-direction: column;
|
|
85
|
+
overflow: hidden;
|
|
86
|
+
background: rgb(250, 250, 252);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.tker-layout-content__body {
|
|
90
|
+
flex: 1;
|
|
91
|
+
padding: 8px;
|
|
92
|
+
overflow: auto;
|
|
93
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tker-react/layout",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "pnpm unbuild"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"sideEffects": [
|
|
14
|
+
"**/*.css"
|
|
15
|
+
],
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"development": "./src/index.ts",
|
|
20
|
+
"default": "./dist/index.mjs"
|
|
21
|
+
},
|
|
22
|
+
"./layout.css": {
|
|
23
|
+
"development": "./src/styles/layout.css",
|
|
24
|
+
"default": "./dist/layout.css"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"default": "./dist/index.mjs"
|
|
33
|
+
},
|
|
34
|
+
"./layout.css": {
|
|
35
|
+
"default": "./dist/layout.css"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"react": ">=18",
|
|
41
|
+
"react-dom": ">=18"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@tker-react/tsconfig": "workspace:*",
|
|
45
|
+
"@types/react": "^19.0.0",
|
|
46
|
+
"@types/react-dom": "^19.0.0",
|
|
47
|
+
"react": "^19.0.0",
|
|
48
|
+
"react-dom": "^19.0.0"
|
|
49
|
+
}
|
|
50
|
+
}
|