563 lines
21 KiB
TypeScript
563 lines
21 KiB
TypeScript
/**
|
||
* Layout主布局组件
|
||
* 作者: 阿瑞
|
||
* 功能: 应用主布局,包含导航菜单、用户信息、主题切换等功能
|
||
* 版本: v3.0 - 性能优化版本,移除realDark,添加React.memo优化
|
||
*/
|
||
import React, { useEffect, useMemo, useState, useCallback } from 'react';
|
||
import { useRouter } from 'next/router';
|
||
import { ConfigProvider, Divider, Dropdown, message, Modal, Button } from 'antd';
|
||
import { LogoutOutlined, TeamOutlined, UserOutlined } from '@ant-design/icons';
|
||
import { PageContainer, ProConfigProvider, ProLayout, type MenuDataItem, type ProSettings } from '@ant-design/pro-components';
|
||
import { Icon } from '@iconify/react';
|
||
import { MdDarkMode, MdLightMode } from "react-icons/md";
|
||
import { useUserActions, useUserInfo } from '@/store/userStore';
|
||
import PersonalInfo from './PersonalInfo';
|
||
import { IPermission } from '@/models/types';
|
||
import { useTheme } from '@/utils/theme';
|
||
import ThemeSwitcher from './ThemeSwitcher';
|
||
import ScriptLibrary from '../ScriptLibrary/index';
|
||
import TodoManager from '../TodoManager/index';
|
||
import { useSocket } from '@/hooks/useSocket';
|
||
|
||
// Layout组件的Props类型定义
|
||
interface LayoutProps {
|
||
children: React.ReactNode;
|
||
}
|
||
|
||
// 生成动态路由的函数 - 优化版本,添加缓存机制
|
||
const routeCache = new Map<string, MenuDataItem[]>();
|
||
|
||
const generateDynamicRoutes = (permissions: IPermission[]): MenuDataItem[] => {
|
||
if (!permissions?.length) return [];
|
||
|
||
// 关键代码行注释:生成缓存键
|
||
const cacheKey = permissions.map(p => `${p.路径 || ''}-${p.排序 ?? 0}`).join('|');
|
||
|
||
// 关键代码行注释:尝试从缓存获取
|
||
if (routeCache.has(cacheKey)) {
|
||
return routeCache.get(cacheKey)!;
|
||
}
|
||
|
||
// 对权限进行排序
|
||
const sortedPermissions = permissions.sort((a, b) => (a.排序 ?? 0) - (b.排序 ?? 0));
|
||
|
||
// 映射权限数据到菜单项
|
||
const routes = sortedPermissions.map(permission => ({
|
||
path: permission.路径 || '/',
|
||
name: permission.名称 || '',
|
||
icon: <Icon icon={(permission.Icon as string) || 'ant-design:home-outlined'} width="20" height="20" />,
|
||
component: './DynamicComponent',
|
||
routes: permission.子级 ? generateDynamicRoutes(permission.子级) : undefined
|
||
}) as Partial<MenuDataItem>);
|
||
|
||
// 关键代码行注释:存储到缓存
|
||
routeCache.set(cacheKey, routes);
|
||
|
||
// 关键代码行注释:限制缓存大小
|
||
if (routeCache.size > 50) {
|
||
const firstKey = routeCache.keys().next().value;
|
||
if (firstKey) {
|
||
routeCache.delete(firstKey);
|
||
}
|
||
}
|
||
|
||
return routes;
|
||
};
|
||
|
||
// 默认props配置
|
||
const defaultProps = {
|
||
route: {
|
||
path: '/',
|
||
routes: [],
|
||
},
|
||
location: {
|
||
pathname: '/',
|
||
},
|
||
};
|
||
|
||
// 头部标题渲染组件 - 使用React.memo优化性能
|
||
const HeaderTitle: React.FC = React.memo(() => (
|
||
<a>
|
||
<img
|
||
src="/aoun.png"
|
||
alt="logo"
|
||
style={{
|
||
height: '32px',
|
||
width: '32px',
|
||
marginRight: '8px',
|
||
borderRadius: '50%'
|
||
}}
|
||
/>
|
||
私域管理系统
|
||
</a>
|
||
));
|
||
|
||
HeaderTitle.displayName = 'HeaderTitle';
|
||
|
||
// 用户信息显示组件 - 新增独立组件,使用React.memo优化
|
||
const UserInfoDisplay: React.FC<{ userInfo: any; collapsed?: boolean; }> = React.memo(({
|
||
userInfo,
|
||
collapsed
|
||
}) => {
|
||
if (collapsed) return null;
|
||
|
||
return (
|
||
<>
|
||
{/* 团队信息部分 */}
|
||
<div style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
marginBottom: 12
|
||
}}>
|
||
<TeamOutlined style={{ fontSize: 18, color: '#1890ff', marginRight: 8 }} />
|
||
<span style={{ fontSize: '14px', fontWeight: 500 }}>
|
||
团队:{userInfo?.团队?.名称 || 'N/A'}
|
||
</span>
|
||
</div>
|
||
|
||
{/* 角色信息部分 */}
|
||
<div style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
marginBottom: 12
|
||
}}>
|
||
<UserOutlined style={{ fontSize: 18, color: '#52c41a', marginRight: 8 }} />
|
||
<span style={{ fontSize: '14px', fontWeight: 500 }}>
|
||
角色:{userInfo?.角色?.名称 || 'N/A'}
|
||
</span>
|
||
</div>
|
||
</>
|
||
);
|
||
});
|
||
|
||
UserInfoDisplay.displayName = 'UserInfoDisplay';
|
||
|
||
// 菜单底部渲染组件 - 优化版本,使用React.memo
|
||
const MenuFooter: React.FC<{
|
||
collapsed?: boolean;
|
||
userInfo: any;
|
||
onShowScriptLibrary: () => void;
|
||
onShowTodoManager: () => void;
|
||
}> = React.memo(({
|
||
collapsed,
|
||
userInfo,
|
||
onShowScriptLibrary,
|
||
onShowTodoManager
|
||
}) => {
|
||
if (collapsed) return undefined;
|
||
|
||
return (
|
||
<div style={{ textAlign: 'center', padding: '0px', borderRadius: '8px' }}>
|
||
{/* 用户信息部分 */}
|
||
<UserInfoDisplay userInfo={userInfo} collapsed={collapsed} />
|
||
|
||
{/* 话术库模态框按钮 */}
|
||
<div style={{ marginTop: '12px', marginBottom: '12px' }}>
|
||
<Button
|
||
type="primary"
|
||
onClick={onShowScriptLibrary}
|
||
style={{
|
||
width: '100%',
|
||
height: '36px',
|
||
fontSize: '14px',
|
||
fontWeight: 500,
|
||
borderRadius: '6px',
|
||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||
border: 'none',
|
||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||
}}
|
||
>
|
||
📚 话术库
|
||
</Button>
|
||
</div>
|
||
{/* 代办事项抽屉按钮 */}
|
||
<div style={{ marginTop: '12px', marginBottom: '12px' }}>
|
||
<Button
|
||
type="primary"
|
||
onClick={onShowTodoManager}
|
||
style={{
|
||
width: '100%',
|
||
height: '36px',
|
||
fontSize: '14px',
|
||
fontWeight: 500,
|
||
borderRadius: '6px',
|
||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||
border: 'none',
|
||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||
}}
|
||
>
|
||
📋 待办事项
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 分割线 */}
|
||
<Divider style={{ margin: '20px 0', borderColor: '#e8e8e8' }} />
|
||
|
||
{/* 底部版权信息 */}
|
||
<div style={{ fontSize: '12px', color: '#999' }}>
|
||
© 2024 AOUN
|
||
</div>
|
||
</div>
|
||
);
|
||
});
|
||
|
||
MenuFooter.displayName = 'MenuFooter';
|
||
|
||
// 主题切换按钮组件 - 新增独立组件,使用React.memo优化
|
||
const ThemeToggleButton: React.FC<{
|
||
toggleTheme: () => void;
|
||
navTheme: 'light' | 'dark';
|
||
}> = React.memo(({ toggleTheme, navTheme }) => (
|
||
<Button
|
||
type="text"
|
||
shape="circle"
|
||
onClick={toggleTheme}
|
||
className="theme-switch-ripple"
|
||
title={navTheme === 'dark' ? '切换到明亮模式' : '切换到暗黑模式'}
|
||
aria-label={navTheme === 'dark' ? '切换到明亮模式' : '切换到暗黑模式'}
|
||
style={{
|
||
marginRight: '8px',
|
||
border: 'none',
|
||
background: 'var(--glass-bg)',
|
||
backdropFilter: 'blur(10px)',
|
||
WebkitBackdropFilter: 'blur(10px)',
|
||
transition: 'all 0.3s ease',
|
||
fontSize: '16px',
|
||
color: navTheme === 'dark' ? 'rgba(255, 255, 255, 0.8)' : 'rgba(10, 17, 40, 0.8)',
|
||
}}
|
||
>
|
||
{navTheme === 'dark' ? <MdLightMode /> : <MdDarkMode />}
|
||
</Button>
|
||
));
|
||
|
||
ThemeToggleButton.displayName = 'ThemeToggleButton';
|
||
|
||
// Layout组件主体
|
||
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||
// 状态管理
|
||
const router = useRouter();
|
||
const userInfo = useUserInfo();
|
||
const userActions = useUserActions();
|
||
|
||
// 初始化 Socket.IO 连接
|
||
useSocket();
|
||
const [isClient, setIsClient] = useState(false);
|
||
const [dynamicRoutes, setDynamicRoutes] = useState<MenuDataItem[]>([]);
|
||
const [showPersonalInfo, setShowPersonalInfo] = useState<boolean>(false);
|
||
const { navTheme, toggleTheme, changePrimaryColor, themeToken } = useTheme(() => { });
|
||
const [showScriptLibrary, setShowScriptLibrary] = useState<boolean>(false);
|
||
const [showTodoManager, setShowTodoManager] = useState<boolean>(false);
|
||
|
||
// 使用 useMemo 优化 settings 计算 - 优化依赖项
|
||
const settings = useMemo<Partial<ProSettings>>(() => ({
|
||
fixSiderbar: true,
|
||
layout: "mix",
|
||
splitMenus: false,
|
||
navTheme: navTheme === 'dark' ? 'realDark' : 'light',
|
||
contentWidth: "Fluid",
|
||
colorPrimary: themeToken.colorPrimary,
|
||
title: "私域管理系统V3",
|
||
siderMenuType: "sub",
|
||
fixedHeader: true,
|
||
menuHeaderRender: false,
|
||
}), [navTheme, themeToken.colorPrimary]);
|
||
|
||
// 使用 useCallback 优化事件处理函数 - 优化依赖项
|
||
const handleLogout = useCallback(() => {
|
||
router.push('/');
|
||
userActions.clearUserInfoAndToken();
|
||
}, [router, userActions]);
|
||
|
||
const handleShowPersonalInfo = useCallback(() => {
|
||
setShowPersonalInfo(true);
|
||
}, []);
|
||
|
||
const handleClosePersonalInfo = useCallback(() => {
|
||
setShowPersonalInfo(false);
|
||
}, []);
|
||
|
||
const handleShowScriptLibrary = useCallback(() => {
|
||
setShowScriptLibrary(true);
|
||
}, []);
|
||
|
||
const handleCloseScriptLibrary = useCallback(() => {
|
||
setShowScriptLibrary(false);
|
||
}, []);
|
||
|
||
const handleShowTodoManager = useCallback(() => {
|
||
setShowTodoManager(true);
|
||
}, []);
|
||
|
||
const handleCloseTodoManager = useCallback(() => {
|
||
setShowTodoManager(false);
|
||
}, []);
|
||
|
||
const handleMenuItemClick = useCallback((path: string) => {
|
||
router.push(path || '/');
|
||
}, [router]);
|
||
|
||
// 用户信息更新逻辑 - 优化依赖项
|
||
const { fetchAndSetUserInfo } = useUserActions();
|
||
useEffect(() => {
|
||
const updateUserInfo = async () => {
|
||
try {
|
||
await fetchAndSetUserInfo();
|
||
} catch (error) {
|
||
message.error('无法更新用户信息');
|
||
console.error('用户信息更新失败:', error);
|
||
}
|
||
};
|
||
updateUserInfo();
|
||
}, [fetchAndSetUserInfo]);
|
||
|
||
// 客户端渲染检测
|
||
useEffect(() => {
|
||
setIsClient(true);
|
||
}, []);
|
||
|
||
// 动态路由生成 - 使用 useMemo 优化,添加更精确的依赖项
|
||
const memoizedRoutes = useMemo(() => {
|
||
if (userInfo?.角色?.权限) {
|
||
return generateDynamicRoutes(userInfo.角色.权限);
|
||
}
|
||
return [];
|
||
}, [userInfo?.角色?.权限]);
|
||
|
||
// 更新动态路由 - 优化依赖项
|
||
useEffect(() => {
|
||
setDynamicRoutes(memoizedRoutes);
|
||
}, [memoizedRoutes]);
|
||
|
||
// 下拉菜单配置 - 使用 useMemo 优化,稳定化依赖项
|
||
const dropdownMenuItems = useMemo(() => [
|
||
{
|
||
key: 'profile',
|
||
icon: <UserOutlined />,
|
||
label: '个人资料',
|
||
onClick: handleShowPersonalInfo,
|
||
},
|
||
{
|
||
key: 'logout',
|
||
icon: <LogoutOutlined />,
|
||
label: '退出登录',
|
||
onClick: handleLogout
|
||
},
|
||
], [handleShowPersonalInfo, handleLogout]);
|
||
|
||
// 菜单项渲染函数 - 使用 useCallback 优化
|
||
const renderMenuItem = useCallback((item: any, dom: React.ReactNode) => (
|
||
<div
|
||
onClick={() => handleMenuItemClick(item.path)}
|
||
style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
|
||
>
|
||
{item.icon && (
|
||
<span
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
marginRight: 4,
|
||
lineHeight: 1,
|
||
}}
|
||
>
|
||
{item.icon}
|
||
</span>
|
||
)}
|
||
<span>{dom}</span>
|
||
</div>
|
||
), [handleMenuItemClick]);
|
||
|
||
// 头像渲染函数 - 使用 useCallback 优化,稳定化依赖项
|
||
const renderAvatar = useCallback((_props: any, dom: React.ReactNode) => (
|
||
<>
|
||
{/* 主题切换按钮 */}
|
||
<ThemeToggleButton toggleTheme={toggleTheme} navTheme={navTheme} />
|
||
|
||
{/* 主题色选择器 */}
|
||
<ThemeSwitcher
|
||
value={themeToken.colorPrimary}
|
||
onChange={changePrimaryColor}
|
||
toggleTheme={toggleTheme}
|
||
navTheme={navTheme}
|
||
/>
|
||
|
||
{/* 下拉菜单按钮 */}
|
||
<Dropdown menu={{ items: dropdownMenuItems }}>
|
||
{dom}
|
||
</Dropdown>
|
||
</>
|
||
), [themeToken.colorPrimary, changePrimaryColor, toggleTheme, navTheme, dropdownMenuItems]);
|
||
|
||
// 菜单底部渲染函数 - 使用useCallback优化
|
||
const renderMenuFooter = useCallback((props: any) => (
|
||
<MenuFooter
|
||
collapsed={props?.collapsed}
|
||
userInfo={userInfo}
|
||
onShowScriptLibrary={handleShowScriptLibrary}
|
||
onShowTodoManager={handleShowTodoManager}
|
||
/>
|
||
), [userInfo, handleShowScriptLibrary, handleShowTodoManager]);
|
||
|
||
// 如果不在客户端,不渲染任何内容
|
||
if (!isClient) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<div id="test-pro-layout" style={{
|
||
height: '100vh',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
}}>
|
||
<ProConfigProvider hashed={false}>
|
||
<ConfigProvider
|
||
getTargetContainer={() => document.getElementById('test-pro-layout') || document.body}
|
||
>
|
||
<ProLayout
|
||
prefixCls="my-prefix"
|
||
{...defaultProps}
|
||
location={{ pathname: router.pathname }}
|
||
token={{
|
||
// 头部菜单选中项的背景颜色
|
||
header: { colorBgMenuItemSelected: 'rgba(0,0,0,0.04)' },
|
||
// PageContainer 内边距控制 - 完全移除左右空白
|
||
pageContainer: {
|
||
// 移除 PageContainer 内容区域的上下内边距 (Block 方向,即垂直方向)
|
||
paddingBlockPageContainerContent: 0,
|
||
// 移除 PageContainer 内容区域的左右内边距 (Inline 方向,即水平方向)
|
||
// 这是消除左右空白的关键配置之一
|
||
paddingInlinePageContainerContent: 0,
|
||
}
|
||
}}
|
||
siderMenuType="group"
|
||
menu={{
|
||
request: async () => dynamicRoutes,
|
||
collapsedShowGroupTitle: true
|
||
}}
|
||
avatarProps={{
|
||
src: 'https://gw.alipayobjects.com/zos/antfincdn/efFD$IOql2/weixintupian_20170331104822.jpg',
|
||
size: 'small',
|
||
title: userInfo?.姓名 || 'N/A',
|
||
render: renderAvatar,
|
||
}}
|
||
headerTitleRender={() => <HeaderTitle />}
|
||
menuFooterRender={renderMenuFooter}
|
||
menuItemRender={renderMenuItem}
|
||
{...settings}
|
||
style={{
|
||
height: '100vh',
|
||
background: 'transparent',
|
||
}}
|
||
contentStyle={{
|
||
background: 'var(--glass-bg)',
|
||
backdropFilter: 'blur(10px)',
|
||
WebkitBackdropFilter: 'blur(10px)',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
padding: 0,
|
||
margin: 0,
|
||
}}
|
||
contentWidth="Fluid"
|
||
locale="zh-CN"
|
||
>
|
||
{/*
|
||
内容区域边距移除方案说明:
|
||
|
||
为了完全移除页面内容的左右空白,采用了三层防护措施:
|
||
|
||
1. ProLayout 层级:通过 token.pageContainer.paddingInlinePageContainerContent = 0
|
||
移除 ProLayout 组件默认的左右内边距
|
||
|
||
2. PageContainer 层级:
|
||
- pageHeaderRender={false} 禁用头部渲染避免额外空间
|
||
- token.paddingInlinePageContainerContent = 0 再次确保移除左右内边距
|
||
- style.padding = 0 移除组件自身样式内边距
|
||
|
||
3. 最内层 div:padding = '0px' 作为最后一道防线
|
||
|
||
这样的多层配置确保在不同版本的 Ant Design Pro 中都能正常工作
|
||
*/}
|
||
<PageContainer
|
||
// 移除默认的页面标题栏
|
||
header={{ title: null }}
|
||
// 完全禁用页面头部渲染 - 确保没有额外的头部空间占用
|
||
pageHeaderRender={false}
|
||
style={{
|
||
flex: 1,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
// 移除 PageContainer 自身的内边距
|
||
padding: 0,
|
||
margin: 0,
|
||
}}
|
||
// PageContainer 级别的内边距控制 - 双重保险移除所有内边距
|
||
token={{
|
||
// 移除内容区域的上下内边距 (垂直方向)
|
||
paddingBlockPageContainerContent: 0,
|
||
// 移除内容区域的左右内边距 (水平方向)
|
||
// 与 ProLayout 的 token 配置配合,确保完全移除左右空白
|
||
paddingInlinePageContainerContent: 0,
|
||
}}
|
||
>
|
||
{/* 最内层容器 - 实际承载页面内容的容器 */}
|
||
<div style={{
|
||
flex: 1,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
// 最终的边距控制 - 设置为 0 完全移除所有内边距
|
||
// 这是移除左右空白的最后一道防线
|
||
// 可以根据需要调整:如 '16px' 恢复默认边距,'0 16px' 只保留左右边距等
|
||
padding: '0px',
|
||
margin: 0,
|
||
overflow: 'auto',
|
||
boxSizing: 'border-box',
|
||
}}>
|
||
{children}
|
||
</div>
|
||
</PageContainer>
|
||
</ProLayout>
|
||
</ConfigProvider>
|
||
</ProConfigProvider>
|
||
|
||
{/* 个人资料模态框 */}
|
||
<Modal
|
||
title="个人资料"
|
||
open={showPersonalInfo}
|
||
onCancel={handleClosePersonalInfo}
|
||
footer={null}
|
||
width={800}
|
||
destroyOnHidden
|
||
>
|
||
<PersonalInfo />
|
||
</Modal>
|
||
|
||
{/* 话术库模态框 */}
|
||
<Modal
|
||
title="话术库"
|
||
open={showScriptLibrary}
|
||
onCancel={handleCloseScriptLibrary}
|
||
footer={null}
|
||
width={1200}
|
||
destroyOnHidden
|
||
styles={{
|
||
body: { padding: '16px' }
|
||
}}
|
||
>
|
||
<ScriptLibrary />
|
||
</Modal>
|
||
|
||
{/* 待办事项管理器 */}
|
||
<TodoManager
|
||
open={showTodoManager}
|
||
onClose={handleCloseTodoManager}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// 使用React.memo包装整个Layout组件 - 性能优化最后一步
|
||
export default React.memo(Layout);
|