init
This commit is contained in:
222
src/client/admin/layouts/MainLayout.tsx
Normal file
222
src/client/admin/layouts/MainLayout.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user