MainLayout.tsx 7.8 KB

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