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,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)
}
})
})

View File

@@ -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
}
}

View File

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

View File

@@ -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;
}
}

View File

@@ -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>
)
}

View File

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

View File

@@ -0,0 +1,2 @@
export { ErrorBoundary } from './ErrorBoundary'
export { PageHeader } from './PageHeader'