feat: admin frontend - React + Vite, auth pages, user management, roles, permissions, webhooks, devices, logs
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ErrorBoundary } from './ErrorBoundary'
|
||||
|
||||
function ThrowingChild(): never {
|
||||
throw new Error('boom')
|
||||
}
|
||||
|
||||
function suppressBoundaryError() {
|
||||
const handler = (event: ErrorEvent) => {
|
||||
if (event.error instanceof Error && event.error.message === 'boom') {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('error', handler)
|
||||
return () => window.removeEventListener('error', handler)
|
||||
}
|
||||
|
||||
describe('ErrorBoundary', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renders children when no error is thrown', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<div>safe child</div>
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('safe child')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the provided fallback when a child throws', () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const cleanupErrorHandler = suppressBoundaryError()
|
||||
|
||||
render(
|
||||
<ErrorBoundary fallback={<div>custom fallback</div>}>
|
||||
<ThrowingChild />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('custom fallback')).toBeInTheDocument()
|
||||
cleanupErrorHandler()
|
||||
})
|
||||
|
||||
it('renders the default error state and resets to the root path', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const cleanupErrorHandler = suppressBoundaryError()
|
||||
|
||||
const locationDescriptor = Object.getOwnPropertyDescriptor(window, 'location')
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: { href: '/current' },
|
||||
})
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowingChild />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('页面出错了')).toBeInTheDocument()
|
||||
expect(screen.getByText('boom')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '刷新页面' }))
|
||||
|
||||
expect(window.location.href).toBe('/')
|
||||
cleanupErrorHandler()
|
||||
|
||||
if (locationDescriptor) {
|
||||
Object.defineProperty(window, 'location', locationDescriptor)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* ErrorBoundary - React 错误边界组件
|
||||
* 捕获子组件树中的 JavaScript 错误,记录错误并显示备用 UI
|
||||
*/
|
||||
|
||||
import { Component, type ReactNode, type ErrorInfo } from 'react'
|
||||
import { Result, Button } from 'antd'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
// 可以将错误日志上报给服务器
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo)
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: null })
|
||||
// 刷新页面
|
||||
window.location.href = '/'
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--color-canvas)',
|
||||
}}>
|
||||
<Result
|
||||
status="error"
|
||||
title="页面出错了"
|
||||
subTitle={this.state.error?.message || '抱歉,页面遇到了问题'}
|
||||
extra={
|
||||
<Button type="primary" onClick={this.handleReset}>
|
||||
刷新页面
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ErrorBoundary } from './ErrorBoundary'
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* PageHeader 样式
|
||||
*/
|
||||
|
||||
.container {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.titleArea {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 !important;
|
||||
font-size: 20px !important;
|
||||
font-weight: 600 !important;
|
||||
color: var(--color-text-strong) !important;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 4px 0 0 0 !important;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* PageHeader - 页面头部组件
|
||||
*
|
||||
* 包含面包屑导航、页面标题、描述、操作按钮
|
||||
*/
|
||||
|
||||
import { Breadcrumb, Typography, Space, type BreadcrumbProps } from 'antd'
|
||||
import type { ReactNode } from 'react'
|
||||
import styles from './PageHeader.module.css'
|
||||
|
||||
const { Title, Paragraph } = Typography
|
||||
|
||||
interface PageHeaderProps {
|
||||
/** 面包屑项 */
|
||||
breadcrumb?: BreadcrumbProps['items']
|
||||
/** 页面标题 */
|
||||
title: string
|
||||
/** 页面描述 */
|
||||
description?: string
|
||||
/** 操作按钮区 */
|
||||
actions?: ReactNode
|
||||
/** 底部额外内容 */
|
||||
footer?: ReactNode
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
breadcrumb,
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
footer,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{breadcrumb && breadcrumb.length > 0 && (
|
||||
<Breadcrumb items={breadcrumb} className={styles.breadcrumb} />
|
||||
)}
|
||||
|
||||
<div className={styles.header}>
|
||||
<div className={styles.titleArea}>
|
||||
<Title level={4} className={styles.title}>
|
||||
{title}
|
||||
</Title>
|
||||
{description && (
|
||||
<Paragraph type="secondary" className={styles.description}>
|
||||
{description}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{actions && (
|
||||
<Space className={styles.actions}>
|
||||
{actions}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{footer && (
|
||||
<div className={styles.footer}>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
frontend/admin/src/components/common/PageHeader/index.ts
Normal file
1
frontend/admin/src/components/common/PageHeader/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PageHeader } from './PageHeader'
|
||||
2
frontend/admin/src/components/common/index.ts
Normal file
2
frontend/admin/src/components/common/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ErrorBoundary } from './ErrorBoundary'
|
||||
export { PageHeader } from './PageHeader'
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* PageState 样式
|
||||
*/
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.spinContent {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.emptyContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
margin-bottom: 16px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
.emptyText {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.errorContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { PageEmpty, PageError, PageLoading } from './PageState'
|
||||
|
||||
vi.mock('antd', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
icon,
|
||||
htmlType,
|
||||
...props
|
||||
}: {
|
||||
children?: ReactNode
|
||||
onClick?: () => void
|
||||
icon?: ReactNode
|
||||
htmlType?: 'button' | 'submit' | 'reset'
|
||||
[key: string]: unknown
|
||||
}) => {
|
||||
void icon
|
||||
|
||||
return (
|
||||
<button type={htmlType ?? 'button'} onClick={onClick} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
Empty: ({
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
description?: ReactNode
|
||||
children?: ReactNode
|
||||
}) => (
|
||||
<div data-testid="empty">
|
||||
<div data-testid="empty-description">{description}</div>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Result: ({
|
||||
status,
|
||||
title,
|
||||
subTitle,
|
||||
extra,
|
||||
}: {
|
||||
status?: string
|
||||
title?: ReactNode
|
||||
subTitle?: ReactNode
|
||||
extra?: ReactNode | ReactNode[]
|
||||
}) => (
|
||||
<div data-testid="result" data-status={status}>
|
||||
<div>{title}</div>
|
||||
<div>{subTitle}</div>
|
||||
<div>{extra}</div>
|
||||
</div>
|
||||
),
|
||||
Spin: ({
|
||||
size,
|
||||
tip,
|
||||
children,
|
||||
}: {
|
||||
size?: string
|
||||
tip?: ReactNode
|
||||
children?: ReactNode
|
||||
}) => (
|
||||
<div data-testid="spin" data-size={size}>
|
||||
<span>{tip}</span>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@ant-design/icons', () => ({
|
||||
PlusOutlined: () => <span>plus-icon</span>,
|
||||
ReloadOutlined: () => <span>reload-icon</span>,
|
||||
}))
|
||||
|
||||
describe('PageState', () => {
|
||||
it('renders PageLoading with both default and custom tips', () => {
|
||||
render(
|
||||
<>
|
||||
<PageLoading />
|
||||
<PageLoading tip="loading-dashboard" />
|
||||
</>,
|
||||
)
|
||||
|
||||
expect(screen.getAllByTestId('spin')).toHaveLength(2)
|
||||
expect(screen.getByText('loading-dashboard')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders PageEmpty without an action when the action handler is incomplete', () => {
|
||||
render(<PageEmpty description="no data" actionText="create now" />)
|
||||
|
||||
expect(screen.getByTestId('empty-description')).toHaveTextContent('no data')
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders PageEmpty action button and invokes the handler when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onAction = vi.fn()
|
||||
|
||||
render(
|
||||
<PageEmpty
|
||||
description="empty table"
|
||||
actionText="add first item"
|
||||
onAction={onAction}
|
||||
actionProps={{ 'data-action': 'create' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const button = screen.getByRole('button', { name: 'add first item' })
|
||||
|
||||
expect(button).toHaveAttribute('data-action', 'create')
|
||||
|
||||
await user.click(button)
|
||||
|
||||
expect(onAction).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders PageError defaults without a retry button when onRetry is absent', () => {
|
||||
render(<PageError />)
|
||||
|
||||
expect(screen.getByTestId('result')).toHaveAttribute('data-status', 'error')
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders PageError retry and extra actions when provided', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onRetry = vi.fn()
|
||||
|
||||
render(
|
||||
<PageError
|
||||
title="load failed"
|
||||
description="service unavailable"
|
||||
retryText="retry now"
|
||||
onRetry={onRetry}
|
||||
extra={<span>contact support</span>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('load failed')).toBeInTheDocument()
|
||||
expect(screen.getByText('service unavailable')).toBeInTheDocument()
|
||||
expect(screen.getByText('contact support')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'retry now' }))
|
||||
|
||||
expect(onRetry).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
113
frontend/admin/src/components/feedback/PageState/PageState.tsx
Normal file
113
frontend/admin/src/components/feedback/PageState/PageState.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 页面状态组件
|
||||
*
|
||||
* 提供:
|
||||
* - PageLoading: 页面级加载状态
|
||||
* - PageEmpty: 页面级空状态
|
||||
* - PageError: 页面级错误状态
|
||||
*/
|
||||
|
||||
import { Spin, Button, Result, Empty, type ButtonProps } from 'antd'
|
||||
import { ReloadOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import type { ReactNode } from 'react'
|
||||
import styles from './PageState.module.css'
|
||||
|
||||
// ==================== PageLoading ====================
|
||||
|
||||
interface PageLoadingProps {
|
||||
/** 加载提示文字 */
|
||||
tip?: string
|
||||
}
|
||||
|
||||
export function PageLoading({ tip = '加载中...' }: PageLoadingProps) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Spin size="large" tip={tip}>
|
||||
<div className={styles.spinContent} />
|
||||
</Spin>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== PageEmpty ====================
|
||||
|
||||
interface PageEmptyProps {
|
||||
/** 空状态描述 */
|
||||
description?: string | ReactNode
|
||||
/** 主操作按钮文字 */
|
||||
actionText?: string
|
||||
/** 主操作按钮点击 */
|
||||
onAction?: () => void
|
||||
/** 主操作按钮属性 */
|
||||
actionProps?: ButtonProps
|
||||
}
|
||||
|
||||
export function PageEmpty({
|
||||
description = '暂无数据',
|
||||
actionText,
|
||||
onAction,
|
||||
actionProps,
|
||||
}: PageEmptyProps) {
|
||||
return (
|
||||
<div className={styles.emptyContainer}>
|
||||
<Empty description={description}>
|
||||
{actionText && onAction && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={onAction}
|
||||
{...actionProps}
|
||||
>
|
||||
{actionText}
|
||||
</Button>
|
||||
)}
|
||||
</Empty>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== PageError ====================
|
||||
|
||||
interface PageErrorProps {
|
||||
/** 错误标题 */
|
||||
title?: string
|
||||
/** 错误描述 */
|
||||
description?: string | ReactNode
|
||||
/** 重试按钮文字 */
|
||||
retryText?: string
|
||||
/** 重试按钮点击 */
|
||||
onRetry?: () => void
|
||||
/** 额外操作 */
|
||||
extra?: ReactNode
|
||||
}
|
||||
|
||||
export function PageError({
|
||||
title = '加载失败',
|
||||
description = '数据加载失败,请稍后重试',
|
||||
retryText = '重新加载',
|
||||
onRetry,
|
||||
extra,
|
||||
}: PageErrorProps) {
|
||||
return (
|
||||
<div className={styles.errorContainer}>
|
||||
<Result
|
||||
status="error"
|
||||
title={title}
|
||||
subTitle={description}
|
||||
extra={[
|
||||
onRetry && (
|
||||
<Button
|
||||
key="retry"
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={onRetry}
|
||||
>
|
||||
{retryText}
|
||||
</Button>
|
||||
),
|
||||
extra,
|
||||
].filter(Boolean)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { PageLoading, PageEmpty, PageError } from './PageState'
|
||||
1
frontend/admin/src/components/feedback/index.ts
Normal file
1
frontend/admin/src/components/feedback/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PageLoading, PageEmpty, PageError } from './PageState'
|
||||
30
frontend/admin/src/components/guards/RequireAdmin.tsx
Normal file
30
frontend/admin/src/components/guards/RequireAdmin.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* RequireAdmin - 管理员守卫
|
||||
*
|
||||
* 非管理员时跳转到个人资料页。
|
||||
* 修复:加入 isLoading 检查,避免会话恢复期间误跳转。
|
||||
*/
|
||||
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '@/app/providers/auth-context'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface RequireAdminProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function RequireAdmin({ children }: RequireAdminProps) {
|
||||
const { isAdmin, isLoading } = useAuth()
|
||||
|
||||
// 会话恢复中,等待完成再判断
|
||||
if (isLoading) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 非管理员,跳转到个人资料页
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/profile" replace />
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
40
frontend/admin/src/components/guards/RequireAuth.tsx
Normal file
40
frontend/admin/src/components/guards/RequireAuth.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* RequireAuth - 登录守卫
|
||||
*
|
||||
* 未登录时跳转到登录页
|
||||
*/
|
||||
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '@/app/providers/auth-context'
|
||||
import { Spin } from 'antd'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface RequireAuthProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function RequireAuth({ children }: RequireAuthProps) {
|
||||
const { isAuthenticated, isLoading } = useAuth()
|
||||
const location = useLocation()
|
||||
|
||||
// 加载中显示 loading
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 未登录,跳转到登录页
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
188
frontend/admin/src/components/guards/guards.test.tsx
Normal file
188
frontend/admin/src/components/guards/guards.test.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { AuthContext, type AuthContextValue } from '@/app/providers/auth-context'
|
||||
import { RequireAdmin } from './RequireAdmin'
|
||||
import { RequireAuth } from './RequireAuth'
|
||||
|
||||
const baseAuthContextValue: AuthContextValue = {
|
||||
user: null,
|
||||
roles: [],
|
||||
isAdmin: false,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
onLoginSuccess: async () => {},
|
||||
logout: async () => {},
|
||||
refreshUser: async () => {},
|
||||
}
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation()
|
||||
const fromPath =
|
||||
(location.state as { from?: { pathname?: string } } | null)?.from?.pathname ?? 'none'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="pathname">{location.pathname}</span>
|
||||
<span data-testid="from-path">{fromPath}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderWithAuth(
|
||||
authContextValue: Partial<AuthContextValue>,
|
||||
router: ReactNode,
|
||||
) {
|
||||
const value: AuthContextValue = {
|
||||
...baseAuthContextValue,
|
||||
...authContextValue,
|
||||
}
|
||||
|
||||
return render(
|
||||
<AuthContext.Provider value={value}>
|
||||
{router}
|
||||
</AuthContext.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('RequireAuth', () => {
|
||||
it('shows a loading indicator while auth state is being restored', () => {
|
||||
const { container } = renderWithAuth(
|
||||
{ isLoading: true },
|
||||
<MemoryRouter initialEntries={['/users']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/users"
|
||||
element={(
|
||||
<RequireAuth>
|
||||
<div>private content</div>
|
||||
</RequireAuth>
|
||||
)}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument()
|
||||
expect(screen.queryByText('private content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('redirects unauthenticated users to login and preserves the original route', async () => {
|
||||
renderWithAuth(
|
||||
{ isAuthenticated: false, isLoading: false },
|
||||
<MemoryRouter initialEntries={['/users']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/users"
|
||||
element={(
|
||||
<RequireAuth>
|
||||
<div>private content</div>
|
||||
</RequireAuth>
|
||||
)}
|
||||
/>
|
||||
<Route path="/login" element={<LocationProbe />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(await screen.findByTestId('pathname')).toHaveTextContent('/login')
|
||||
expect(screen.getByTestId('from-path')).toHaveTextContent('/users')
|
||||
})
|
||||
|
||||
it('renders protected content when authenticated', () => {
|
||||
renderWithAuth(
|
||||
{
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
phone: '',
|
||||
nickname: 'Admin',
|
||||
avatar: '',
|
||||
status: 1,
|
||||
},
|
||||
},
|
||||
<MemoryRouter initialEntries={['/users']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/users"
|
||||
element={(
|
||||
<RequireAuth>
|
||||
<div>private content</div>
|
||||
</RequireAuth>
|
||||
)}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('private content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RequireAdmin', () => {
|
||||
it('waits silently while auth state is still loading', () => {
|
||||
const { container } = renderWithAuth(
|
||||
{ isLoading: true, isAdmin: false },
|
||||
<MemoryRouter initialEntries={['/dashboard']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={(
|
||||
<RequireAdmin>
|
||||
<div>admin dashboard</div>
|
||||
</RequireAdmin>
|
||||
)}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('redirects non-admin users to profile', async () => {
|
||||
renderWithAuth(
|
||||
{ isLoading: false, isAdmin: false, isAuthenticated: true },
|
||||
<MemoryRouter initialEntries={['/dashboard']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={(
|
||||
<RequireAdmin>
|
||||
<div>admin dashboard</div>
|
||||
</RequireAdmin>
|
||||
)}
|
||||
/>
|
||||
<Route path="/profile" element={<LocationProbe />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(await screen.findByTestId('pathname')).toHaveTextContent('/profile')
|
||||
})
|
||||
|
||||
it('renders admin-only content for admins', () => {
|
||||
renderWithAuth(
|
||||
{ isLoading: false, isAdmin: true, isAuthenticated: true },
|
||||
<MemoryRouter initialEntries={['/dashboard']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={(
|
||||
<RequireAdmin>
|
||||
<div>admin dashboard</div>
|
||||
</RequireAdmin>
|
||||
)}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('admin dashboard')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
2
frontend/admin/src/components/guards/index.ts
Normal file
2
frontend/admin/src/components/guards/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { RequireAuth } from './RequireAuth'
|
||||
export { RequireAdmin } from './RequireAdmin'
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 统一内容卡片组件
|
||||
*
|
||||
* 功能:
|
||||
* - 提供统一的内容展示区域样式
|
||||
* - 遵循 warm-elegant 设计主题
|
||||
*/
|
||||
|
||||
import { Card } from 'antd'
|
||||
import styles from './PageLayout.module.css'
|
||||
|
||||
interface ContentCardProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
title?: React.ReactNode
|
||||
}
|
||||
|
||||
export function ContentCard({ children, className, style, title }: ContentCardProps) {
|
||||
return (
|
||||
<Card
|
||||
className={`${styles.contentCard} ${className || ''}`}
|
||||
style={style}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 统一筛选卡片组件
|
||||
*
|
||||
* 功能:
|
||||
* - 提供统一的筛选区域样式
|
||||
* - 遵循 warm-elegant 设计主题
|
||||
*/
|
||||
|
||||
import { Card } from 'antd'
|
||||
import styles from './PageLayout.module.css'
|
||||
|
||||
interface FilterCardProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FilterCard({ children, className }: FilterCardProps) {
|
||||
return (
|
||||
<Card className={`${styles.filterCard} ${className || ''}`}>
|
||||
{children}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 统一页面布局样式
|
||||
* 遵循 warm-elegant 设计主题
|
||||
*/
|
||||
|
||||
.pageLayout {
|
||||
padding: var(--space-5, 24px);
|
||||
max-width: var(--page-max-width, 1440px);
|
||||
margin: 0 auto;
|
||||
min-height: calc(100vh - 64px - 48px); /* 减去header和padding */
|
||||
}
|
||||
|
||||
/* 筛选卡片样式 */
|
||||
.filterCard {
|
||||
margin-bottom: var(--space-4, 16px);
|
||||
border-radius: var(--radius-md, 16px) !important;
|
||||
box-shadow: var(--shadow-card, 0 10px 30px rgba(23, 33, 43, 0.06)) !important;
|
||||
background: var(--color-surface, #ffffff) !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.filterCard :global(.ant-card-body) {
|
||||
padding: var(--space-4, 16px) var(--space-5, 24px) !important;
|
||||
}
|
||||
|
||||
/* 表格卡片样式 */
|
||||
.tableCard {
|
||||
border-radius: var(--radius-md, 16px) !important;
|
||||
box-shadow: var(--shadow-card, 0 10px 30px rgba(23, 33, 43, 0.06)) !important;
|
||||
background: var(--color-surface, #ffffff) !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.tableCard :global(.ant-card-body) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.tableCard :global(.ant-table-wrapper) {
|
||||
border-radius: var(--radius-md, 16px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 树形卡片样式 */
|
||||
.treeCard {
|
||||
border-radius: var(--radius-md, 16px) !important;
|
||||
box-shadow: var(--shadow-card, 0 10px 30px rgba(23, 33, 43, 0.06)) !important;
|
||||
background: var(--color-surface, #ffffff) !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.treeCard :global(.ant-card-body) {
|
||||
padding: var(--space-5, 24px) !important;
|
||||
}
|
||||
|
||||
/* 内容卡片样式 */
|
||||
.contentCard {
|
||||
border-radius: var(--radius-md, 16px) !important;
|
||||
box-shadow: var(--shadow-card, 0 10px 30px rgba(23, 33, 43, 0.06)) !important;
|
||||
background: var(--color-surface, #ffffff) !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.contentCard :global(.ant-card-body) {
|
||||
padding: var(--space-5, 24px) !important;
|
||||
}
|
||||
|
||||
/* 操作栏样式 */
|
||||
.actionBar {
|
||||
display: flex;
|
||||
gap: var(--space-2, 8px);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 页面头部样式 */
|
||||
.pageHeader {
|
||||
margin-bottom: var(--space-5, 24px);
|
||||
}
|
||||
|
||||
.pageHeaderTitle {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-strong, #17212b);
|
||||
margin: 0 0 var(--space-2, 8px) 0;
|
||||
}
|
||||
|
||||
.pageHeaderDescription {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-muted, #677380);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pageHeaderActions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* 筛选表单样式 */
|
||||
.filterForm {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3, 12px);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 表格操作按钮统一样式 */
|
||||
.tableActionButton {
|
||||
padding: 0 var(--space-1, 4px) !important;
|
||||
}
|
||||
|
||||
/* 响应式适配 */
|
||||
@media (max-width: 768px) {
|
||||
.pageLayout {
|
||||
padding: var(--space-3, 12px);
|
||||
}
|
||||
|
||||
.filterForm {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.filterForm > * {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 统一页面布局容器
|
||||
*
|
||||
* 功能:
|
||||
* - 提供统一的页面布局结构
|
||||
* - 遵循 warm-elegant 设计主题
|
||||
*/
|
||||
|
||||
import styles from './PageLayout.module.css'
|
||||
|
||||
interface PageLayoutProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PageLayout({ children, className }: PageLayoutProps) {
|
||||
return (
|
||||
<div className={`${styles.pageLayout} ${className || ''}`}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 统一表格卡片组件
|
||||
*
|
||||
* 功能:
|
||||
* - 提供统一的表格区域样式
|
||||
* - 遵循 warm-elegant 设计主题
|
||||
*/
|
||||
|
||||
import { Card } from 'antd'
|
||||
import styles from './PageLayout.module.css'
|
||||
|
||||
interface TableCardProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function TableCard({ children, className }: TableCardProps) {
|
||||
return (
|
||||
<Card className={`${styles.tableCard} ${className || ''}`}>
|
||||
{children}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
23
frontend/admin/src/components/layout/PageLayout/TreeCard.tsx
Normal file
23
frontend/admin/src/components/layout/PageLayout/TreeCard.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 统一树形卡片组件
|
||||
*
|
||||
* 功能:
|
||||
* - 提供统一的树形展示区域样式
|
||||
* - 遵循 warm-elegant 设计主题
|
||||
*/
|
||||
|
||||
import { Card } from 'antd'
|
||||
import styles from './PageLayout.module.css'
|
||||
|
||||
interface TreeCardProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function TreeCard({ children, className }: TreeCardProps) {
|
||||
return (
|
||||
<Card className={`${styles.treeCard} ${className || ''}`}>
|
||||
{children}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
9
frontend/admin/src/components/layout/PageLayout/index.ts
Normal file
9
frontend/admin/src/components/layout/PageLayout/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 统一页面布局组件导出
|
||||
*/
|
||||
|
||||
export { PageLayout } from './PageLayout'
|
||||
export { FilterCard } from './FilterCard'
|
||||
export { TableCard } from './TableCard'
|
||||
export { TreeCard } from './TreeCard'
|
||||
export { ContentCard } from './ContentCard'
|
||||
11
frontend/admin/src/components/layout/index.ts
Normal file
11
frontend/admin/src/components/layout/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 布局组件导出
|
||||
*/
|
||||
|
||||
export {
|
||||
PageLayout,
|
||||
FilterCard,
|
||||
TableCard,
|
||||
TreeCard,
|
||||
ContentCard,
|
||||
} from './PageLayout'
|
||||
Reference in New Issue
Block a user