feat: admin frontend - React + Vite, auth pages, user management, roles, permissions, webhooks, devices, logs

This commit is contained in:
2026-04-02 11:20:20 +08:00
parent dcc1f186f8
commit 4718980ab5
235 changed files with 35682 additions and 0 deletions

View File

@@ -0,0 +1,209 @@
/**
* AdminLayout 样式
*/
.layout {
min-height: 100vh;
background: var(--color-canvas);
}
/* 加载状态 */
.loadingContainer {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-canvas);
}
/* 侧边栏 */
.sider {
background: var(--color-layout) !important;
border-right: 1px solid var(--color-border-soft);
}
.logo {
height: 64px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
color: var(--color-text-strong);
border-bottom: 1px solid var(--color-border-soft);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding: 0 16px;
}
.menu {
background: transparent !important;
border: none !important;
}
.menu :global(.ant-menu-item),
.menu :global(.ant-menu-submenu-title) {
margin: 4px 8px !important;
border-radius: var(--radius-sm) !important;
pointer-events: auto !important;
cursor: pointer !important;
}
.menu :global(.ant-menu-item-selected) {
background: var(--color-primary) !important;
color: var(--color-text-inverse) !important;
}
/* 确保子菜单可展开 */
.menu :global(.ant-menu-submenu-arrow) {
pointer-events: none !important;
}
/* 顶栏 */
.header {
height: 64px;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border-soft);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
position: sticky;
top: 0;
z-index: 10;
}
.headerLeft {
display: flex;
align-items: center;
gap: 16px;
}
.collapseBtn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--color-text-base);
font-size: 18px;
cursor: pointer;
border-radius: var(--radius-sm);
transition: background var(--motion-fast);
}
.collapseBtn:hover {
background: var(--color-surface-muted);
}
.breadcrumb {
font-size: 14px;
color: var(--color-text-muted);
display: flex;
align-items: center;
gap: 0;
}
.breadcrumbLink {
color: var(--color-text-muted);
cursor: pointer;
transition: color var(--motion-fast);
}
.breadcrumbLink:hover {
color: var(--color-primary);
}
.breadcrumbCurrent {
color: var(--color-text-base);
font-weight: 500;
}
.breadcrumbSeparator {
margin: 0 8px;
color: var(--color-text-muted);
}
.headerRight {
display: flex;
align-items: center;
gap: 16px;
}
.userTrigger {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px 8px;
border-radius: var(--radius-sm);
transition: background var(--motion-fast);
}
.userTrigger:hover {
background: var(--color-surface-muted);
}
.userName {
font-size: 14px;
color: var(--color-text-base);
}
/* 内容区 */
.content {
padding: 24px;
min-height: calc(100vh - 64px);
max-width: var(--page-max-width);
width: 100%;
margin: 0 auto;
}
/* 响应式 */
@media (max-width: 1024px) {
.content {
padding: 16px;
}
}
/* 跳过链接 - 可访问性 */
.skipLink {
position: absolute;
top: -40px;
left: 0;
background: var(--color-primary);
color: var(--color-text-inverse);
padding: 8px 16px;
z-index: 1000;
transition: top 0.2s;
text-decoration: none;
border-radius: 0 0 4px 0;
pointer-events: auto;
}
.skipLink:focus {
top: 0;
outline: 3px solid var(--color-primary);
outline-offset: 2px;
}
/* 侧边栏层级 */
.sider {
z-index: 100;
}
/* 确保布局不被遮挡 */
.layout {
position: relative;
}
/* 移动端抽屉样式 */
.mobileDrawer :global(.ant-drawer-header) {
border-bottom: 1px solid var(--color-border-soft);
}
.mobileDrawer :global(.ant-drawer-body) {
padding: 0;
}

View File

@@ -0,0 +1,469 @@
import type { ReactNode } from 'react'
import { act, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { AuthContext, type AuthContextValue } from '@/app/providers/auth-context'
import { AdminLayout } from './AdminLayout'
import styles from './AdminLayout.module.css'
const logoutMock = vi.fn(async () => {})
function flattenChildren(children: ReactNode): string {
if (children === null || children === undefined || typeof children === 'boolean') {
return ''
}
if (typeof children === 'string' || typeof children === 'number') {
return String(children)
}
if (Array.isArray(children)) {
return children.map(flattenChildren).join(' ').trim()
}
if (typeof children === 'object' && 'props' in children) {
return flattenChildren((children as { props?: { children?: ReactNode } }).props?.children)
}
return ''
}
vi.mock('antd', async () => {
const React = await import('react')
type MenuItem = {
key?: string
label?: ReactNode
children?: MenuItem[]
type?: string
onClick?: () => void
}
const Layout = Object.assign(
({
children,
className,
}: {
children?: ReactNode
className?: string
}) => (
<div data-testid="layout" className={className}>
{children}
</div>
),
{
Sider: ({
children,
className,
}: {
children?: ReactNode
className?: string
}) => (
<aside data-testid="sider" className={className}>
{children}
</aside>
),
Header: ({
children,
className,
}: {
children?: ReactNode
className?: string
}) => (
<header data-testid="header" className={className}>
{children}
</header>
),
Content: ({
children,
className,
}: {
children?: ReactNode
className?: string
}) => (
<main data-testid="content" className={className}>
{children}
</main>
),
},
)
function Menu({
items = [],
onClick,
selectedKeys = [],
defaultOpenKeys = [],
}: {
items?: MenuItem[]
onClick?: (info: { key: string }) => void
selectedKeys?: string[]
defaultOpenKeys?: string[]
}) {
const [openKeys, setOpenKeys] = React.useState((defaultOpenKeys ?? []).map(String))
React.useEffect(() => {
setOpenKeys((defaultOpenKeys ?? []).map(String))
}, [defaultOpenKeys])
const renderItem = (item: MenuItem): ReactNode => {
if (item.type === 'divider') {
return <hr key="divider" />
}
const key = String(item.key ?? flattenChildren(item.label))
const label = flattenChildren(item.label)
const hasChildren = Boolean(item.children?.length)
return (
<div key={key}>
<button
type="button"
data-testid={`menu-item-${key}`}
onClick={() => {
if (hasChildren) {
setOpenKeys((current) => (
current.includes(key)
? current.filter((value) => value !== key)
: [...current, key]
))
return
}
onClick?.({ key })
}}
>
{label}
</button>
{hasChildren && openKeys.includes(key) ? item.children?.map(renderItem) : null}
</div>
)
}
return (
<div
data-testid="menu"
data-open-keys={openKeys.join(',')}
data-selected-keys={(selectedKeys ?? []).join(',')}
>
{items.map((item) => renderItem(item as MenuItem))}
</div>
)
}
function Dropdown({
children,
menu,
}: {
children?: ReactNode
menu?: { items?: MenuItem[] }
}) {
const [open, setOpen] = React.useState(false)
return (
<div>
<button type="button" data-testid="dropdown-trigger" onClick={() => setOpen((value) => !value)}>
{children}
</button>
{open ? (
<div data-testid="dropdown-menu">
{menu?.items?.map((item, index) => {
if (!item || item.type === 'divider') {
return <hr key={`dropdown-divider-${index}`} />
}
const key = String(item.key ?? index)
return (
<button
key={key}
type="button"
data-testid={`dropdown-item-${key}`}
onClick={() => {
item.onClick?.()
setOpen(false)
}}
>
{flattenChildren(item.label)}
</button>
)
})}
</div>
) : null}
</div>
)
}
return {
Avatar: ({
src,
style,
icon,
size,
}: {
src?: string | null
style?: { backgroundColor?: string }
icon?: ReactNode
size?: number
}) => (
<div
data-testid="avatar"
data-src={src ?? ''}
data-background={style?.backgroundColor ?? ''}
data-size={String(size ?? '')}
>
{src ? <img alt="avatar" src={src} /> : icon}
</div>
),
Button: ({
children,
icon,
onClick,
htmlType,
...props
}: {
children?: ReactNode
icon?: ReactNode
onClick?: () => void
htmlType?: 'button' | 'submit' | 'reset'
[key: string]: unknown
}) => (
<button type={htmlType ?? 'button'} onClick={onClick} {...props}>
{children ?? icon}
</button>
),
Drawer: ({
open,
title,
children,
onClose,
}: {
open?: boolean
title?: ReactNode
children?: ReactNode
onClose?: () => void
}) => (
open ? (
<div data-testid="drawer">
<div data-testid="drawer-title">{title}</div>
<button type="button" onClick={onClose}>close drawer</button>
{children}
</div>
) : null
),
Dropdown,
Layout,
Menu,
Spin: ({
tip,
size,
children,
}: {
tip?: ReactNode
size?: string
children?: ReactNode
}) => (
<div aria-busy="true" data-testid="spin" data-tip={flattenChildren(tip)} data-size={size}>
{children}
</div>
),
}
})
vi.mock('@ant-design/icons', () => ({
ApiOutlined: () => <span>api-icon</span>,
DashboardOutlined: () => <span>dashboard-icon</span>,
FileTextOutlined: () => <span>file-text-icon</span>,
LogoutOutlined: () => <span>logout-icon</span>,
MenuFoldOutlined: () => <span>menu-fold-icon</span>,
MenuOutlined: () => <span>menu-icon</span>,
MenuUnfoldOutlined: () => <span>menu-unfold-icon</span>,
SafetyOutlined: () => <span>safety-icon</span>,
SettingOutlined: () => <span>setting-icon</span>,
UserOutlined: () => <span>user-icon</span>,
}))
const baseAuthContextValue: AuthContextValue = {
user: {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
nickname: 'admin-nickname',
avatar: '',
status: 1,
},
roles: [],
isAdmin: true,
isAuthenticated: true,
isLoading: false,
onLoginSuccess: async () => {},
logout: () => logoutMock(),
refreshUser: async () => {},
}
function setWindowWidth(width: number) {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
writable: true,
value: width,
})
}
function renderAdminLayout(
authContextValue: Partial<AuthContextValue> = {},
initialEntry: string = '/profile/security',
layoutChildren?: ReactNode,
) {
const value: AuthContextValue = {
...baseAuthContextValue,
...authContextValue,
}
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<AuthContext.Provider value={value}>
<Routes>
<Route path="/" element={<AdminLayout>{layoutChildren}</AdminLayout>}>
<Route path="dashboard" element={<div>Dashboard Page</div>} />
<Route path="users" element={<div>Users Page</div>} />
<Route path="roles" element={<div>Roles Page</div>} />
<Route path="permissions" element={<div>Permissions Page</div>} />
<Route path="logs/login" element={<div>Login Logs Page</div>} />
<Route path="logs/operation" element={<div>Operation Logs Page</div>} />
<Route path="webhooks" element={<div>Webhooks Page</div>} />
<Route path="import-export" element={<div>Import Export Page</div>} />
<Route path="profile" element={<div>Profile Page</div>} />
<Route path="profile/security" element={<div>Security Page</div>} />
</Route>
</Routes>
</AuthContext.Provider>
</MemoryRouter>,
)
}
describe('AdminLayout', () => {
beforeEach(() => {
logoutMock.mockClear()
setWindowWidth(1280)
})
afterEach(() => {
setWindowWidth(1280)
vi.restoreAllMocks()
})
it('shows a loading state while the session is restoring', () => {
renderAdminLayout({ isLoading: true })
expect(screen.getByTestId('spin')).toHaveAttribute('data-tip')
expect(screen.queryByText('Security Page')).not.toBeInTheDocument()
})
it('renders desktop admin navigation, breadcrumbs, collapse state, dropdown actions, and mobile drawer navigation', async () => {
const user = userEvent.setup()
const { container } = renderAdminLayout({ isAdmin: true }, '/profile/security')
expect(container.querySelector(`.${styles.logo}`)).toHaveTextContent('用户管理系统')
expect(container.querySelector(`.${styles.userName}`)).toHaveTextContent('admin-nickname')
expect(screen.getAllByTestId('menu')[0]).toHaveAttribute('data-open-keys', 'profile')
expect(screen.getByText('Security Page')).toBeInTheDocument()
const breadcrumbLink = container.querySelector(`.${styles.breadcrumbLink}`)
expect(breadcrumbLink).not.toBeNull()
await user.click(breadcrumbLink as HTMLElement)
await waitFor(() => expect(screen.getByText('Profile Page')).toBeInTheDocument())
await user.click(screen.getByTestId('menu-item-access-control'))
await user.click(screen.getByTestId('menu-item-/users'))
await waitFor(() => expect(screen.getByText('Users Page')).toBeInTheDocument())
await user.click(screen.getByTestId('dropdown-trigger'))
await user.click(screen.getByTestId('dropdown-item-security'))
await waitFor(() => expect(screen.getByText('Security Page')).toBeInTheDocument())
await user.click(screen.getByTestId('dropdown-trigger'))
await user.click(screen.getByTestId('dropdown-item-profile'))
await waitFor(() => expect(screen.getByText('Profile Page')).toBeInTheDocument())
await user.click(screen.getByTestId('dropdown-trigger'))
await user.click(screen.getByTestId('dropdown-item-logout'))
await waitFor(() => expect(logoutMock).toHaveBeenCalledTimes(1))
const collapseButton = screen.getByText('menu-fold-icon').closest('button')
expect(collapseButton).not.toBeNull()
await user.click(collapseButton as HTMLButtonElement)
expect(container.querySelector(`.${styles.logo}`)).toHaveTextContent('UMS')
expect(screen.getAllByTestId('menu')[0]).toHaveAttribute('data-open-keys', '')
expect(screen.getByText('menu-unfold-icon')).toBeInTheDocument()
await act(async () => {
setWindowWidth(375)
window.dispatchEvent(new Event('resize'))
})
await waitFor(() => expect(screen.getByRole('button', { name: 'menu-icon' })).toBeInTheDocument())
await user.click(screen.getByRole('button', { name: 'menu-icon' }))
const drawer = screen.getByTestId('drawer')
expect(within(drawer).getByTestId('drawer-title')).toHaveTextContent('UMS')
await user.click(within(drawer).getByTestId('menu-item-/dashboard'))
await waitFor(() => expect(screen.getByText('Dashboard Page')).toBeInTheDocument())
expect(screen.queryByTestId('drawer')).not.toBeInTheDocument()
})
it('renders the reduced mobile menu for non-admin users and uses avatar and username fallbacks correctly', async () => {
const user = userEvent.setup()
setWindowWidth(375)
const { container } = renderAdminLayout(
{
isAdmin: false,
user: {
id: 2,
username: 'operator-name',
email: 'operator@example.com',
phone: '',
nickname: '',
avatar: 'https://example.com/avatar.png',
status: 1,
},
},
'/profile',
)
expect(screen.queryByTestId('menu-item-access-control')).not.toBeInTheDocument()
expect(screen.queryByTestId('menu-item-logs')).not.toBeInTheDocument()
expect(screen.getAllByTestId('menu')[0]).toHaveAttribute('data-open-keys', 'profile')
expect(screen.getByTestId('avatar')).toHaveAttribute('data-src', 'https://example.com/avatar.png')
expect(screen.getByTestId('avatar')).toHaveAttribute('data-background', '')
expect(container.querySelector(`.${styles.userName}`)).toHaveTextContent('operator-name')
await user.click(screen.getByRole('button', { name: 'menu-icon' }))
const drawer = screen.getByTestId('drawer')
await user.click(within(drawer).getByTestId('menu-item-/webhooks'))
await waitFor(() => expect(screen.getByText('Webhooks Page')).toBeInTheDocument())
expect(screen.queryByTestId('drawer')).not.toBeInTheDocument()
})
it('opens the logs group for audit routes and prefers explicit children over the outlet while keeping the default user fallback', async () => {
const { container } = renderAdminLayout(
{
user: null,
},
'/logs/login',
<div>Injected Layout Content</div>,
)
expect(screen.getByText('Injected Layout Content')).toBeInTheDocument()
expect(screen.queryByText('Login Logs Page')).not.toBeInTheDocument()
expect(container.querySelector(`.${styles.userName}`)?.textContent?.trim().length).toBeGreaterThan(0)
expect(screen.getAllByTestId('menu')[0]).toHaveAttribute('data-selected-keys', '/logs/login')
expect(container.querySelector(`.${styles.breadcrumb}`)).toHaveTextContent('审计日志')
})
})

View File

@@ -0,0 +1,329 @@
/**
* AdminLayout - 管理后台布局
*
* 布局:侧栏 248px + 顶栏 64px + 内容区
*/
import { useState, useEffect } from 'react'
import { Layout, Menu, Avatar, Dropdown, Spin, Drawer, Button, type MenuProps } from 'antd'
import {
DashboardOutlined,
SafetyOutlined,
FileTextOutlined,
ApiOutlined,
UserOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
MenuOutlined,
LogoutOutlined,
SettingOutlined,
} 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 { Sider, Header, Content } = Layout
// 管理员菜单配置
const adminMenuItems: MenuProps['items'] = [
{
key: '/dashboard',
icon: <DashboardOutlined />,
label: '总览',
},
{
key: 'access-control',
icon: <SafetyOutlined />,
label: '访问控制',
children: [
{ key: '/users', label: '用户管理' },
{ key: '/roles', label: '角色管理' },
{ key: '/permissions', label: '权限管理' },
],
},
{
key: 'logs',
icon: <FileTextOutlined />,
label: '审计日志',
children: [
{ key: '/logs/login', label: '登录日志' },
{ key: '/logs/operation', label: '操作日志' },
],
},
{
key: 'integration',
icon: <ApiOutlined />,
label: '集成能力',
children: [
{ key: '/webhooks', label: 'Webhooks' },
{ key: '/import-export', label: '导入导出' },
],
},
{
key: 'profile',
icon: <UserOutlined />,
label: '我的账户',
children: [
{ key: '/profile', label: '个人资料' },
{ key: '/profile/security', label: '安全设置' },
],
},
]
// 非管理员菜单配置(只有 Webhooks 和个人中心)
const userMenuItems: MenuProps['items'] = [
{
key: '/webhooks',
icon: <ApiOutlined />,
label: 'Webhooks',
},
{
key: 'profile',
icon: <UserOutlined />,
label: '我的账户',
children: [
{ key: '/profile', label: '个人资料' },
{ key: '/profile/security', label: '安全设置' },
],
},
]
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 = () => {
setIsMobile(window.innerWidth < 768)
}
checkMobile()
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [])
// 移动端切换侧边栏
const toggleMobileDrawer = () => {
setMobileDrawerOpen(!mobileDrawerOpen)
}
// 移动端菜单点击后关闭抽屉
const handleMobileMenuClick: MenuProps['onClick'] = (info) => {
navigate(info.key)
setMobileDrawerOpen(false)
}
// 根据是否为管理员选择菜单
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}
>
{/* Logo 区域 */}
<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={toggleMobileDrawer}
className={styles.collapseBtn}
/>
) : (
<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>
)}
</span>
))}
</div>
)}
</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={toggleMobileDrawer}
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>
)
}

View File

@@ -0,0 +1 @@
export { AdminLayout } from './AdminLayout'

View File

@@ -0,0 +1,105 @@
/**
* AuthLayout 样式
*/
.container {
display: flex;
min-height: 100vh;
}
/* 左侧品牌区 */
.brand {
width: 480px;
min-width: 400px;
background: var(--gradient-shell);
padding: 48px;
display: flex;
align-items: flex-end;
position: relative;
overflow: hidden;
}
.brand::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(circle at 20% 20%, rgba(14, 90, 106, 0.1) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(194, 109, 58, 0.08) 0%, transparent 50%);
pointer-events: none;
}
.brandContent {
position: relative;
z-index: 1;
}
.brandTitle {
font-size: 32px;
font-weight: 600;
color: var(--color-text-strong);
margin-bottom: 12px;
}
.brandDesc {
font-size: 16px;
color: var(--color-text-muted);
margin-bottom: 32px;
}
.features {
list-style: none;
padding: 0;
}
.features li {
font-size: 14px;
color: var(--color-text-base);
padding: 8px 0;
padding-left: 24px;
position: relative;
}
.features li::before {
content: '✓';
position: absolute;
left: 0;
color: var(--color-success);
font-weight: 600;
}
/* 右侧表单区 */
.main {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 48px;
background: var(--color-canvas);
}
.formContainer {
width: 100%;
max-width: 420px;
}
/* 响应式 */
@media (max-width: 1024px) {
.brand {
width: 360px;
min-width: 320px;
}
}
@media (max-width: 768px) {
.brand {
display: none;
}
.main {
padding: 24px;
}
}

View File

@@ -0,0 +1,42 @@
/**
* AuthLayout - 认证页面布局
* 用于登录、忘记密码、重置密码等页面
*
* 布局:左侧品牌区 + 右侧表单区
*/
import type { ReactNode } from 'react'
import styles from './AuthLayout.module.css'
interface AuthLayoutProps {
children: ReactNode
}
export function AuthLayout({ children }: AuthLayoutProps) {
return (
<div className={styles.container}>
{/* 左侧品牌区 */}
<aside className={styles.brand}>
<div className={styles.brandContent}>
<h1 className={styles.brandTitle}></h1>
<p className={styles.brandDesc}>
</p>
<ul className={styles.features}>
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
</div>
</aside>
{/* 右侧表单区 */}
<main className={styles.main}>
<div className={styles.formContainer}>
{children}
</div>
</main>
</div>
)
}

View File

@@ -0,0 +1 @@
export { AuthLayout } from './AuthLayout'

View File

@@ -0,0 +1,2 @@
export { AuthLayout } from './AuthLayout'
export { AdminLayout } from './AdminLayout'