Files
SaaS2/src/components/layout/Layout.tsx
RUI d8398afa12
Some checks failed
Next.js CI/CD 流水线 / deploy (push) Failing after 22s
0609.1
2025-06-09 01:27:51 +08:00

563 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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. 最内层 divpadding = '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);