MainLayout.tsx 8.8 KB

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