Files
d8d-vite-starter/src/client/admin/layouts/MainLayout.tsx
D8D Developer d371fbaefa init
2025-06-27 03:31:29 +00:00

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>
);
};