MainLayout.tsx 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. import React, { useState, useEffect, useMemo } from 'react';
  2. import {
  3. Outlet,
  4. useLocation,
  5. } from 'react-router';
  6. import {
  7. Layout, Button, Space, Badge, Avatar, Dropdown, Typography, Input, Menu,
  8. } from 'antd';
  9. import {
  10. MenuFoldOutlined,
  11. MenuUnfoldOutlined,
  12. BellOutlined,
  13. VerticalAlignTopOutlined,
  14. UserOutlined
  15. } from '@ant-design/icons';
  16. import { useAuth } from '../hooks/AuthProvider';
  17. import { useMenu, useMenuSearch, type MenuItem } from '../menu';
  18. import { getGlobalConfig } from '@/client/utils/utils';
  19. const { Header, Sider, Content } = Layout;
  20. /**
  21. * 主布局组件
  22. * 包含侧边栏、顶部导航和内容区域
  23. */
  24. export const MainLayout = () => {
  25. const { user } = useAuth();
  26. const [showBackTop, setShowBackTop] = useState(false);
  27. const location = useLocation();
  28. // 使用菜单hook
  29. const {
  30. menuItems,
  31. userMenuItems,
  32. openKeys,
  33. collapsed,
  34. setCollapsed,
  35. handleMenuClick: handleRawMenuClick,
  36. onOpenChange
  37. } = useMenu();
  38. // 处理菜单点击
  39. const handleMenuClick = (key: string) => {
  40. const item = findMenuItem(menuItems, key);
  41. if (item && 'label' in item) {
  42. handleRawMenuClick(item);
  43. }
  44. };
  45. // 查找菜单项
  46. const findMenuItem = (items: MenuItem[], key: string): MenuItem | null => {
  47. for (const item of items) {
  48. if (!item) continue;
  49. if (item.key === key) return item;
  50. if (item.children) {
  51. const found = findMenuItem(item.children, key);
  52. if (found) return found;
  53. }
  54. }
  55. return null;
  56. };
  57. // 使用菜单搜索hook
  58. const {
  59. searchText,
  60. setSearchText,
  61. filteredMenuItems
  62. } = useMenuSearch(menuItems);
  63. // 获取当前选中的菜单项
  64. const selectedKey = useMemo(() => {
  65. const findSelectedKey = (items: MenuItem[]): string | null => {
  66. for (const item of items) {
  67. if (!item) continue;
  68. if (item.path === location.pathname) return item.key || null;
  69. if (item.children) {
  70. const childKey = findSelectedKey(item.children);
  71. if (childKey) return childKey;
  72. }
  73. }
  74. return null;
  75. };
  76. return findSelectedKey(menuItems) || '';
  77. }, [location.pathname, menuItems]);
  78. // 检测滚动位置,控制回到顶部按钮显示
  79. useEffect(() => {
  80. const handleScroll = () => {
  81. setShowBackTop(window.pageYOffset > 300);
  82. };
  83. window.addEventListener('scroll', handleScroll);
  84. return () => window.removeEventListener('scroll', handleScroll);
  85. }, []);
  86. // 回到顶部
  87. const scrollToTop = () => {
  88. window.scrollTo({
  89. top: 0,
  90. behavior: 'smooth'
  91. });
  92. };
  93. // 应用名称 - 从CONFIG中获取或使用默认值
  94. const appName = getGlobalConfig('APP_NAME') || '应用Starter';
  95. return (
  96. <Layout style={{ minHeight: '100vh' }}>
  97. <Sider
  98. trigger={null}
  99. collapsible
  100. collapsed={collapsed}
  101. width={240}
  102. className="custom-sider"
  103. theme='light'
  104. style={{
  105. overflow: 'auto',
  106. height: '100vh',
  107. position: 'fixed',
  108. left: 0,
  109. top: 0,
  110. bottom: 0,
  111. zIndex: 100,
  112. transition: 'all 0.2s ease',
  113. boxShadow: '2px 0 8px 0 rgba(29, 35, 41, 0.05)',
  114. background: 'linear-gradient(180deg, #001529 0%, #003a6c 100%)',
  115. }}
  116. >
  117. <div className="p-4">
  118. <Typography.Title level={2} className="text-xl font-bold truncate">
  119. <span className="text-white">{collapsed ? '应用' : appName}</span>
  120. </Typography.Title>
  121. {/* 菜单搜索框 */}
  122. {!collapsed && (
  123. <div className="mb-4">
  124. <Input.Search
  125. placeholder="搜索菜单..."
  126. allowClear
  127. value={searchText}
  128. onChange={(e) => setSearchText(e.target.value)}
  129. />
  130. </div>
  131. )}
  132. </div>
  133. {/* 菜单列表 */}
  134. <Menu
  135. theme='dark'
  136. mode="inline"
  137. items={filteredMenuItems}
  138. openKeys={openKeys}
  139. selectedKeys={[selectedKey]}
  140. onOpenChange={onOpenChange}
  141. onClick={({ key }) => handleMenuClick(key)}
  142. inlineCollapsed={collapsed}
  143. style={{
  144. backgroundColor: 'transparent',
  145. borderRight: 'none'
  146. }}
  147. />
  148. </Sider>
  149. <Layout style={{ marginLeft: collapsed ? 80 : 240, transition: 'margin-left 0.2s' }}>
  150. <div className="sticky top-0 z-50 bg-white shadow-sm transition-all duration-200 h-16 flex items-center justify-between pl-2"
  151. style={{
  152. boxShadow: '0 1px 8px rgba(0,21,41,0.12)',
  153. borderBottom: '1px solid #f0f0f0'
  154. }}
  155. >
  156. <Button
  157. type="text"
  158. icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
  159. onClick={() => setCollapsed(!collapsed)}
  160. className="w-16 h-16"
  161. />
  162. <Space size="middle" className="mr-4">
  163. <Badge count={5} offset={[0, 5]}>
  164. <Button
  165. type="text"
  166. icon={<BellOutlined />}
  167. />
  168. </Badge>
  169. <Dropdown menu={{ items: userMenuItems }}>
  170. <Space className="cursor-pointer">
  171. <Avatar
  172. src={user?.avatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?q=80&w=40&auto=format&fit=crop'}
  173. icon={!user?.avatar && !navigator.onLine && <UserOutlined />}
  174. />
  175. <span>
  176. {user?.nickname || user?.username}
  177. </span>
  178. </Space>
  179. </Dropdown>
  180. </Space>
  181. </div>
  182. <Content className="m-6" style={{ overflow: 'initial', transition: 'all 0.2s ease' }}>
  183. <div className="site-layout-content p-6 rounded-lg bg-white shadow-sm transition-all duration-300 hover:shadow-md">
  184. <Outlet />
  185. </div>
  186. {/* 回到顶部按钮 */}
  187. {showBackTop && (
  188. <Button
  189. type="primary"
  190. shape="circle"
  191. icon={<VerticalAlignTopOutlined />}
  192. size="large"
  193. onClick={scrollToTop}
  194. style={{
  195. position: 'fixed',
  196. right: 30,
  197. bottom: 30,
  198. zIndex: 1000,
  199. boxShadow: '0 3px 6px rgba(0,0,0,0.16)',
  200. }}
  201. />
  202. )}
  203. </Content>
  204. </Layout>
  205. </Layout>
  206. );
  207. };