|
@@ -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"
|
|
|
|
|
+ theme='light'
|
|
|
|
|
+ style={{
|
|
|
|
|
+ overflow: 'auto',
|
|
|
|
|
+ height: '100vh',
|
|
|
|
|
+ position: 'fixed',
|
|
|
|
|
+ left: 0,
|
|
|
|
|
+ top: 0,
|
|
|
|
|
+ bottom: 0,
|
|
|
|
|
+ zIndex: 100,
|
|
|
|
|
+ transition: 'all 0.2s ease',
|
|
|
|
|
+ boxShadow: '2px 0 8px 0 rgba(29, 35, 41, 0.05)',
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <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' }}>
|
|
|
|
|
+ <div className="sticky top-0 z-50 bg-white shadow-sm transition-all duration-200 h-16 flex items-center justify-between pl-2"
|
|
|
|
|
+ style={{
|
|
|
|
|
+ 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>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <Content className="m-6" style={{ overflow: 'initial', transition: 'all 0.2s ease' }}>
|
|
|
|
|
+ <div className="site-layout-content p-6 rounded-lg bg-white shadow-sm">
|
|
|
|
|
+ <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>
|
|
|
|
|
+ );
|
|
|
|
|
+};
|