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'

View File

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

View File

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

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

View File

@@ -0,0 +1 @@
export { PageLoading, PageEmpty, PageError } from './PageState'

View File

@@ -0,0 +1 @@
export { PageLoading, PageEmpty, PageError } from './PageState'

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

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

View 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()
})
})

View File

@@ -0,0 +1,2 @@
export { RequireAuth } from './RequireAuth'
export { RequireAdmin } from './RequireAdmin'

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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'

View File

@@ -0,0 +1,11 @@
/**
* 布局组件导出
*/
export {
PageLayout,
FilterCard,
TableCard,
TreeCard,
ContentCard,
} from './PageLayout'