MainLayout.tsx 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  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. import { Toaster } from '@/client/components/ui/sonner';
  24. /**
  25. * 主布局组件
  26. * 包含侧边栏、顶部导航和内容区域
  27. */
  28. export const MainLayout = () => {
  29. const { user } = useAuth();
  30. const [showBackTop, setShowBackTop] = useState(false);
  31. const location = useLocation();
  32. const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
  33. // 使用菜单hook
  34. const {
  35. menuItems,
  36. userMenuItems,
  37. collapsed,
  38. setCollapsed,
  39. handleMenuClick
  40. } = useMenu();
  41. // 获取当前选中的菜单项
  42. const selectedKey = useMemo(() => {
  43. const findSelectedKey = (items: MenuItem[]): string | null => {
  44. for (const item of items) {
  45. if (!item) continue;
  46. if (item.path === location.pathname) return item.key || null;
  47. if (item.children) {
  48. const childKey = findSelectedKey(item.children);
  49. if (childKey) return childKey;
  50. }
  51. }
  52. return null;
  53. };
  54. return findSelectedKey(menuItems) || '';
  55. }, [location.pathname, menuItems]);
  56. // 检测滚动位置,控制回到顶部按钮显示
  57. useEffect(() => {
  58. const handleScroll = () => {
  59. setShowBackTop(window.pageYOffset > 300);
  60. };
  61. window.addEventListener('scroll', handleScroll);
  62. return () => window.removeEventListener('scroll', handleScroll);
  63. }, []);
  64. // 回到顶部
  65. const scrollToTop = () => {
  66. window.scrollTo({
  67. top: 0,
  68. behavior: 'smooth'
  69. });
  70. };
  71. // 应用名称 - 从CONFIG中获取或使用默认值
  72. const appName = getGlobalConfig('APP_NAME') || '应用Starter';
  73. // 侧边栏内容
  74. const SidebarContent = () => (
  75. <div className="flex h-full flex-col">
  76. <div className="p-4 border-b">
  77. <h2 className="text-lg font-semibold truncate">
  78. {collapsed ? '应用' : appName}
  79. </h2>
  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">
  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. <Toaster />
  137. {/* Desktop Sidebar */}
  138. <aside className={cn(
  139. "hidden md:block border-r bg-background transition-all duration-200",
  140. collapsed ? "w-16" : "w-64"
  141. )}>
  142. <SidebarContent />
  143. </aside>
  144. {/* Mobile Sidebar */}
  145. <Sheet open={isMobileMenuOpen} onOpenChange={setIsMobileMenuOpen}>
  146. <SheetContent side="left" className="w-64 p-0">
  147. <SheetHeader className="p-4">
  148. <SheetTitle>{appName}</SheetTitle>
  149. </SheetHeader>
  150. <SidebarContent />
  151. </SheetContent>
  152. </Sheet>
  153. <div className="flex-1 flex flex-col overflow-hidden">
  154. {/* Header */}
  155. <header className="flex h-16 items-center justify-between border-b bg-background px-4">
  156. <div className="flex items-center gap-2">
  157. <Button
  158. variant="ghost"
  159. size="icon"
  160. className="md:hidden"
  161. onClick={() => setIsMobileMenuOpen(true)}
  162. >
  163. <Menu className="h-4 w-4" />
  164. </Button>
  165. <Button
  166. variant="ghost"
  167. size="icon"
  168. className="hidden md:block"
  169. onClick={() => setCollapsed(!collapsed)}
  170. >
  171. <Menu className="h-4 w-4" />
  172. </Button>
  173. </div>
  174. <div className="flex items-center gap-4">
  175. <Button variant="ghost" size="icon" className="relative">
  176. <Bell className="h-4 w-4" />
  177. <Badge className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center text-xs">
  178. 5
  179. </Badge>
  180. </Button>
  181. <DropdownMenu>
  182. <DropdownMenuTrigger asChild>
  183. <Button variant="ghost" className="relative h-8 w-8 rounded-full">
  184. <Avatar className="h-8 w-8">
  185. <AvatarImage
  186. src={user?.avatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?q=80&w=40&auto=format&fit=crop'}
  187. alt={user?.username || 'User'}
  188. />
  189. <AvatarFallback>
  190. <User className="h-4 w-4" />
  191. </AvatarFallback>
  192. </Avatar>
  193. </Button>
  194. </DropdownMenuTrigger>
  195. <DropdownMenuContent className="w-56" align="end" forceMount>
  196. <DropdownMenuLabel className="font-normal">
  197. <div className="flex flex-col space-y-1">
  198. <p className="text-sm font-medium leading-none">
  199. {user?.nickname || user?.username}
  200. </p>
  201. <p className="text-xs leading-none text-muted-foreground">
  202. {user?.email}
  203. </p>
  204. </div>
  205. </DropdownMenuLabel>
  206. <DropdownMenuSeparator />
  207. {userMenuItems.map((item) => (
  208. item.type === 'separator' ? (
  209. <DropdownMenuSeparator key={item.key} />
  210. ) : (
  211. <DropdownMenuItem key={item.key} onClick={item.onClick}>
  212. {item.icon && item.icon}
  213. <span>{item.label}</span>
  214. </DropdownMenuItem>
  215. )
  216. ))}
  217. </DropdownMenuContent>
  218. </DropdownMenu>
  219. </div>
  220. </header>
  221. {/* Main Content */}
  222. <main className="flex-1 overflow-auto p-4">
  223. <div className="max-w-7xl mx-auto">
  224. <Outlet />
  225. </div>
  226. {/* Back to top button */}
  227. {showBackTop && (
  228. <Button
  229. size="icon"
  230. className="fixed bottom-4 right-4 rounded-full shadow-lg"
  231. onClick={scrollToTop}
  232. >
  233. <ChevronDown className="h-4 w-4 rotate-180" />
  234. </Button>
  235. )}
  236. </main>
  237. </div>
  238. </div>
  239. );
  240. };