| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257 |
- import { useState, useEffect, useMemo } from 'react';
- import {
- Outlet,
- useLocation,
- } from 'react-router';
- import {
- Bell,
- Menu,
- User,
- ChevronDown
- } from 'lucide-react';
- import { useAuth } from '../hooks/AuthProvider';
- import { useMenu, type MenuItem } from '../menu';
- import { getGlobalConfig } from '@/client/utils/utils';
- import { Button } from '@/client/components/ui/button';
- import { Input } from '@/client/components/ui/input';
- import { Avatar, AvatarFallback, AvatarImage } from '@/client/components/ui/avatar';
- import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/client/components/ui/dropdown-menu';
- import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/client/components/ui/sheet';
- import { ScrollArea } from '@/client/components/ui/scroll-area';
- import { cn } from '@/client/lib/utils';
- import { Badge } from '@/client/components/ui/badge';
- /**
- * 主布局组件
- * 包含侧边栏、顶部导航和内容区域
- */
- export const MainLayout = () => {
- const { user } = useAuth();
- const [showBackTop, setShowBackTop] = useState(false);
- const location = useLocation();
- const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
-
- // 使用菜单hook
- const {
- menuItems,
- userMenuItems,
- collapsed,
- setCollapsed,
- handleMenuClick
- } = useMenu();
-
- // 获取当前选中的菜单项
- const selectedKey = useMemo(() => {
- const findSelectedKey = (items: MenuItem[]): string | null => {
- for (const item of items) {
- if (!item) continue;
- if (item.path === location.pathname) return item.key || null;
- if (item.children) {
- const childKey = findSelectedKey(item.children);
- if (childKey) return childKey;
- }
- }
- return null;
- };
-
- return findSelectedKey(menuItems) || '';
- }, [location.pathname, menuItems]);
-
- // 检测滚动位置,控制回到顶部按钮显示
- useEffect(() => {
- const handleScroll = () => {
- setShowBackTop(window.pageYOffset > 300);
- };
-
- window.addEventListener('scroll', handleScroll);
- return () => window.removeEventListener('scroll', handleScroll);
- }, []);
-
- // 回到顶部
- const scrollToTop = () => {
- window.scrollTo({
- top: 0,
- behavior: 'smooth'
- });
- };
- // 应用名称 - 从CONFIG中获取或使用默认值
- const appName = getGlobalConfig('APP_NAME') || '应用Starter';
-
- // 侧边栏内容
- const SidebarContent = () => (
- <div className="flex h-full flex-col">
- <div className="p-4 border-b">
- <h2 className="text-lg font-semibold truncate">
- {collapsed ? '应用' : appName}
- </h2>
- {!collapsed && (
- <div className="mt-4">
- <Input
- placeholder="搜索菜单..."
- className="h-8"
- />
- </div>
- )}
- </div>
-
- <ScrollArea className="flex-1">
- <nav className="p-2">
- {menuItems.map((item) => (
- <div key={item.key}>
- <Button
- variant={selectedKey === item.key ? "default" : "ghost"}
- className={cn(
- "w-full justify-start mb-1",
- selectedKey === item.key && "bg-primary text-primary-foreground"
- )}
- onClick={() => {
- handleMenuClick(item);
- setIsMobileMenuOpen(false);
- }}
- >
- {item.icon}
- {!collapsed && <span className="ml-2">{item.label}</span>}
- </Button>
-
- {item.children && !collapsed && (
- <div className="ml-4">
- {item.children.map((child) => (
- <Button
- key={child.key}
- variant={selectedKey === child.key ? "default" : "ghost"}
- className={cn(
- "w-full justify-start mb-1 text-sm",
- selectedKey === child.key && "bg-primary text-primary-foreground"
- )}
- onClick={() => {
- handleMenuClick(child);
- setIsMobileMenuOpen(false);
- }}
- >
- {child.icon && <span className="ml-2">{child.icon}</span>}
- <span className={child.icon ? "ml-2" : "ml-6"}>{child.label}</span>
- </Button>
- ))}
- </div>
- )}
- </div>
- ))}
- </nav>
- </ScrollArea>
- </div>
- );
- return (
- <div className="flex h-screen bg-background">
- {/* Desktop Sidebar */}
- <aside className={cn(
- "hidden md:block border-r bg-background transition-all duration-200",
- collapsed ? "w-16" : "w-64"
- )}>
- <SidebarContent />
- </aside>
- {/* Mobile Sidebar */}
- <Sheet open={isMobileMenuOpen} onOpenChange={setIsMobileMenuOpen}>
- <SheetContent side="left" className="w-64 p-0">
- <SheetHeader className="p-4">
- <SheetTitle>{appName}</SheetTitle>
- </SheetHeader>
- <SidebarContent />
- </SheetContent>
- </Sheet>
- <div className="flex-1 flex flex-col overflow-hidden">
- {/* Header */}
- <header className="flex h-16 items-center justify-between border-b bg-background px-4">
- <div className="flex items-center gap-2">
- <Button
- variant="ghost"
- size="icon"
- className="md:hidden"
- onClick={() => setIsMobileMenuOpen(true)}
- data-testid="mobile-menu-button"
- >
- <Menu className="h-4 w-4" />
- </Button>
- <Button
- variant="ghost"
- size="icon"
- className="hidden md:block"
- onClick={() => setCollapsed(!collapsed)}
- >
- <Menu className="h-4 w-4" />
- </Button>
- </div>
- <div className="flex items-center gap-4">
- <Button variant="ghost" size="icon" className="relative">
- <Bell className="h-4 w-4" />
- <Badge className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center text-xs">
- 5
- </Badge>
- </Button>
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button variant="ghost" className="relative h-8 w-8 rounded-full">
- <Avatar className="h-8 w-8">
- <AvatarImage
- src={user?.avatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?q=80&w=40&auto=format&fit=crop'}
- alt={user?.username || 'User'}
- />
- <AvatarFallback>
- <User className="h-4 w-4" />
- </AvatarFallback>
- </Avatar>
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent className="w-56" align="end" forceMount>
- <DropdownMenuLabel className="font-normal">
- <div className="flex flex-col space-y-1">
- <p className="text-sm font-medium leading-none">
- {user?.nickname || user?.username}
- </p>
- <p className="text-xs leading-none text-muted-foreground">
- {user?.email}
- </p>
- </div>
- </DropdownMenuLabel>
- <DropdownMenuSeparator />
- {userMenuItems.map((item) => (
- item.type === 'separator' ? (
- <DropdownMenuSeparator key={item.key} />
- ) : (
- <DropdownMenuItem key={item.key} onClick={item.onClick}>
- {item.icon && item.icon}
- <span>{item.label}</span>
- </DropdownMenuItem>
- )
- ))}
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
- </header>
- {/* Main Content */}
- <main className="flex-1 overflow-auto p-4">
- <div className="max-w-7xl mx-auto">
- <Outlet />
- </div>
-
- {/* Back to top button */}
- {showBackTop && (
- <Button
- size="icon"
- className="fixed bottom-4 right-4 rounded-full shadow-lg"
- onClick={scrollToTop}
- >
- <ChevronDown className="h-4 w-4 rotate-180" />
- </Button>
- )}
- </main>
- </div>
- </div>
- );
- };
|