MainLayout.tsx 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  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. {/* 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. <Button
  157. variant="ghost"
  158. size="icon"
  159. className="md:hidden"
  160. onClick={() => setIsMobileMenuOpen(true)}
  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. };