223 lines
6.0 KiB
TypeScript
223 lines
6.0 KiB
TypeScript
import React, { useState, useEffect, useMemo } from 'react';
|
|
import {
|
|
Outlet,
|
|
useLocation,
|
|
} from 'react-router';
|
|
import {
|
|
Layout, Button, Space, Badge, Avatar, Dropdown, Typography, Input, Menu,
|
|
} from 'antd';
|
|
import {
|
|
MenuFoldOutlined,
|
|
MenuUnfoldOutlined,
|
|
BellOutlined,
|
|
VerticalAlignTopOutlined,
|
|
UserOutlined
|
|
} from '@ant-design/icons';
|
|
import { useAuth } from '../hooks/AuthProvider';
|
|
import { useMenu, useMenuSearch, type MenuItem } from '../menu';
|
|
import { getGlobalConfig } from '@/client/utils/utils';
|
|
|
|
const { Header, Sider, Content } = Layout;
|
|
|
|
/**
|
|
* 主布局组件
|
|
* 包含侧边栏、顶部导航和内容区域
|
|
*/
|
|
export const MainLayout = () => {
|
|
const { user } = useAuth();
|
|
const [showBackTop, setShowBackTop] = useState(false);
|
|
const location = useLocation();
|
|
|
|
// 使用菜单hook
|
|
const {
|
|
menuItems,
|
|
userMenuItems,
|
|
openKeys,
|
|
collapsed,
|
|
setCollapsed,
|
|
handleMenuClick: handleRawMenuClick,
|
|
onOpenChange
|
|
} = useMenu();
|
|
|
|
// 处理菜单点击
|
|
const handleMenuClick = (key: string) => {
|
|
const item = findMenuItem(menuItems, key);
|
|
if (item && 'label' in item) {
|
|
handleRawMenuClick(item);
|
|
}
|
|
};
|
|
|
|
// 查找菜单项
|
|
const findMenuItem = (items: MenuItem[], key: string): MenuItem | null => {
|
|
for (const item of items) {
|
|
if (!item) continue;
|
|
if (item.key === key) return item;
|
|
if (item.children) {
|
|
const found = findMenuItem(item.children, key);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// 使用菜单搜索hook
|
|
const {
|
|
searchText,
|
|
setSearchText,
|
|
filteredMenuItems
|
|
} = useMenuSearch(menuItems);
|
|
|
|
// 获取当前选中的菜单项
|
|
const selectedKey = useMemo(() => {
|
|
const findSelectedKey = (items: MenuItem[]): string | null => {
|
|
for (const item of items) {
|
|
if (!item) continue;
|
|
if (item.path === location.pathname) return item.key || null;
|
|
if (item.children) {
|
|
const childKey = findSelectedKey(item.children);
|
|
if (childKey) return childKey;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
return findSelectedKey(menuItems) || '';
|
|
}, [location.pathname, menuItems]);
|
|
|
|
// 检测滚动位置,控制回到顶部按钮显示
|
|
useEffect(() => {
|
|
const handleScroll = () => {
|
|
setShowBackTop(window.pageYOffset > 300);
|
|
};
|
|
|
|
window.addEventListener('scroll', handleScroll);
|
|
return () => window.removeEventListener('scroll', handleScroll);
|
|
}, []);
|
|
|
|
// 回到顶部
|
|
const scrollToTop = () => {
|
|
window.scrollTo({
|
|
top: 0,
|
|
behavior: 'smooth'
|
|
});
|
|
};
|
|
|
|
|
|
// 应用名称 - 从CONFIG中获取或使用默认值
|
|
const appName = getGlobalConfig('APP_NAME') || '应用Starter';
|
|
|
|
return (
|
|
<Layout style={{ minHeight: '100vh' }}>
|
|
<Sider
|
|
trigger={null}
|
|
collapsible
|
|
collapsed={collapsed}
|
|
width={240}
|
|
className="custom-sider"
|
|
style={{
|
|
overflow: 'auto',
|
|
height: '100vh',
|
|
position: 'fixed',
|
|
left: 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
zIndex: 100,
|
|
}}
|
|
>
|
|
<div className="p-4">
|
|
<Typography.Title level={2} className="text-xl font-bold truncate">
|
|
{collapsed ? '应用' : appName}
|
|
</Typography.Title>
|
|
|
|
{/* 菜单搜索框 */}
|
|
{!collapsed && (
|
|
<div className="mb-4">
|
|
<Input.Search
|
|
placeholder="搜索菜单..."
|
|
allowClear
|
|
value={searchText}
|
|
onChange={(e) => setSearchText(e.target.value)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 菜单列表 */}
|
|
<Menu
|
|
theme='light'
|
|
mode="inline"
|
|
items={filteredMenuItems}
|
|
openKeys={openKeys}
|
|
selectedKeys={[selectedKey]}
|
|
onOpenChange={onOpenChange}
|
|
onClick={({ key }) => handleMenuClick(key)}
|
|
inlineCollapsed={collapsed}
|
|
/>
|
|
</Sider>
|
|
|
|
<Layout style={{ marginLeft: collapsed ? 80 : 240, transition: 'margin-left 0.2s' }}>
|
|
<Header className="p-0 flex justify-between items-center"
|
|
style={{
|
|
position: 'sticky',
|
|
top: 0,
|
|
zIndex: 99,
|
|
boxShadow: '0 1px 4px rgba(0,21,41,0.08)',
|
|
}}
|
|
>
|
|
<Button
|
|
type="text"
|
|
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
|
onClick={() => setCollapsed(!collapsed)}
|
|
className="w-16 h-16"
|
|
/>
|
|
|
|
<Space size="middle" className="mr-4">
|
|
<Badge count={5} offset={[0, 5]}>
|
|
<Button
|
|
type="text"
|
|
icon={<BellOutlined />}
|
|
/>
|
|
</Badge>
|
|
|
|
<Dropdown menu={{ items: userMenuItems }}>
|
|
<Space className="cursor-pointer">
|
|
<Avatar
|
|
src={user?.avatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?q=80&w=40&auto=format&fit=crop'}
|
|
icon={!user?.avatar && !navigator.onLine && <UserOutlined />}
|
|
/>
|
|
<span>
|
|
{user?.nickname || user?.username}
|
|
</span>
|
|
</Space>
|
|
</Dropdown>
|
|
</Space>
|
|
</Header>
|
|
|
|
<Content className="m-6" style={{ overflow: 'initial' }}>
|
|
<div className="site-layout-content p-6 rounded-lg">
|
|
<Outlet />
|
|
</div>
|
|
|
|
{/* 回到顶部按钮 */}
|
|
{showBackTop && (
|
|
<Button
|
|
type="primary"
|
|
shape="circle"
|
|
icon={<VerticalAlignTopOutlined />}
|
|
size="large"
|
|
onClick={scrollToTop}
|
|
style={{
|
|
position: 'fixed',
|
|
right: 30,
|
|
bottom: 30,
|
|
zIndex: 1000,
|
|
boxShadow: '0 3px 6px rgba(0,0,0,0.16)',
|
|
}}
|
|
/>
|
|
)}
|
|
</Content>
|
|
</Layout>
|
|
</Layout>
|
|
);
|
|
};
|