|
|
@@ -1,24 +1,25 @@
|
|
|
-import React, { useState, useEffect, useMemo } from 'react';
|
|
|
+import { 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';
|
|
|
+ Bell,
|
|
|
+ Menu,
|
|
|
+ User,
|
|
|
+ ChevronDown
|
|
|
+} from 'lucide-react';
|
|
|
import { useAuth } from '../hooks/AuthProvider';
|
|
|
-import { useMenu, useMenuSearch, type MenuItem } from '../menu';
|
|
|
+import { useMenu, type MenuItem } from '../menu';
|
|
|
import { getGlobalConfig } from '@/client/utils/utils';
|
|
|
-
|
|
|
-const { Header, Sider, Content } = Layout;
|
|
|
-
|
|
|
+import { Button } from '@/client/components/ui/button';
|
|
|
+import { Input } from '@/client/components/ui/input';
|
|
|
+import { Avatar, AvatarFallback, AvatarImage } from '@/client/components/ui/avatar';
|
|
|
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/client/components/ui/dropdown-menu';
|
|
|
+import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/client/components/ui/sheet';
|
|
|
+import { ScrollArea } from '@/client/components/ui/scroll-area';
|
|
|
+import { cn } from '@/client/lib/utils';
|
|
|
+import { Badge } from '@/client/components/ui/badge';
|
|
|
/**
|
|
|
* 主布局组件
|
|
|
* 包含侧边栏、顶部导航和内容区域
|
|
|
@@ -27,46 +28,17 @@ export const MainLayout = () => {
|
|
|
const { user } = useAuth();
|
|
|
const [showBackTop, setShowBackTop] = useState(false);
|
|
|
const location = useLocation();
|
|
|
+ const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
|
|
|
|
|
// 使用菜单hook
|
|
|
const {
|
|
|
menuItems,
|
|
|
userMenuItems,
|
|
|
- openKeys,
|
|
|
collapsed,
|
|
|
setCollapsed,
|
|
|
- handleMenuClick: handleRawMenuClick,
|
|
|
- onOpenChange
|
|
|
+ handleMenuClick
|
|
|
} = 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 => {
|
|
|
@@ -102,127 +74,183 @@ export const MainLayout = () => {
|
|
|
});
|
|
|
};
|
|
|
|
|
|
-
|
|
|
// 应用名称 - 从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)',
|
|
|
- background: 'linear-gradient(180deg, #001529 0%, #003a6c 100%)',
|
|
|
- }}
|
|
|
- >
|
|
|
- <div className="p-4">
|
|
|
- <Typography.Title level={2} className="text-xl font-bold truncate">
|
|
|
- <span className="text-white">{collapsed ? '应用' : appName}</span>
|
|
|
- </Typography.Title>
|
|
|
-
|
|
|
- {/* 菜单搜索框 */}
|
|
|
- {!collapsed && (
|
|
|
- <div className="mb-4">
|
|
|
- <Input.Search
|
|
|
- placeholder="搜索菜单..."
|
|
|
- allowClear
|
|
|
- value={searchText}
|
|
|
- onChange={(e) => setSearchText(e.target.value)}
|
|
|
- />
|
|
|
- </div>
|
|
|
- )}
|
|
|
- </div>
|
|
|
-
|
|
|
- {/* 菜单列表 */}
|
|
|
- <Menu
|
|
|
- theme='dark'
|
|
|
- mode="inline"
|
|
|
- items={filteredMenuItems}
|
|
|
- openKeys={openKeys}
|
|
|
- selectedKeys={[selectedKey]}
|
|
|
- onOpenChange={onOpenChange}
|
|
|
- onClick={({ key }) => handleMenuClick(key)}
|
|
|
- inlineCollapsed={collapsed}
|
|
|
- style={{
|
|
|
- backgroundColor: 'transparent',
|
|
|
- borderRight: 'none'
|
|
|
- }}
|
|
|
- />
|
|
|
- </Sider>
|
|
|
+
|
|
|
+ // 侧边栏内容
|
|
|
+ const SidebarContent = () => (
|
|
|
+ <div className="flex h-full flex-col">
|
|
|
+ <div className="p-4 border-b">
|
|
|
+ <h2 className="text-lg font-semibold truncate">
|
|
|
+ {collapsed ? '应用' : appName}
|
|
|
+ </h2>
|
|
|
+ {!collapsed && (
|
|
|
+ <div className="mt-4">
|
|
|
+ <Input
|
|
|
+ placeholder="搜索菜单..."
|
|
|
+ className="h-8"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
|
|
|
- <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 8px rgba(0,21,41,0.12)',
|
|
|
- borderBottom: '1px solid #f0f0f0'
|
|
|
- }}
|
|
|
- >
|
|
|
- <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 transition-all duration-300 hover:shadow-md">
|
|
|
+ <ScrollArea className="flex-1">
|
|
|
+ <nav className="p-2">
|
|
|
+ {menuItems.map((item) => (
|
|
|
+ <div key={item.key}>
|
|
|
+ <Button
|
|
|
+ variant={selectedKey === item.key ? "default" : "ghost"}
|
|
|
+ className={cn(
|
|
|
+ "w-full justify-start mb-1",
|
|
|
+ selectedKey === item.key && "bg-primary text-primary-foreground"
|
|
|
+ )}
|
|
|
+ onClick={() => {
|
|
|
+ handleMenuClick(item);
|
|
|
+ setIsMobileMenuOpen(false);
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {item.icon}
|
|
|
+ {!collapsed && <span className="ml-2">{item.label}</span>}
|
|
|
+ </Button>
|
|
|
+
|
|
|
+ {item.children && !collapsed && (
|
|
|
+ <div className="ml-4">
|
|
|
+ {item.children.map((child) => (
|
|
|
+ <Button
|
|
|
+ key={child.key}
|
|
|
+ variant={selectedKey === child.key ? "default" : "ghost"}
|
|
|
+ className={cn(
|
|
|
+ "w-full justify-start mb-1 text-sm",
|
|
|
+ selectedKey === child.key && "bg-primary text-primary-foreground"
|
|
|
+ )}
|
|
|
+ onClick={() => {
|
|
|
+ handleMenuClick(child);
|
|
|
+ setIsMobileMenuOpen(false);
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {child.icon && <span className="ml-2">{child.icon}</span>}
|
|
|
+ <span className={child.icon ? "ml-2" : "ml-6"}>{child.label}</span>
|
|
|
+ </Button>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </nav>
|
|
|
+ </ScrollArea>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="flex h-screen bg-background">
|
|
|
+ {/* Desktop Sidebar */}
|
|
|
+ <aside className={cn(
|
|
|
+ "hidden md:block border-r bg-background transition-all duration-200",
|
|
|
+ collapsed ? "w-16" : "w-64"
|
|
|
+ )}>
|
|
|
+ <SidebarContent />
|
|
|
+ </aside>
|
|
|
+
|
|
|
+ {/* Mobile Sidebar */}
|
|
|
+ <Sheet open={isMobileMenuOpen} onOpenChange={setIsMobileMenuOpen}>
|
|
|
+ <SheetContent side="left" className="w-64 p-0">
|
|
|
+ <SheetHeader className="p-4">
|
|
|
+ <SheetTitle>{appName}</SheetTitle>
|
|
|
+ </SheetHeader>
|
|
|
+ <SidebarContent />
|
|
|
+ </SheetContent>
|
|
|
+ </Sheet>
|
|
|
+
|
|
|
+ <div className="flex-1 flex flex-col overflow-hidden">
|
|
|
+ {/* Header */}
|
|
|
+ <header className="flex h-16 items-center justify-between border-b bg-background px-4">
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <Button
|
|
|
+ variant="ghost"
|
|
|
+ size="icon"
|
|
|
+ className="md:hidden"
|
|
|
+ onClick={() => setIsMobileMenuOpen(true)}
|
|
|
+ >
|
|
|
+ <Menu className="h-4 w-4" />
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ variant="ghost"
|
|
|
+ size="icon"
|
|
|
+ className="hidden md:block"
|
|
|
+ onClick={() => setCollapsed(!collapsed)}
|
|
|
+ >
|
|
|
+ <Menu className="h-4 w-4" />
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="flex items-center gap-4">
|
|
|
+ <Button variant="ghost" size="icon" className="relative">
|
|
|
+ <Bell className="h-4 w-4" />
|
|
|
+ <Badge className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center text-xs">
|
|
|
+ 5
|
|
|
+ </Badge>
|
|
|
+ </Button>
|
|
|
+
|
|
|
+ <DropdownMenu>
|
|
|
+ <DropdownMenuTrigger asChild>
|
|
|
+ <Button variant="ghost" className="relative h-8 w-8 rounded-full">
|
|
|
+ <Avatar className="h-8 w-8">
|
|
|
+ <AvatarImage
|
|
|
+ src={user?.avatarFile?.fullUrl || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?q=80&w=40&auto=format&fit=crop'}
|
|
|
+ alt={user?.username || 'User'}
|
|
|
+ />
|
|
|
+ <AvatarFallback>
|
|
|
+ <User className="h-4 w-4" />
|
|
|
+ </AvatarFallback>
|
|
|
+ </Avatar>
|
|
|
+ </Button>
|
|
|
+ </DropdownMenuTrigger>
|
|
|
+ <DropdownMenuContent className="w-56" align="end" forceMount>
|
|
|
+ <DropdownMenuLabel className="font-normal">
|
|
|
+ <div className="flex flex-col space-y-1">
|
|
|
+ <p className="text-sm font-medium leading-none">
|
|
|
+ {user?.nickname || user?.username}
|
|
|
+ </p>
|
|
|
+ <p className="text-xs leading-none text-muted-foreground">
|
|
|
+ {user?.email}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </DropdownMenuLabel>
|
|
|
+ <DropdownMenuSeparator />
|
|
|
+ {userMenuItems.map((item) => (
|
|
|
+ item.type === 'separator' ? (
|
|
|
+ <DropdownMenuSeparator key={item.key} />
|
|
|
+ ) : (
|
|
|
+ <DropdownMenuItem key={item.key} onClick={item.onClick}>
|
|
|
+ {item.icon && item.icon}
|
|
|
+ <span>{item.label}</span>
|
|
|
+ </DropdownMenuItem>
|
|
|
+ )
|
|
|
+ ))}
|
|
|
+ </DropdownMenuContent>
|
|
|
+ </DropdownMenu>
|
|
|
+ </div>
|
|
|
+ </header>
|
|
|
+
|
|
|
+ {/* Main Content */}
|
|
|
+ <main className="flex-1 overflow-auto p-4">
|
|
|
+ <div className="max-w-7xl mx-auto">
|
|
|
<Outlet />
|
|
|
</div>
|
|
|
|
|
|
- {/* 回到顶部按钮 */}
|
|
|
+ {/* Back to top button */}
|
|
|
{showBackTop && (
|
|
|
<Button
|
|
|
- type="primary"
|
|
|
- shape="circle"
|
|
|
- icon={<VerticalAlignTopOutlined />}
|
|
|
- size="large"
|
|
|
+ size="icon"
|
|
|
+ className="fixed bottom-4 right-4 rounded-full shadow-lg"
|
|
|
onClick={scrollToTop}
|
|
|
- style={{
|
|
|
- position: 'fixed',
|
|
|
- right: 30,
|
|
|
- bottom: 30,
|
|
|
- zIndex: 1000,
|
|
|
- boxShadow: '0 3px 6px rgba(0,0,0,0.16)',
|
|
|
- }}
|
|
|
- />
|
|
|
+ >
|
|
|
+ <ChevronDown className="h-4 w-4 rotate-180" />
|
|
|
+ </Button>
|
|
|
)}
|
|
|
- </Content>
|
|
|
- </Layout>
|
|
|
- </Layout>
|
|
|
+ </main>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
);
|
|
|
-};
|
|
|
+};
|