2026-04-02 11:20:20 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* AdminLayout - 管理后台布局
|
2026-05-12 00:28:38 +08:00
|
|
|
|
*
|
2026-04-02 11:20:20 +08:00
|
|
|
|
* 布局:侧栏 248px + 顶栏 64px + 内容区
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2026-05-12 00:28:38 +08:00
|
|
|
|
import { useEffect, useState } from 'react'
|
|
|
|
|
|
import { Avatar, Button, Drawer, Dropdown, Layout, Menu, Spin, type MenuProps } from 'antd'
|
2026-04-02 11:20:20 +08:00
|
|
|
|
import {
|
2026-05-12 00:28:38 +08:00
|
|
|
|
ApiOutlined,
|
2026-04-02 11:20:20 +08:00
|
|
|
|
DashboardOutlined,
|
|
|
|
|
|
FileTextOutlined,
|
2026-05-12 00:28:38 +08:00
|
|
|
|
LogoutOutlined,
|
2026-04-02 11:20:20 +08:00
|
|
|
|
MenuFoldOutlined,
|
|
|
|
|
|
MenuOutlined,
|
2026-05-12 00:28:38 +08:00
|
|
|
|
MenuUnfoldOutlined,
|
|
|
|
|
|
SafetyOutlined,
|
2026-04-02 11:20:20 +08:00
|
|
|
|
SettingOutlined,
|
2026-05-12 00:28:38 +08:00
|
|
|
|
UserOutlined,
|
2026-04-02 11:20:20 +08:00
|
|
|
|
} from '@ant-design/icons'
|
|
|
|
|
|
import type { ReactNode } from 'react'
|
|
|
|
|
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
2026-05-12 00:28:38 +08:00
|
|
|
|
|
2026-04-02 11:20:20 +08:00
|
|
|
|
import { useAuth } from '@/app/providers/auth-context'
|
|
|
|
|
|
import { useBreadcrumbs } from '@/lib/hooks/useBreadcrumbs'
|
2026-05-12 00:28:38 +08:00
|
|
|
|
|
2026-04-02 11:20:20 +08:00
|
|
|
|
import styles from './AdminLayout.module.css'
|
|
|
|
|
|
|
2026-05-12 00:28:38 +08:00
|
|
|
|
const { Content, Header, Sider } = Layout
|
|
|
|
|
|
|
|
|
|
|
|
const menuLabel = (testId: string, text: string) => (
|
|
|
|
|
|
<span data-testid={testId}>{text}</span>
|
|
|
|
|
|
)
|
2026-04-02 11:20:20 +08:00
|
|
|
|
|
|
|
|
|
|
const adminMenuItems: MenuProps['items'] = [
|
|
|
|
|
|
{
|
|
|
|
|
|
key: '/dashboard',
|
|
|
|
|
|
icon: <DashboardOutlined />,
|
2026-05-12 00:28:38 +08:00
|
|
|
|
label: menuLabel('nav-dashboard', '总览'),
|
2026-04-02 11:20:20 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'access-control',
|
|
|
|
|
|
icon: <SafetyOutlined />,
|
2026-05-12 00:28:38 +08:00
|
|
|
|
label: menuLabel('nav-group-access-control', '访问控制'),
|
2026-04-02 11:20:20 +08:00
|
|
|
|
children: [
|
2026-05-12 00:28:38 +08:00
|
|
|
|
{ key: '/users', label: menuLabel('nav-users', '用户管理') },
|
|
|
|
|
|
{ key: '/roles', label: menuLabel('nav-roles', '角色管理') },
|
|
|
|
|
|
{ key: '/permissions', label: menuLabel('nav-permissions', '权限管理') },
|
2026-04-02 11:20:20 +08:00
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'logs',
|
|
|
|
|
|
icon: <FileTextOutlined />,
|
2026-05-12 00:28:38 +08:00
|
|
|
|
label: menuLabel('nav-group-logs', '审计日志'),
|
2026-04-02 11:20:20 +08:00
|
|
|
|
children: [
|
2026-05-12 00:28:38 +08:00
|
|
|
|
{ key: '/logs/login', label: menuLabel('nav-login-logs', '登录日志') },
|
|
|
|
|
|
{ key: '/logs/operation', label: menuLabel('nav-operation-logs', '操作日志') },
|
2026-04-02 11:20:20 +08:00
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'integration',
|
|
|
|
|
|
icon: <ApiOutlined />,
|
2026-05-12 00:28:38 +08:00
|
|
|
|
label: menuLabel('nav-group-integration', '集成能力'),
|
2026-04-02 11:20:20 +08:00
|
|
|
|
children: [
|
2026-05-12 00:28:38 +08:00
|
|
|
|
{ key: '/webhooks', label: menuLabel('nav-webhooks', 'Webhooks') },
|
|
|
|
|
|
{ key: '/import-export', label: menuLabel('nav-import-export', '导入导出') },
|
2026-04-02 11:20:20 +08:00
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'profile',
|
|
|
|
|
|
icon: <UserOutlined />,
|
2026-05-12 00:28:38 +08:00
|
|
|
|
label: menuLabel('nav-group-profile', '我的账户'),
|
2026-04-02 11:20:20 +08:00
|
|
|
|
children: [
|
2026-05-12 00:28:38 +08:00
|
|
|
|
{ key: '/profile', label: menuLabel('nav-profile', '个人资料') },
|
|
|
|
|
|
{ key: '/profile/security', label: menuLabel('nav-profile-security', '安全设置') },
|
2026-04-02 11:20:20 +08:00
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
const userMenuItems: MenuProps['items'] = [
|
|
|
|
|
|
{
|
|
|
|
|
|
key: '/webhooks',
|
|
|
|
|
|
icon: <ApiOutlined />,
|
2026-05-12 00:28:38 +08:00
|
|
|
|
label: menuLabel('nav-webhooks', 'Webhooks'),
|
2026-04-02 11:20:20 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'profile',
|
|
|
|
|
|
icon: <UserOutlined />,
|
2026-05-12 00:28:38 +08:00
|
|
|
|
label: menuLabel('nav-group-profile', '我的账户'),
|
2026-04-02 11:20:20 +08:00
|
|
|
|
children: [
|
2026-05-12 00:28:38 +08:00
|
|
|
|
{ key: '/profile', label: menuLabel('nav-profile', '个人资料') },
|
|
|
|
|
|
{ key: '/profile/security', label: menuLabel('nav-profile-security', '安全设置') },
|
2026-04-02 11:20:20 +08:00
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
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 = () => {
|
2026-05-12 00:28:38 +08:00
|
|
|
|
const nextIsMobile = window.innerWidth < 768
|
|
|
|
|
|
setIsMobile(nextIsMobile)
|
|
|
|
|
|
if (!nextIsMobile) {
|
|
|
|
|
|
setMobileDrawerOpen(false)
|
|
|
|
|
|
}
|
2026-04-02 11:20:20 +08:00
|
|
|
|
}
|
2026-05-12 00:28:38 +08:00
|
|
|
|
|
2026-04-02 11:20:20 +08:00
|
|
|
|
checkMobile()
|
|
|
|
|
|
window.addEventListener('resize', checkMobile)
|
|
|
|
|
|
return () => window.removeEventListener('resize', checkMobile)
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
2026-05-12 00:28:38 +08:00
|
|
|
|
const openMobileDrawer = () => {
|
|
|
|
|
|
setMobileDrawerOpen(true)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const closeMobileDrawer = () => {
|
|
|
|
|
|
setMobileDrawerOpen(false)
|
2026-04-02 11:20:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleMobileMenuClick: MenuProps['onClick'] = (info) => {
|
|
|
|
|
|
navigate(info.key)
|
2026-05-12 00:28:38 +08:00
|
|
|
|
closeMobileDrawer()
|
2026-04-02 11:20:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const menuItems = isAdmin ? adminMenuItems : userMenuItems
|
|
|
|
|
|
const selectedKeys = [location.pathname]
|
|
|
|
|
|
|
|
|
|
|
|
const openKeys = collapsed
|
|
|
|
|
|
? []
|
|
|
|
|
|
: [
|
2026-05-12 00:28:38 +08:00
|
|
|
|
...(location.pathname.startsWith('/users')
|
|
|
|
|
|
|| location.pathname.startsWith('/roles')
|
|
|
|
|
|
|| location.pathname.startsWith('/permissions')
|
2026-04-02 11:20:20 +08:00
|
|
|
|
? ['access-control']
|
|
|
|
|
|
: []),
|
|
|
|
|
|
...(location.pathname.startsWith('/logs') ? ['logs'] : []),
|
2026-05-12 00:28:38 +08:00
|
|
|
|
...(location.pathname.startsWith('/webhooks')
|
|
|
|
|
|
|| location.pathname.startsWith('/import-export')
|
2026-04-02 11:20:20 +08:00
|
|
|
|
? ['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 />}
|
2026-05-12 00:28:38 +08:00
|
|
|
|
onClick={openMobileDrawer}
|
2026-04-02 11:20:20 +08:00
|
|
|
|
className={styles.collapseBtn}
|
2026-05-12 00:28:38 +08:00
|
|
|
|
data-testid="mobile-nav-trigger"
|
2026-04-02 11:20:20 +08:00
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
2026-05-12 00:28:38 +08:00
|
|
|
|
<button
|
2026-04-02 11:20:20 +08:00
|
|
|
|
className={styles.collapseBtn}
|
|
|
|
|
|
onClick={() => setCollapsed(!collapsed)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-12 00:28:38 +08:00
|
|
|
|
{breadcrumbItems && breadcrumbItems.length > 0 ? (
|
2026-04-02 11:20:20 +08:00
|
|
|
|
<div className={styles.breadcrumb}>
|
|
|
|
|
|
{breadcrumbItems.map((item, index) => (
|
|
|
|
|
|
<span key={index}>
|
|
|
|
|
|
{item.path ? (
|
2026-05-12 00:28:38 +08:00
|
|
|
|
<a
|
2026-04-02 11:20:20 +08:00
|
|
|
|
className={styles.breadcrumbLink}
|
|
|
|
|
|
onClick={() => handleBreadcrumbClick(item.path as string)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{item.title}
|
|
|
|
|
|
</a>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className={styles.breadcrumbCurrent}>
|
|
|
|
|
|
{item.title}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
2026-05-12 00:28:38 +08:00
|
|
|
|
{index < breadcrumbItems.length - 1 ? (
|
2026-04-02 11:20:20 +08:00
|
|
|
|
<span className={styles.breadcrumbSeparator}>/</span>
|
2026-05-12 00:28:38 +08:00
|
|
|
|
) : null}
|
2026-04-02 11:20:20 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
2026-05-12 00:28:38 +08:00
|
|
|
|
) : null}
|
2026-04-02 11:20:20 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className={styles.headerRight}>
|
|
|
|
|
|
<Dropdown menu={{ items: userDropdownItems }} placement="bottomRight">
|
|
|
|
|
|
<div className={styles.userTrigger}>
|
2026-05-12 00:28:38 +08:00
|
|
|
|
<Avatar
|
|
|
|
|
|
size={32}
|
2026-04-02 11:20:20 +08:00
|
|
|
|
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
|
2026-05-12 00:28:38 +08:00
|
|
|
|
title={<div className={styles.logo}>{collapsed ? 'UMS' : '用户管理系统'}</div>}
|
2026-04-02 11:20:20 +08:00
|
|
|
|
placement="left"
|
2026-05-12 00:28:38 +08:00
|
|
|
|
onClose={closeMobileDrawer}
|
2026-04-02 11:20:20 +08:00
|
|
|
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|