Files
user-system/frontend/admin/src/layouts/AdminLayout/AdminLayout.tsx

318 lines
9.0 KiB
TypeScript
Raw Normal View History

/**
* AdminLayout -
*
* 248px + 64px +
*/
import { useEffect, useState } from 'react'
import { Avatar, Button, Drawer, Dropdown, Layout, Menu, Spin, type MenuProps } from 'antd'
import {
ApiOutlined,
DashboardOutlined,
FileTextOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuOutlined,
MenuUnfoldOutlined,
SafetyOutlined,
SettingOutlined,
UserOutlined,
} from '@ant-design/icons'
import type { ReactNode } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '@/app/providers/auth-context'
import { useBreadcrumbs } from '@/lib/hooks/useBreadcrumbs'
import styles from './AdminLayout.module.css'
const { Content, Header, Sider } = Layout
const menuLabel = (testId: string, text: string) => (
<span data-testid={testId}>{text}</span>
)
const adminMenuItems: MenuProps['items'] = [
{
key: '/dashboard',
icon: <DashboardOutlined />,
label: menuLabel('nav-dashboard', '总览'),
},
{
key: 'access-control',
icon: <SafetyOutlined />,
label: menuLabel('nav-group-access-control', '访问控制'),
children: [
{ key: '/users', label: menuLabel('nav-users', '用户管理') },
{ key: '/roles', label: menuLabel('nav-roles', '角色管理') },
{ key: '/permissions', label: menuLabel('nav-permissions', '权限管理') },
],
},
{
key: 'logs',
icon: <FileTextOutlined />,
label: menuLabel('nav-group-logs', '审计日志'),
children: [
{ key: '/logs/login', label: menuLabel('nav-login-logs', '登录日志') },
{ key: '/logs/operation', label: menuLabel('nav-operation-logs', '操作日志') },
],
},
{
key: 'integration',
icon: <ApiOutlined />,
label: menuLabel('nav-group-integration', '集成能力'),
children: [
{ key: '/webhooks', label: menuLabel('nav-webhooks', 'Webhooks') },
{ key: '/import-export', label: menuLabel('nav-import-export', '导入导出') },
],
},
{
key: 'profile',
icon: <UserOutlined />,
label: menuLabel('nav-group-profile', '我的账户'),
children: [
{ key: '/profile', label: menuLabel('nav-profile', '个人资料') },
{ key: '/profile/security', label: menuLabel('nav-profile-security', '安全设置') },
],
},
]
const userMenuItems: MenuProps['items'] = [
{
key: '/webhooks',
icon: <ApiOutlined />,
label: menuLabel('nav-webhooks', 'Webhooks'),
},
{
key: 'profile',
icon: <UserOutlined />,
label: menuLabel('nav-group-profile', '我的账户'),
children: [
{ key: '/profile', label: menuLabel('nav-profile', '个人资料') },
{ key: '/profile/security', label: menuLabel('nav-profile-security', '安全设置') },
],
},
]
interface AdminLayoutProps {
children?: ReactNode
}
export function AdminLayout({ children }: AdminLayoutProps) {
const [collapsed, setCollapsed] = useState(false)
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false)
const [isMobile, setIsMobile] = useState(false)
const location = useLocation()
const navigate = useNavigate()
const { user, isAdmin, logout, isLoading } = useAuth()
const breadcrumbItems = useBreadcrumbs()
useEffect(() => {
const checkMobile = () => {
const nextIsMobile = window.innerWidth < 768
setIsMobile(nextIsMobile)
if (!nextIsMobile) {
setMobileDrawerOpen(false)
}
}
checkMobile()
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [])
const openMobileDrawer = () => {
setMobileDrawerOpen(true)
}
const closeMobileDrawer = () => {
setMobileDrawerOpen(false)
}
const handleMobileMenuClick: MenuProps['onClick'] = (info) => {
navigate(info.key)
closeMobileDrawer()
}
const menuItems = isAdmin ? adminMenuItems : userMenuItems
const selectedKeys = [location.pathname]
const openKeys = collapsed
? []
: [
...(location.pathname.startsWith('/users')
|| location.pathname.startsWith('/roles')
|| location.pathname.startsWith('/permissions')
? ['access-control']
: []),
...(location.pathname.startsWith('/logs') ? ['logs'] : []),
...(location.pathname.startsWith('/webhooks')
|| location.pathname.startsWith('/import-export')
? ['integration']
: []),
...(location.pathname.startsWith('/profile') ? ['profile'] : []),
]
const handleMenuClick: MenuProps['onClick'] = (info) => {
navigate(info.key)
}
const handleBreadcrumbClick = (path: string) => {
navigate(path)
}
const handleLogout = () => {
void logout()
}
const userDropdownItems: MenuProps['items'] = [
{
key: 'profile',
icon: <UserOutlined />,
label: '个人资料',
onClick: () => navigate('/profile'),
},
{
key: 'security',
icon: <SettingOutlined />,
label: '安全设置',
onClick: () => navigate('/profile/security'),
},
{ type: 'divider' },
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
danger: true,
onClick: handleLogout,
},
]
if (isLoading) {
return (
<div className={styles.loadingContainer}>
<Spin size="large" tip="正在恢复会话..." />
</div>
)
}
return (
<Layout className={styles.layout}>
<a href="#main-content" className={styles.skipLink}>
</a>
<Sider
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
width={248}
collapsedWidth={84}
className={styles.sider}
trigger={null}
>
<div className={styles.logo}>
{collapsed ? 'UMS' : '用户管理系统'}
</div>
<Menu
mode="inline"
selectedKeys={selectedKeys}
defaultOpenKeys={openKeys}
items={menuItems}
onClick={handleMenuClick}
className={styles.menu}
style={{ pointerEvents: 'auto' }}
/>
</Sider>
<Layout>
<Header className={styles.header}>
<div className={styles.headerLeft}>
{isMobile ? (
<Button
type="text"
icon={<MenuOutlined />}
onClick={openMobileDrawer}
className={styles.collapseBtn}
data-testid="mobile-nav-trigger"
/>
) : (
<button
className={styles.collapseBtn}
onClick={() => setCollapsed(!collapsed)}
>
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</button>
)}
{breadcrumbItems && breadcrumbItems.length > 0 ? (
<div className={styles.breadcrumb}>
{breadcrumbItems.map((item, index) => (
<span key={index}>
{item.path ? (
<a
className={styles.breadcrumbLink}
onClick={() => handleBreadcrumbClick(item.path as string)}
>
{item.title}
</a>
) : (
<span className={styles.breadcrumbCurrent}>
{item.title}
</span>
)}
{index < breadcrumbItems.length - 1 ? (
<span className={styles.breadcrumbSeparator}>/</span>
) : null}
</span>
))}
</div>
) : null}
</div>
<div className={styles.headerRight}>
<Dropdown menu={{ items: userDropdownItems }} placement="bottomRight">
<div className={styles.userTrigger}>
<Avatar
size={32}
icon={<UserOutlined />}
src={user?.avatar || null}
style={{ backgroundColor: user?.avatar ? undefined : 'var(--color-primary)' }}
/>
<span className={styles.userName}>
{user?.nickname || user?.username || '用户'}
</span>
</div>
</Dropdown>
</div>
</Header>
<Content id="main-content" className={styles.content}>
{children || <Outlet />}
</Content>
</Layout>
<Drawer
title={<div className={styles.logo}>{collapsed ? 'UMS' : '用户管理系统'}</div>}
placement="left"
onClose={closeMobileDrawer}
open={mobileDrawerOpen}
size="default"
className={styles.mobileDrawer}
styles={{ body: { padding: 0 } }}
>
<Menu
mode="inline"
selectedKeys={selectedKeys}
defaultOpenKeys={openKeys}
items={menuItems}
onClick={handleMobileMenuClick}
className={styles.menu}
style={{ pointerEvents: 'auto' }}
/>
</Drawer>
</Layout>
)
}