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'
|
||||
Reference in New Issue
Block a user