MainLayout.tsx 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. import { useState, useEffect, useMemo } from 'react';
  2. import {
  3. Outlet,
  4. useLocation,
  5. } from 'react-router';
  6. import {
  7. Bell,
  8. Menu,
  9. User,
  10. ChevronDown
  11. } from 'lucide-react';
  12. import { useAuth } from '../hooks/AuthProvider';
  13. import { useMenu, type MenuItem } from '../menu';
  14. import { getGlobalConfig } from '@/client/utils/utils';
  15. import { Button } from '@/client/components/ui/button';
  16. import { Input } from '@/client/components/ui/input';
  17. import { Avatar, AvatarFallback, AvatarImage } from '@/client/components/ui/avatar';
  18. import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/client/components/ui/dropdown-menu';
  19. import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/client/components/ui/sheet';
  20. import { ScrollArea } from '@/client/components/ui/scroll-area';
  21. import { cn } from '@/client/lib/utils';
  22. import { Badge } from '@/client/components/ui/badge';
  23. /**
  24. * 主布局组件
  25. * 包含侧边栏、顶部导航和内容区域
  26. */
  27. export const MainLayout = () => {
  28. const { user } = useAuth();
  29. const [showBackTop, setShowBackTop] = useState(false);
  30. const location = useLocation();
  31. const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
  32. // 使用菜单hook
  33. const {
  34. menuItems,
  35. userMenuItems,
  36. collapsed,
  37. setCollapsed,
  38. handleMenuClick
  39. } = useMenu();
  40. // 获取当前选中的菜单项
  41. const selectedKey = useMemo(() => {
  42. const findSelectedKey = (items: MenuItem[]): string | null => {
  43. for (const item of items) {
  44. if (!item) continue;
  45. if (item.path === location.pathname) return item.key || null;
  46. if (item.children) {
  47. const childKey = findSelectedKey(item.children);
  48. if (childKey) return childKey;
  49. }
  50. }
  51. return null;
  52. };
  53. return findSelectedKey(menuItems) || '';
  54. }, [location.pathname, menuItems]);
  55. // 检测滚动位置,控制回到顶部按钮显示
  56. useEffect(() => {
  57. const handleScroll = () => {
  58. setShowBackTop(window.pageYOffset > 300);
  59. };
  60. window.addEventListener('scroll', handleScroll);
  61. return () => window.removeEventListener('scroll', handleScroll);
  62. }, []);
  63. // 回到顶部
  64. const scrollToTop = () => {
  65. window.scrollTo({
  66. top: 0,
  67. behavior: 'smooth'
  68. });
  69. };
  70. // 应用名称 - 从CONFIG中获取或使用默认值
  71. const appName = getGlobalConfig('APP_NAME') || '应用Starter';
  72. // 侧边栏内容
  73. const SidebarContent = () => (
  74. <div className="flex h-full flex-col">
  75. <div className="p-4 border-b">
  76. <h2 className="text-lg font-semibold truncate">
  77. {collapsed ? '应用' : appName}
  78. </h2>
  79. {!collapsed && (
  80. <div className="mt-4">
  81. <Input
  82. placeholder="搜索菜单..."
  83. className="h-8"
  84. />
  85. </div>
  86. )}
  87. </div>
  88. <ScrollArea className="flex-1">
  89. <nav className="p-2">
  90. {menuItems.map((item) => (
  91. <div key={item.key}>
  92. <Button
  93. variant={selectedKey === item.key ? "default" : "ghost"}
  94. className={cn(
  95. "w-full justify-start mb-1",
  96. selectedKey === item.key && "bg-primary text-primary-foreground"
  97. )}
  98. onClick={() => {
  99. handleMenuClick(item);
  100. setIsMobileMenuOpen(false);
  101. }}
  102. >
  103. {item.icon}
  104. {!collapsed && <span className="ml-2">{item.label}</span>}
  105. </Button>
  106. {item.children && !collapsed && (
  107. <div className="ml-4">
  108. {item.children.map((child) => (
  109. <Button
  110. key={child.key}
  111. variant={selectedKey === child.key ? "default" : "ghost"}
  112. className={cn(
  113. "w-full justify-start mb-1 text-sm",
  114. selectedKey === child.key && "bg-primary text-primary-foreground"
  115. )}
  116. onClick={() => {
  117. handleMenuClick(child);
  118. setIsMobileMenuOpen(false);
  119. }}
  120. >
  121. {child.icon && <span className="ml-2">{child.icon}</span>}
  122. <span className={child.icon ? "ml-2" : "ml-6"}>{child.label}</span>
  123. </Button>
  124. ))}
  125. </div>
  126. )}
  127. </div>
  128. ))}
  129. </nav>
  130. </ScrollArea>
  131. </div>
  132. );
  133. return (
  134. <div className="flex h-screen bg-background">
  135. {/* Desktop Sidebar */}
  136. <aside className={cn(
  137. "hidden md:block border-r bg-background transition-all duration-200",
  138. collapsed ? "w-16" : "w-64"
  139. )}>
  140. <SidebarContent />
  141. </aside>
  142. {/* Mobile Sidebar */}
  143. <Sheet open={isMobileMenuOpen} onOpenChange={setIsMobileMenuOpen}>
  144. <SheetContent side="left" className="w-64 p-0">
  145. <SheetHeader className="p-4">
  146. <SheetTitle>{appName}</SheetTitle>
  147. </SheetHeader>
  148. <SidebarContent />
  149. </SheetContent>
  150. </Sheet>
  151. <div className="flex-1 flex flex-col overflow-hidden">
  152. {/* Header */}
  153. <header className="flex h-16 items-center justify-between border-b bg-background px-4">
  154. <div className="flex items-center gap-2">
  155. <Button
  156. variant="ghost"
  157. size="icon"
  158. className="md:hidden"
  159. onClick={() => setIsMobileMenuOpen(true)}
  160. data-testid="mobile-menu-button"
  161. >
  162. <Menu className="h-4 w-4" />
  163. </Button>
  164. <Button
  165. variant="ghost"
  166. size="icon"
  167. className="hidden md:block"
  168. onClick={() => setCollapsed(!collapsed)}
  169. >
  170. <Menu className="h-4 w-4" />
  171. </Button>
  172. </div>
  173. <div className="flex items-center gap-4">
  174. <Button variant="ghost" size="icon" className="relative">
  175. <Bell className="h-4 w-4" />
  176. <Badge className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center text-xs">
  177. 5
  178. </Badge>
  179. </Button>
  180. <DropdownMenu>
  181. <DropdownMenuTrigger asChild>
  182. <Button variant="ghost" className="relative h-8 w-8 rounded-full">
  183. <Avatar className="h-8 w-8">
  184. <AvatarImage
  185. src={user?.avatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?q=80&w=40&auto=format&fit=crop'}
  186. alt={user?.username || 'User'}
  187. />
  188. <AvatarFallback>
  189. <User className="h-4 w-4" />
  190. </AvatarFallback>
  191. </Avatar>
  192. </Button>
  193. </DropdownMenuTrigger>
  194. <DropdownMenuContent className="w-56" align="end" forceMount>
  195. <DropdownMenuLabel className="font-normal">
  196. <div className="flex flex-col space-y-1">
  197. <p className="text-sm font-medium leading-none">
  198. {user?.nickname || user?.username}
  199. </p>
  200. <p className="text-xs leading-none text-muted-foreground">
  201. {user?.email}
  202. </p>
  203. </div>
  204. </DropdownMenuLabel>
  205. <DropdownMenuSeparator />
  206. {userMenuItems.map((item) => (
  207. item.type === 'separator' ? (
  208. <DropdownMenuSeparator key={item.key} />
  209. ) : (
  210. <DropdownMenuItem key={item.key} onClick={item.onClick}>
  211. {item.icon && item.icon}
  212. <span>{item.label}</span>
  213. </DropdownMenuItem>
  214. )
  215. ))}
  216. </DropdownMenuContent>
  217. </DropdownMenu>
  218. </div>
  219. </header>
  220. {/* Main Content */}
  221. <main className="flex-1 overflow-auto p-4">
  222. <div className="max-w-7xl mx-auto">
  223. <Outlet />
  224. </div>
  225. {/* Back to top button */}
  226. {showBackTop && (
  227. <Button
  228. size="icon"
  229. className="fixed bottom-4 right-4 rounded-full shadow-lg"
  230. onClick={scrollToTop}
  231. >
  232. <ChevronDown className="h-4 w-4 rotate-180" />
  233. </Button>
  234. )}
  235. </main>
  236. </div>
  237. </div>
  238. );
  239. };