feat(web): 侧边栏支持动态菜单渲染
基于用户权限数据动态渲染侧边栏菜单,支持多级菜单和图标映射。 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,63 +1,225 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import type { MenuTreeNode } from '@seclusion/shared';
|
||||
import { ChevronLeft, ChevronDown } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useState, useMemo } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet';
|
||||
import { sidebarNav } from '@/config/nav';
|
||||
import { siteConfig } from '@/config/site';
|
||||
import { getIcon } from '@/lib/icons';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUIStore } from '@/stores';
|
||||
import { useUIStore, usePermissionStore } from '@/stores';
|
||||
|
||||
interface SidebarContentProps {
|
||||
collapsed?: boolean;
|
||||
onItemClick?: () => void;
|
||||
}
|
||||
|
||||
function SidebarContent({ collapsed = false }: SidebarContentProps) {
|
||||
// 渲染单个菜单项
|
||||
function MenuItem({
|
||||
item,
|
||||
collapsed = false,
|
||||
onItemClick,
|
||||
isActive,
|
||||
}: {
|
||||
item: MenuTreeNode;
|
||||
collapsed?: boolean;
|
||||
onItemClick?: () => void;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const Icon = getIcon(item.icon);
|
||||
|
||||
// 隐藏的菜单不显示
|
||||
if (item.isHidden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 按钮类型不在侧边栏显示
|
||||
if (item.type === 'button') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={item.path || '#'}
|
||||
onClick={onItemClick}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
isActive
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'text-muted-foreground',
|
||||
collapsed && 'justify-center px-2'
|
||||
)}
|
||||
title={collapsed ? item.name : undefined}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{item.name}</span>}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// 渲染目录(可折叠)
|
||||
function MenuDir({
|
||||
item,
|
||||
collapsed = false,
|
||||
onItemClick,
|
||||
pathname,
|
||||
}: {
|
||||
item: MenuTreeNode;
|
||||
collapsed?: boolean;
|
||||
onItemClick?: () => void;
|
||||
pathname: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const Icon = getIcon(item.icon);
|
||||
const children = item.children || [];
|
||||
|
||||
// 隐藏的菜单不显示
|
||||
if (item.isHidden || children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 检查子菜单是否有激活项
|
||||
const hasActiveChild = children.some(
|
||||
(child) => child.path && pathname.startsWith(child.path)
|
||||
);
|
||||
|
||||
if (collapsed) {
|
||||
// 折叠状态只显示图标
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center rounded-lg p-2',
|
||||
hasActiveChild ? 'bg-accent' : ''
|
||||
)}
|
||||
title={item.name}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
hasActiveChild ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1 text-left">{item.name}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 transition-transform',
|
||||
open && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pl-4">
|
||||
{children.map((child) => (
|
||||
<MenuNode
|
||||
key={child.id}
|
||||
item={child}
|
||||
collapsed={false}
|
||||
onItemClick={onItemClick}
|
||||
pathname={pathname}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
// 递归渲染菜单节点
|
||||
function MenuNode({
|
||||
item,
|
||||
collapsed = false,
|
||||
onItemClick,
|
||||
pathname,
|
||||
}: {
|
||||
item: MenuTreeNode;
|
||||
collapsed?: boolean;
|
||||
onItemClick?: () => void;
|
||||
pathname: string;
|
||||
}) {
|
||||
if (item.type === 'dir' && item.children && item.children.length > 0) {
|
||||
return (
|
||||
<MenuDir
|
||||
item={item}
|
||||
collapsed={collapsed}
|
||||
onItemClick={onItemClick}
|
||||
pathname={pathname}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isActive = item.path ? pathname === item.path : false;
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
item={item}
|
||||
collapsed={collapsed}
|
||||
onItemClick={onItemClick}
|
||||
isActive={isActive}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarContent({ collapsed = false, onItemClick }: SidebarContentProps) {
|
||||
const pathname = usePathname();
|
||||
const { menus, isLoaded } = usePermissionStore();
|
||||
|
||||
// 如果权限数据已加载且有菜单,使用动态菜单
|
||||
// 否则使用静态菜单作为 fallback
|
||||
const menuItems = useMemo(() => {
|
||||
if (isLoaded && menus.length > 0) {
|
||||
return menus;
|
||||
}
|
||||
|
||||
// 将静态导航转换为 MenuTreeNode 格式
|
||||
return sidebarNav.flatMap((group) =>
|
||||
group.items.map((item) => ({
|
||||
id: item.href,
|
||||
parentId: null,
|
||||
code: item.href,
|
||||
name: item.title,
|
||||
type: 'menu' as const,
|
||||
path: item.href,
|
||||
icon: item.icon.displayName || null,
|
||||
permission: null,
|
||||
isHidden: false,
|
||||
meta: null,
|
||||
}))
|
||||
);
|
||||
}, [isLoaded, menus]);
|
||||
|
||||
// 将动态菜单按分组渲染(如果有 parentId 则是子菜单)
|
||||
const topLevelMenus = menuItems.filter((item) => !item.parentId);
|
||||
|
||||
return (
|
||||
<ScrollArea className="flex-1 py-2">
|
||||
<nav className="grid gap-1 px-2">
|
||||
{sidebarNav.map((group, index) => (
|
||||
<div key={index} className="pb-2">
|
||||
{group.title && !collapsed && (
|
||||
<h4 className="mb-1 px-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{group.title}
|
||||
</h4>
|
||||
)}
|
||||
{group.items.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
isActive
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'text-muted-foreground',
|
||||
collapsed && 'justify-center px-2'
|
||||
)}
|
||||
title={collapsed ? item.title : undefined}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{item.title}</span>}
|
||||
{!collapsed && item.badge && (
|
||||
<span className="ml-auto rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{topLevelMenus.map((item) => (
|
||||
<MenuNode
|
||||
key={item.id}
|
||||
item={item}
|
||||
collapsed={collapsed}
|
||||
onItemClick={onItemClick}
|
||||
pathname={pathname}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</ScrollArea>
|
||||
@@ -127,7 +289,7 @@ export function MobileSidebar() {
|
||||
{siteConfig.name}
|
||||
</Link>
|
||||
</div>
|
||||
<SidebarContent />
|
||||
<SidebarContent onItemClick={() => setSidebarOpen(false)} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user