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,40 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import App from './App'
const routerProviderMock = vi.fn((props: unknown) => {
void props
return <div data-testid="router-provider" />
})
const errorBoundaryMock = vi.fn(({ children }: { children: ReactNode }) => (
<div data-testid="error-boundary">{children}</div>
))
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom')
return {
...actual,
RouterProvider: (props: unknown) => routerProviderMock(props),
}
})
vi.mock('@/components/common', () => ({
ErrorBoundary: (props: { children: React.ReactNode }) => errorBoundaryMock(props),
}))
describe('App', () => {
it('renders the router provider inside the error boundary shell', () => {
render(<App />)
expect(screen.getByTestId('error-boundary')).toBeInTheDocument()
expect(screen.getByTestId('router-provider')).toBeInTheDocument()
expect(errorBoundaryMock).toHaveBeenCalledTimes(1)
expect(routerProviderMock).toHaveBeenCalledWith(
expect.objectContaining({
router: expect.any(Object),
}),
)
})
})

View File

@@ -0,0 +1,38 @@
/**
* Admin Frontend App Shell
*
* 项目:用户管理系统 Admin 后台
* 技术栈React 18 + TypeScript + Vite + Ant Design 5
*/
import { Suspense } from 'react'
import { RouterProvider } from 'react-router-dom'
import { Spin } from 'antd'
import { ErrorBoundary } from '@/components/common'
import { router } from './router'
const routeFallback = (
<div
style={{
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--color-canvas)',
}}
>
<Spin size="large" />
</div>
)
function App() {
return (
<ErrorBoundary>
<Suspense fallback={routeFallback}>
<RouterProvider router={router} />
</Suspense>
</ErrorBoundary>
)
}
export default App

View File

@@ -0,0 +1,27 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { RootLayout } from './RootLayout'
const authProviderMock = vi.fn(({ children }: { children: ReactNode }) => (
<div data-testid="auth-provider">{children}</div>
))
vi.mock('react-router-dom', () => ({
Outlet: () => <div data-testid="root-outlet" />,
}))
vi.mock('./providers/AuthProvider', () => ({
AuthProvider: (props: { children: ReactNode }) => authProviderMock(props),
}))
describe('RootLayout', () => {
it('wraps the route outlet with the auth provider', () => {
render(<RootLayout />)
expect(screen.getByTestId('auth-provider')).toBeInTheDocument()
expect(screen.getByTestId('root-outlet')).toBeInTheDocument()
expect(authProviderMock).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,10 @@
import { Outlet } from 'react-router-dom'
import { AuthProvider } from './providers/AuthProvider'
export function RootLayout() {
return (
<AuthProvider>
<Outlet />
</AuthProvider>
)
}

View File

@@ -0,0 +1,67 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installWindowGuards, restoreWindowGuardsForTest } from './installWindowGuards'
describe('installWindowGuards', () => {
const logger = vi.fn()
beforeEach(() => {
logger.mockReset()
restoreWindowGuardsForTest()
})
afterEach(() => {
restoreWindowGuardsForTest()
})
it('blocks native dialogs and popup windows with structured logs', () => {
installWindowGuards(logger)
expect(window.alert('danger')).toBeUndefined()
expect(window.confirm('continue?')).toBe(false)
expect(window.prompt('name?', 'admin')).toBeNull()
expect(window.open('https://example.com', '_blank')).toBeNull()
expect(logger).toHaveBeenCalledTimes(4)
expect(logger.mock.calls[0][0]).toContain('native-alert-blocked')
expect(logger.mock.calls[1][0]).toContain('native-confirm-blocked')
expect(logger.mock.calls[2][0]).toContain('native-prompt-blocked')
expect(logger.mock.calls[3][0]).toContain('popup-blocked')
})
it('logs window errors and unhandled promise rejections', () => {
installWindowGuards(logger)
const runtimeError = new Error('boom')
window.dispatchEvent(new ErrorEvent('error', {
message: runtimeError.message,
filename: '/app.js',
lineno: 12,
colno: 34,
error: runtimeError,
}))
const rejectionEvent = new Event('unhandledrejection') as PromiseRejectionEvent
Object.defineProperty(rejectionEvent, 'promise', {
value: Promise.resolve(),
configurable: true,
})
Object.defineProperty(rejectionEvent, 'reason', {
value: new Error('rejected'),
configurable: true,
})
window.dispatchEvent(rejectionEvent)
expect(logger).toHaveBeenCalledTimes(2)
expect(logger.mock.calls[0][0]).toContain('[window-guard] error')
expect(logger.mock.calls[1][0]).toContain('[window-guard] unhandledrejection')
})
it('does not install twice', () => {
installWindowGuards(logger)
installWindowGuards(logger)
window.alert('once')
expect(logger).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,144 @@
type GuardLogger = (message?: unknown, ...optionalParams: unknown[]) => void
type WindowGuardOriginals = {
alert: typeof window.alert
confirm: typeof window.confirm
prompt: typeof window.prompt
open: typeof window.open
}
type WindowGuardListeners = {
error: (event: ErrorEvent) => void
unhandledrejection: (event: PromiseRejectionEvent) => void
}
declare global {
interface Window {
__UMS_WINDOW_GUARDS_INSTALLED__?: boolean
__UMS_WINDOW_GUARDS_ORIGINALS__?: WindowGuardOriginals
__UMS_WINDOW_GUARDS_LISTENERS__?: WindowGuardListeners
}
}
function formatValue(value: unknown): string {
if (typeof value === 'string') {
return value
}
if (value instanceof Error) {
return value.stack || value.message
}
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
function reportWindowEvent(
logger: GuardLogger,
category: string,
payload: Record<string, unknown>,
) {
logger(`[window-guard] ${category}`, payload)
}
export function installWindowGuards(logger: GuardLogger = console.error) {
if (typeof window === 'undefined' || window.__UMS_WINDOW_GUARDS_INSTALLED__) {
return
}
window.__UMS_WINDOW_GUARDS_ORIGINALS__ = {
alert: window.alert.bind(window),
confirm: window.confirm.bind(window),
prompt: window.prompt.bind(window),
open: window.open.bind(window),
}
const onError = (event: ErrorEvent) => {
reportWindowEvent(logger, 'error', {
message: event.message,
source: event.filename,
line: event.lineno,
column: event.colno,
error: formatValue(event.error),
})
}
const onUnhandledRejection = (event: PromiseRejectionEvent) => {
reportWindowEvent(logger, 'unhandledrejection', {
reason: formatValue(event.reason),
})
}
window.__UMS_WINDOW_GUARDS_LISTENERS__ = {
error: onError,
unhandledrejection: onUnhandledRejection,
}
window.addEventListener('error', onError)
window.addEventListener('unhandledrejection', onUnhandledRejection)
window.alert = (message?: unknown) => {
reportWindowEvent(logger, 'native-alert-blocked', {
message: formatValue(message),
})
}
window.confirm = (message?: string) => {
reportWindowEvent(logger, 'native-confirm-blocked', {
message: formatValue(message),
fallback: false,
})
return false
}
window.prompt = (message?: string, defaultValue?: string) => {
reportWindowEvent(logger, 'native-prompt-blocked', {
message: formatValue(message),
defaultValue: formatValue(defaultValue ?? ''),
fallback: null,
})
return null
}
window.open = (url?: string | URL, target?: string, features?: string) => {
reportWindowEvent(logger, 'popup-blocked', {
url: typeof url === 'string' ? url : url?.toString() ?? '',
target: target ?? '',
features: features ?? '',
fallback: null,
})
return null
}
window.__UMS_WINDOW_GUARDS_INSTALLED__ = true
}
export function restoreWindowGuardsForTest() {
if (typeof window === 'undefined') {
return
}
const originals = window.__UMS_WINDOW_GUARDS_ORIGINALS__
const listeners = window.__UMS_WINDOW_GUARDS_LISTENERS__
if (!originals) {
window.__UMS_WINDOW_GUARDS_INSTALLED__ = false
return
}
if (listeners) {
window.removeEventListener('error', listeners.error)
window.removeEventListener('unhandledrejection', listeners.unhandledrejection)
delete window.__UMS_WINDOW_GUARDS_LISTENERS__
}
window.alert = originals.alert
window.confirm = originals.confirm
window.prompt = originals.prompt
window.open = originals.open
delete window.__UMS_WINDOW_GUARDS_ORIGINALS__
window.__UMS_WINDOW_GUARDS_INSTALLED__ = false
}

View File

@@ -0,0 +1,442 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Role, SessionUser, TokenBundle } from '@/types'
import { useAuth } from './auth-context'
import { AuthProvider } from './AuthProvider'
let storedAccessToken: string | null = null
let storedUser: SessionUser | null = null
let storedRoles: Role[] = []
const navigateMock = vi.fn()
const getMock = vi.fn()
const setRefreshTokenMock = vi.fn()
const clearRefreshTokenMock = vi.fn()
const hasSessionPresenceCookieMock = vi.fn()
const setAccessTokenMock = vi.fn((token: string, expiresIn: number) => {
void expiresIn
storedAccessToken = token
})
const getCurrentUserMock = vi.fn(() => storedUser)
const setCurrentUserMock = vi.fn((user: SessionUser) => {
storedUser = user
})
const getCurrentRolesMock = vi.fn(() => storedRoles)
const setCurrentRolesMock = vi.fn((roles: Role[]) => {
storedRoles = roles
})
const clearSessionMock = vi.fn(() => {
storedAccessToken = null
storedUser = null
storedRoles = []
})
const isAuthenticatedMock = vi.fn(() => storedAccessToken !== null && storedUser !== null)
const isAccessTokenExpiredMock = vi.fn()
const initCSRFTokenMock = vi.fn()
const clearCSRFTokenMock = vi.fn()
const logoutRequestMock = vi.fn()
const refreshSessionMock = vi.fn()
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom')
return {
...actual,
useNavigate: () => navigateMock,
}
})
vi.mock('@/lib/http', () => ({
get: (path: string) => getMock(path),
}))
vi.mock('@/lib/storage', () => ({
setRefreshToken: (token: string | null | undefined) => setRefreshTokenMock(token),
clearRefreshToken: () => clearRefreshTokenMock(),
hasSessionPresenceCookie: () => hasSessionPresenceCookieMock(),
}))
vi.mock('@/lib/http/auth-session', () => ({
setAccessToken: (token: string, expiresIn: number) => setAccessTokenMock(token, expiresIn),
getCurrentUser: () => getCurrentUserMock(),
setCurrentUser: (user: SessionUser) => setCurrentUserMock(user),
getCurrentRoles: () => getCurrentRolesMock(),
setCurrentRoles: (roles: Role[]) => setCurrentRolesMock(roles),
clearSession: () => clearSessionMock(),
isAuthenticated: () => isAuthenticatedMock(),
isAccessTokenExpired: () => isAccessTokenExpiredMock(),
}))
vi.mock('@/lib/http/csrf', () => ({
initCSRFToken: () => initCSRFTokenMock(),
clearCSRFToken: () => clearCSRFTokenMock(),
}))
vi.mock('@/services/auth', () => ({
logout: () => logoutRequestMock(),
refreshSession: () => refreshSessionMock(),
}))
const adminUser: SessionUser = {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
nickname: 'Admin',
avatar: '',
status: 1,
}
const adminRoles: Role[] = [
{
id: 1,
name: 'Administrator',
code: 'admin',
description: 'System administrator',
is_system: true,
is_default: false,
status: 1,
},
]
const refreshedSession: TokenBundle = {
access_token: 'access-token',
refresh_token: 'refresh-token',
expires_in: 7200,
user: adminUser,
}
const loginSession: TokenBundle = {
access_token: 'login-access-token',
refresh_token: 'login-refresh-token',
expires_in: 3600,
user: adminUser,
}
const operatorUser: SessionUser = {
id: 2,
username: 'operator',
email: 'operator@example.com',
phone: '13900139000',
nickname: 'Operator',
avatar: '',
status: 1,
}
const operatorRoles: Role[] = [
{
id: 2,
name: 'Operator',
code: 'operator',
description: 'Operations user',
is_system: false,
is_default: false,
status: 1,
},
]
function Probe() {
const auth = useAuth()
return (
<div>
<span data-testid="loading">{String(auth.isLoading)}</span>
<span data-testid="authenticated">{String(auth.isAuthenticated)}</span>
<span data-testid="username">{auth.user?.username ?? ''}</span>
<span data-testid="roles">{auth.roles.map((role) => role.code).join(',')}</span>
<button onClick={() => void auth.onLoginSuccess(loginSession)} type="button">
login-success
</button>
<button onClick={() => void auth.refreshUser()} type="button">
refresh-user
</button>
<button onClick={() => void auth.logout()} type="button">
logout
</button>
</div>
)
}
function renderAuthProvider() {
return render(
<AuthProvider>
<Probe />
</AuthProvider>,
)
}
async function waitForProviderIdle() {
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('false')
})
}
describe('AuthProvider', () => {
beforeEach(() => {
window.history.pushState({}, '', '/')
storedAccessToken = null
storedUser = null
storedRoles = []
navigateMock.mockReset()
getMock.mockReset()
setRefreshTokenMock.mockReset()
clearRefreshTokenMock.mockReset()
hasSessionPresenceCookieMock.mockReset()
setAccessTokenMock.mockReset()
getCurrentUserMock.mockReset()
setCurrentUserMock.mockReset()
getCurrentRolesMock.mockReset()
setCurrentRolesMock.mockReset()
clearSessionMock.mockReset()
isAuthenticatedMock.mockReset()
isAccessTokenExpiredMock.mockReset()
initCSRFTokenMock.mockReset()
clearCSRFTokenMock.mockReset()
logoutRequestMock.mockReset()
isAccessTokenExpiredMock.mockReturnValue(true)
isAuthenticatedMock.mockImplementation(() => storedAccessToken !== null && storedUser !== null)
getCurrentUserMock.mockImplementation(() => storedUser)
setCurrentUserMock.mockImplementation((user: SessionUser) => {
storedUser = user
})
getCurrentRolesMock.mockImplementation(() => storedRoles)
setCurrentRolesMock.mockImplementation((roles: Role[]) => {
storedRoles = roles
})
setAccessTokenMock.mockImplementation((token: string, expiresIn: number) => {
void expiresIn
storedAccessToken = token
})
clearSessionMock.mockImplementation(() => {
storedAccessToken = null
storedUser = null
storedRoles = []
})
hasSessionPresenceCookieMock.mockReturnValue(false)
initCSRFTokenMock.mockResolvedValue(undefined)
clearCSRFTokenMock.mockReturnValue(undefined)
logoutRequestMock.mockResolvedValue(undefined)
refreshSessionMock.mockReset()
})
it('reuses an in-memory authenticated session when the access token is still valid', async () => {
storedAccessToken = 'cached-access-token'
storedUser = adminUser
storedRoles = adminRoles
isAccessTokenExpiredMock.mockReturnValue(false)
renderAuthProvider()
await waitForProviderIdle()
expect(refreshSessionMock).not.toHaveBeenCalled()
expect(navigateMock).not.toHaveBeenCalled()
expect(clearRefreshTokenMock).not.toHaveBeenCalled()
expect(clearSessionMock).not.toHaveBeenCalled()
expect(initCSRFTokenMock).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('authenticated')).toHaveTextContent('true')
expect(screen.getByTestId('username')).toHaveTextContent('admin')
expect(screen.getByTestId('roles')).toHaveTextContent('admin')
})
it('clears the local session when auth state has no current user and no backend session cookie exists', async () => {
storedAccessToken = 'dangling-access-token'
isAuthenticatedMock.mockReturnValue(true)
isAccessTokenExpiredMock.mockReturnValue(false)
getCurrentUserMock.mockReturnValue(null)
renderAuthProvider()
await waitForProviderIdle()
expect(refreshSessionMock).not.toHaveBeenCalled()
expect(clearRefreshTokenMock).toHaveBeenCalledTimes(1)
expect(clearSessionMock).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('authenticated')).toHaveTextContent('false')
expect(screen.getByTestId('username').textContent).toBe('')
})
it('restores the session by refreshing through the backend cookie flow', async () => {
hasSessionPresenceCookieMock.mockReturnValue(true)
refreshSessionMock.mockResolvedValue(refreshedSession)
getMock.mockResolvedValue(adminRoles)
renderAuthProvider()
await waitFor(() => {
expect(refreshSessionMock).toHaveBeenCalledTimes(1)
})
await waitForProviderIdle()
expect(setAccessTokenMock).toHaveBeenCalledWith('access-token', 7200)
expect(setRefreshTokenMock).toHaveBeenCalledWith('refresh-token')
expect(setCurrentUserMock).toHaveBeenCalledWith(adminUser)
expect(setCurrentRolesMock).toHaveBeenCalledWith(adminRoles)
expect(getMock).toHaveBeenCalledWith('/users/1/roles')
expect(initCSRFTokenMock).toHaveBeenCalledTimes(1)
expect(navigateMock).not.toHaveBeenCalled()
expect(screen.getByTestId('username')).toHaveTextContent('admin')
expect(screen.getByTestId('roles')).toHaveTextContent('admin')
})
it('restores the session with empty roles when the role lookup fails after refresh', async () => {
hasSessionPresenceCookieMock.mockReturnValue(true)
refreshSessionMock.mockResolvedValue(refreshedSession)
getMock.mockRejectedValue(new Error('roles lookup failed'))
renderAuthProvider()
await waitForProviderIdle()
expect(setAccessTokenMock).toHaveBeenCalledWith('access-token', 7200)
expect(setRefreshTokenMock).toHaveBeenCalledWith('refresh-token')
expect(setCurrentUserMock).toHaveBeenCalledWith(adminUser)
expect(setCurrentRolesMock).toHaveBeenCalledWith([])
expect(initCSRFTokenMock).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('authenticated')).toHaveTextContent('true')
expect(screen.getByTestId('username')).toHaveTextContent('admin')
expect(screen.getByTestId('roles').textContent).toBe('')
})
it('clears local state when refresh fails against the backend cookie flow', async () => {
hasSessionPresenceCookieMock.mockReturnValue(true)
refreshSessionMock.mockRejectedValue(new Error('missing refresh cookie'))
renderAuthProvider()
await waitFor(() => {
expect(refreshSessionMock).toHaveBeenCalledTimes(1)
})
await waitForProviderIdle()
expect(clearRefreshTokenMock).toHaveBeenCalledTimes(1)
expect(clearSessionMock).toHaveBeenCalledTimes(1)
expect(navigateMock).not.toHaveBeenCalled()
expect(screen.getByTestId('authenticated')).toHaveTextContent('false')
})
it('persists tokens, user, roles, and csrf state after login success', async () => {
renderAuthProvider()
await waitForProviderIdle()
vi.clearAllMocks()
getMock.mockResolvedValue(adminRoles)
fireEvent.click(screen.getByRole('button', { name: 'login-success' }))
await waitFor(() => {
expect(setAccessTokenMock).toHaveBeenCalledWith('login-access-token', 3600)
})
expect(setRefreshTokenMock).toHaveBeenCalledWith('login-refresh-token')
expect(setCurrentUserMock).toHaveBeenCalledWith(adminUser)
expect(setCurrentRolesMock).toHaveBeenCalledWith(adminRoles)
expect(getMock).toHaveBeenCalledWith('/users/1/roles')
expect(initCSRFTokenMock).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('authenticated')).toHaveTextContent('true')
expect(screen.getByTestId('username')).toHaveTextContent('admin')
expect(screen.getByTestId('roles')).toHaveTextContent('admin')
})
it('refreshes the current user and roles from the backend', async () => {
renderAuthProvider()
await waitForProviderIdle()
vi.clearAllMocks()
getMock.mockImplementation((path: string) => {
if (path === '/auth/userinfo') {
return Promise.resolve(operatorUser)
}
if (path === '/users/2/roles') {
return Promise.resolve(operatorRoles)
}
return Promise.reject(new Error(`Unexpected path: ${path}`))
})
fireEvent.click(screen.getByRole('button', { name: 'refresh-user' }))
await waitFor(() => {
expect(setCurrentUserMock).toHaveBeenCalledWith(operatorUser)
})
expect(setCurrentRolesMock).toHaveBeenCalledWith(operatorRoles)
expect(screen.getByTestId('username')).toHaveTextContent('operator')
expect(screen.getByTestId('roles')).toHaveTextContent('operator')
})
it('logs refreshUser failures without corrupting the current auth state', async () => {
storedAccessToken = 'cached-access-token'
storedUser = adminUser
storedRoles = adminRoles
isAccessTokenExpiredMock.mockReturnValue(false)
renderAuthProvider()
await waitForProviderIdle()
vi.clearAllMocks()
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
getMock.mockRejectedValue(new Error('userinfo failed'))
fireEvent.click(screen.getByRole('button', { name: 'refresh-user' }))
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to refresh user info:',
expect.any(Error),
)
})
expect(screen.getByTestId('username')).toHaveTextContent('admin')
expect(screen.getByTestId('roles')).toHaveTextContent('admin')
consoleErrorSpy.mockRestore()
})
it('clears the local session and navigates to login when logout succeeds', async () => {
storedAccessToken = 'cached-access-token'
storedUser = adminUser
storedRoles = adminRoles
isAccessTokenExpiredMock.mockReturnValue(false)
renderAuthProvider()
await waitForProviderIdle()
vi.clearAllMocks()
fireEvent.click(screen.getByRole('button', { name: 'logout' }))
await waitFor(() => {
expect(logoutRequestMock).toHaveBeenCalledTimes(1)
})
expect(clearRefreshTokenMock).toHaveBeenCalledTimes(1)
expect(clearSessionMock).toHaveBeenCalledTimes(1)
expect(clearCSRFTokenMock).toHaveBeenCalledTimes(1)
expect(navigateMock).toHaveBeenCalledWith('/login')
expect(screen.getByTestId('authenticated')).toHaveTextContent('false')
expect(screen.getByTestId('username').textContent).toBe('')
})
it('clears the local session and navigates to login when logout fails', async () => {
storedAccessToken = 'cached-access-token'
storedUser = adminUser
storedRoles = adminRoles
isAccessTokenExpiredMock.mockReturnValue(false)
logoutRequestMock.mockRejectedValue(new Error('logout failed'))
renderAuthProvider()
await waitForProviderIdle()
vi.clearAllMocks()
logoutRequestMock.mockRejectedValue(new Error('logout failed'))
fireEvent.click(screen.getByRole('button', { name: 'logout' }))
await waitFor(() => {
expect(logoutRequestMock).toHaveBeenCalledTimes(1)
})
expect(clearRefreshTokenMock).toHaveBeenCalledTimes(1)
expect(clearSessionMock).toHaveBeenCalledTimes(1)
expect(clearCSRFTokenMock).toHaveBeenCalledTimes(1)
expect(navigateMock).toHaveBeenCalledWith('/login')
expect(screen.getByTestId('authenticated')).toHaveTextContent('false')
expect(screen.getByTestId('username').textContent).toBe('')
})
})

View File

@@ -0,0 +1,201 @@
/**
* AuthProvider - 全局会话上下文
*
* 提供:
* - 会话状态user, roles, isAdmin
* - 登录/登出方法
* - 会话恢复(启动时自动刷新)
*/
import {
useEffect,
useState,
useCallback,
type ReactNode,
} from 'react'
import { useNavigate } from 'react-router-dom'
import type { SessionUser, Role, TokenBundle } from '@/types'
import { get } from '@/lib/http'
import {
setRefreshToken,
clearRefreshToken,
hasSessionPresenceCookie,
} from '@/lib/storage'
import {
setAccessToken,
getCurrentUser,
setCurrentUser,
getCurrentRoles,
setCurrentRoles,
clearSession,
isAuthenticated,
isAccessTokenExpired,
} from '@/lib/http/auth-session'
import { initCSRFToken, clearCSRFToken } from '@/lib/http/csrf'
import { logout as logoutRequest, refreshSession } from '@/services/auth'
import { AuthContext, type AuthContextValue } from './auth-context'
// ==================== Provider ====================
interface AuthProviderProps {
children: ReactNode
}
export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<SessionUser | null>(getCurrentUser())
const [roles, setRoles] = useState<Role[]>(getCurrentRoles())
const [isLoading, setIsLoading] = useState(true)
const navigate = useNavigate()
const effectiveUser = user ?? getCurrentUser()
const effectiveRoles = roles.length > 0 ? roles : getCurrentRoles()
// 判断是否为管理员
const isAdmin = effectiveRoles.some((role) => role.code === 'admin')
/**
* 获取用户角色
*/
const fetchUserRoles = useCallback(async (userId: number): Promise<Role[]> => {
try {
const result = await get<Role[]>(`/users/${userId}/roles`)
return result
} catch {
return []
}
}, [])
/**
* 登录成功回调
*/
const onLoginSuccess = useCallback(async (tokenBundle: TokenBundle) => {
// 保存 tokens
setAccessToken(tokenBundle.access_token, tokenBundle.expires_in)
setRefreshToken(tokenBundle.refresh_token)
// 保存用户信息
setCurrentUser(tokenBundle.user)
setUser(tokenBundle.user)
// 获取角色
const userRoles = await fetchUserRoles(tokenBundle.user.id)
setCurrentRoles(userRoles)
setRoles(userRoles)
// 初始化 CSRF Token
await initCSRFToken()
}, [fetchUserRoles])
/**
* 刷新用户信息
*/
const refreshUser = useCallback(async () => {
try {
const userInfo = await get<SessionUser>('/auth/userinfo')
setCurrentUser(userInfo)
setUser(userInfo)
const userRoles = await fetchUserRoles(userInfo.id)
setCurrentRoles(userRoles)
setRoles(userRoles)
} catch {
// 刷新失败,清除会话
setUser(null)
setRoles([])
}
}, [fetchUserRoles])
/**
* 登出
*/
const logout = useCallback(async () => {
try {
await logoutRequest()
} catch {
// 忽略登出请求错误
} finally {
// 无论请求成功与否,都清除本地会话和 CSRF Token
clearRefreshToken()
clearSession()
clearCSRFToken()
setUser(null)
setRoles([])
navigate('/login')
}
}, [navigate])
/**
* 会话恢复(应用启动时,只运行一次)
*/
useEffect(() => {
const restoreSession = async () => {
// 如果已有 access_token 且未过期,直接使用
if (isAuthenticated() && !isAccessTokenExpired()) {
const currentUser = getCurrentUser()
const currentRoles = getCurrentRoles()
if (currentUser) {
setUser(currentUser)
setRoles(currentRoles)
await initCSRFToken()
setIsLoading(false)
return
}
}
if (!hasSessionPresenceCookie()) {
clearRefreshToken()
clearSession()
setUser(null)
setRoles([])
setIsLoading(false)
return
}
try {
const result = await refreshSession()
// 保存 tokens
setAccessToken(result.access_token, result.expires_in)
setRefreshToken(result.refresh_token)
// 保存用户信息
setCurrentUser(result.user)
setUser(result.user)
// 获取角色
const userRoles = await fetchUserRoles(result.user.id)
setCurrentRoles(userRoles)
setRoles(userRoles)
await initCSRFToken()
} catch {
// 刷新失败,清除会话
clearRefreshToken()
clearSession()
setUser(null)
setRoles([])
}
setIsLoading(false)
}
restoreSession()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []) // 只在挂载时运行一次,不依赖 location.pathname
const value: AuthContextValue = {
user: effectiveUser,
roles: effectiveRoles,
isAdmin,
isAuthenticated: effectiveUser !== null && isAuthenticated(),
isLoading,
onLoginSuccess,
logout,
refreshUser,
}
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
)
}

View File

@@ -0,0 +1,65 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import zhCN from 'antd/locale/zh_CN'
import { describe, expect, it, vi } from 'vitest'
import { ThemeProvider } from './ThemeProvider'
const configProviderMock = vi.fn(
({ children }: { children: ReactNode }) => <div data-testid="config-provider">{children}</div>,
)
vi.mock('antd', async () => {
const actual = await vi.importActual<typeof import('antd')>('antd')
return {
...actual,
ConfigProvider: (props: { children: ReactNode; theme: unknown; locale: unknown }) => configProviderMock(props),
}
})
describe('ThemeProvider', () => {
it('passes the theme tokens and locale to ConfigProvider', () => {
render(
<ThemeProvider>
<div>theme child</div>
</ThemeProvider>,
)
expect(screen.getByTestId('config-provider')).toBeInTheDocument()
expect(screen.getByText('theme child')).toBeInTheDocument()
expect(configProviderMock).toHaveBeenCalledWith(
expect.objectContaining({
locale: zhCN,
theme: expect.objectContaining({
token: expect.objectContaining({
colorPrimary: '#0e5a6a',
colorSuccess: '#217a5b',
colorWarning: '#b7791f',
colorError: '#b33a3a',
colorInfo: '#2d6a9f',
fontFamily: '"IBM Plex Sans", "PingFang SC", "Microsoft YaHei", sans-serif',
borderRadius: 10,
}),
components: expect.objectContaining({
Table: expect.objectContaining({
headerBg: '#f8f5ef',
borderColor: '#d6d0c3',
}),
Card: expect.objectContaining({
borderRadiusLG: 16,
}),
Button: expect.objectContaining({
controlHeightLG: 44,
}),
Input: expect.objectContaining({
controlHeight: 36,
}),
Select: expect.objectContaining({
controlHeight: 36,
}),
}),
}),
}),
)
})
})

View File

@@ -0,0 +1,89 @@
/**
* 主题配置 Provider
* 使用 Ant Design 5 的 ConfigProvider 覆盖主题 Token
*/
import { ConfigProvider, type ThemeConfig } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import type { ReactNode } from 'react'
/**
* Ant Design 主题配置
* 基于 Mineral Console 视觉方向
*/
const themeConfig: ThemeConfig = {
token: {
// 主色
colorPrimary: '#0e5a6a',
colorPrimaryHover: '#0a4b59',
colorPrimaryActive: '#083d4a',
// 成功色
colorSuccess: '#217a5b',
// 警告色
colorWarning: '#b7791f',
// 错误色
colorError: '#b33a3a',
// 信息色
colorInfo: '#2d6a9f',
// 字体
fontFamily: '"IBM Plex Sans", "PingFang SC", "Microsoft YaHei", sans-serif',
fontSize: 14,
// 圆角
borderRadius: 10,
borderRadiusLG: 16,
borderRadiusSM: 6,
// 链接
colorLink: '#0e5a6a',
colorLinkHover: '#0a4b59',
colorLinkActive: '#083d4a',
},
components: {
// 表格组件定制
Table: {
headerBg: '#f8f5ef',
borderColor: '#d6d0c3',
rowHoverBg: 'rgba(14, 90, 106, 0.04)',
},
// 卡片组件定制
Card: {
borderRadiusLG: 16,
boxShadowTertiary: '0 10px 30px rgba(23, 33, 43, 0.06)',
},
// 按钮组件定制
Button: {
borderRadius: 10,
controlHeight: 36,
controlHeightLG: 44,
controlHeightSM: 28,
},
// 输入框组件定制
Input: {
borderRadius: 10,
controlHeight: 36,
},
// 选择器组件定制
Select: {
borderRadius: 10,
controlHeight: 36,
},
},
}
interface ThemeProviderProps {
children: ReactNode
}
export function ThemeProvider({ children }: ThemeProviderProps) {
return (
<ConfigProvider theme={themeConfig} locale={zhCN}>
{children}
</ConfigProvider>
)
}

View File

@@ -0,0 +1,24 @@
import { createContext, useContext } from 'react'
import type { Role, SessionUser, TokenBundle } from '@/types'
export interface AuthContextValue {
user: SessionUser | null
roles: Role[]
isAdmin: boolean
isAuthenticated: boolean
isLoading: boolean
onLoginSuccess: (tokenBundle: TokenBundle) => Promise<void>
logout: () => Promise<void>
refreshUser: () => Promise<void>
}
export const AuthContext = createContext<AuthContextValue | null>(null)
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}

View File

@@ -0,0 +1,210 @@
import { createElement, type ComponentType, type ReactElement, type ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Navigate, type RouteObject } from 'react-router-dom'
type PageDefinition = {
exportName: string
routePath: string
requireAdmin?: boolean
}
type RouterFixture = {
lazyPage: typeof import('./router').lazyPage
router: { routes: RouteObject[] }
}
type LazyType = {
_init: (payload: unknown) => unknown
_payload: unknown
}
const publicPages: PageDefinition[] = [
{ routePath: '/login', exportName: 'LoginPage' },
{ routePath: '/register', exportName: 'RegisterPage' },
{ routePath: '/bootstrap-admin', exportName: 'BootstrapAdminPage' },
{ routePath: '/activate-account', exportName: 'ActivateAccountPage' },
{ routePath: '/login/oauth/callback', exportName: 'OAuthCallbackPage' },
{ routePath: '/forgot-password', exportName: 'ForgotPasswordPage' },
{ routePath: '/reset-password', exportName: 'ResetPasswordPage' },
]
const protectedPages: PageDefinition[] = [
{ routePath: 'dashboard', exportName: 'DashboardPage', requireAdmin: true },
{ routePath: 'users', exportName: 'UsersPage', requireAdmin: true },
{ routePath: 'roles', exportName: 'RolesPage', requireAdmin: true },
{ routePath: 'permissions', exportName: 'PermissionsPage', requireAdmin: true },
{ routePath: 'logs/login', exportName: 'LoginLogsPage', requireAdmin: true },
{ routePath: 'logs/operation', exportName: 'OperationLogsPage', requireAdmin: true },
{ routePath: 'webhooks', exportName: 'WebhooksPage' },
{ routePath: 'import-export', exportName: 'ImportExportPage', requireAdmin: true },
{ routePath: 'profile', exportName: 'ProfilePage' },
{ routePath: 'profile/security', exportName: 'ProfileSecurityPage' },
]
const resetModulePaths = ['./router', 'react-router-dom']
afterEach(() => {
vi.clearAllMocks()
for (const modulePath of resetModulePaths) {
vi.doUnmock(modulePath)
}
vi.resetModules()
})
function asRouteObject(route: unknown): RouteObject {
return route as RouteObject
}
function asElement(node: ReactNode | undefined): ReactElement<{ children?: ReactNode }> | null {
return node && typeof node === 'object' && 'type' in node ? (node as ReactElement<{ children?: ReactNode }>) : null
}
function asLazyType(type: unknown): LazyType {
return type as LazyType
}
function getComponentName(type: unknown): string | undefined {
return typeof type === 'function' ? type.name : undefined
}
function getRouteByPath(routes: RouteObject[], path: string): RouteObject {
const route = routes.find((candidate) => candidate.path === path)
expect(route).toBeDefined()
return route as RouteObject
}
function expectLazyElement(node: ReactNode | undefined): LazyType {
const element = asElement(node)
expect(element).not.toBeNull()
const lazyType = asLazyType(element?.type)
expect(typeof lazyType).toBe('object')
expect(typeof lazyType._init).toBe('function')
return lazyType
}
async function resolveLazyType(lazyType: LazyType): Promise<ComponentType<object>> {
for (;;) {
try {
return lazyType._init(lazyType._payload) as ComponentType<object>
} catch (thrown) {
if (thrown && typeof (thrown as PromiseLike<unknown>).then === 'function') {
await thrown
continue
}
throw thrown
}
}
}
async function expectResolvedLazyName(node: ReactNode | undefined, expectedName: string) {
const resolved = await resolveLazyType(expectLazyElement(node))
expect(resolved.name).toBe(expectedName)
}
async function loadRouterFixture(): Promise<RouterFixture> {
for (const modulePath of resetModulePaths) {
vi.doUnmock(modulePath)
}
vi.resetModules()
const module = await import('./router')
return {
lazyPage: module.lazyPage,
router: module.router as { routes: RouteObject[] },
}
}
describe('router', () => {
it('maps each route to the expected shell and lazy page component shape', async () => {
const { router } = await loadRouterFixture()
const rootRoute = asRouteObject(router.routes[0])
const rootChildren = (rootRoute.children ?? []).map(asRouteObject)
expect(getComponentName(asElement(rootRoute.element)?.type)).toBe('RootLayout')
expect(
rootChildren
.map((route) => route.path)
.filter((path): path is string => Boolean(path)),
).toEqual([
'/login',
'/register',
'/bootstrap-admin',
'/activate-account',
'/login/oauth/callback',
'/forgot-password',
'/reset-password',
'/',
'*',
])
for (const page of publicPages) {
const route = getRouteByPath(rootChildren, page.routePath)
await expectResolvedLazyName(route.element, page.exportName)
}
const protectedRoute = getRouteByPath(rootChildren, '/')
const protectedElement = asElement(protectedRoute.element)
const protectedChildren = (protectedRoute.children ?? []).map(asRouteObject)
expect(getComponentName(protectedElement?.type)).toBe('RequireAuth')
expect(getComponentName(asElement(protectedElement?.props.children)?.type)).toBe('AdminLayout')
expect(
protectedChildren
.map((route) => route.path)
.filter((path): path is string => Boolean(path)),
).toEqual([
'dashboard',
'users',
'roles',
'permissions',
'logs/login',
'logs/operation',
'webhooks',
'import-export',
'profile',
'profile/security',
])
const indexRoute = protectedChildren.find((route) => route.index)
const indexElement = asElement(indexRoute?.element)
expect(indexRoute).toBeDefined()
expect(indexElement?.type).toBe(Navigate)
expect(indexElement?.props).toEqual(expect.objectContaining({ to: '/dashboard', replace: true }))
for (const page of protectedPages.filter((candidate) => candidate.requireAdmin)) {
const route = getRouteByPath(protectedChildren, page.routePath)
const element = asElement(route.element)
expect(getComponentName(element?.type)).toBe('RequireAdmin')
await expectResolvedLazyName(element?.props.children, page.exportName)
}
for (const page of protectedPages.filter((candidate) => !candidate.requireAdmin)) {
const route = getRouteByPath(protectedChildren, page.routePath)
expect(getComponentName(asElement(route.element)?.type)).not.toBe('RequireAdmin')
await expectResolvedLazyName(route.element, page.exportName)
}
const notFoundRoute = getRouteByPath(rootChildren, '*')
await expectResolvedLazyName(notFoundRoute.element, 'NotFoundPage')
})
it('resolves valid lazy exports and rejects invalid lazy exports clearly', async () => {
const { lazyPage } = await loadRouterFixture()
const LoginPage: ComponentType<object> = () => null
const validLazyPage = lazyPage(async () => ({ LoginPage }), 'LoginPage')
const invalidLazyPage = lazyPage(async () => ({ LoginPage: 'not-a-component' }), 'LoginPage')
await expect(resolveLazyType(expectLazyElement(createElement(validLazyPage)))).resolves.toBe(LoginPage)
await expect(resolveLazyType(expectLazyElement(createElement(invalidLazyPage)))).rejects.toThrow(
'lazy route export "LoginPage" is not a React component',
)
})
})

View File

@@ -0,0 +1,223 @@
/**
* 应用路由配置
*
* 路由结构:
* - /login - 登录页(公开)
* - /forgot-password - 忘记密码(公开)
* - /reset-password - 重置密码(公开)
* - /dashboard - 总览(需登录)
* - /users - 用户管理(需管理员)
* - /roles - 角色管理(需管理员)
* - /permissions - 权限管理(需管理员)
* - /logs/login - 登录日志(需管理员)
* - /logs/operation - 操作日志(需管理员)
* - /webhooks - Webhooks需登录
* - /import-export - 导入导出(需管理员)
* - /profile - 个人资料(需登录)
* - /profile/security - 安全设置(需登录)
*/
import { createElement, lazy, type ComponentType, type LazyExoticComponent } from 'react'
import { createBrowserRouter, Navigate } from 'react-router-dom'
import { AdminLayout } from '@/layouts'
import { RootLayout } from './RootLayout'
// 路由守卫
import { RequireAuth, RequireAdmin } from '@/components/guards'
export function lazyPage<T extends ComponentType<object>>(
loader: () => Promise<Record<string, unknown>>,
exportName: string,
): LazyExoticComponent<T> {
return lazy(async () => {
const module = await loader()
const component = module[exportName]
if (typeof component !== 'function') {
throw new Error(`lazy route export "${exportName}" is not a React component`)
}
return { default: component as T }
})
}
function renderLazy<T extends ComponentType<object>>(Component: LazyExoticComponent<T>) {
return createElement(Component)
}
const LoginPage = lazyPage(() => import('@/pages/auth/LoginPage'), 'LoginPage')
const RegisterPage = lazyPage(() => import('@/pages/auth/RegisterPage'), 'RegisterPage')
const BootstrapAdminPage = lazyPage(() => import('@/pages/auth/BootstrapAdminPage'), 'BootstrapAdminPage')
const ActivateAccountPage = lazyPage(() => import('@/pages/auth/ActivateAccountPage'), 'ActivateAccountPage')
const OAuthCallbackPage = lazyPage(() => import('@/pages/auth/OAuthCallbackPage'), 'OAuthCallbackPage')
const ForgotPasswordPage = lazyPage(() => import('@/pages/auth/ForgotPasswordPage'), 'ForgotPasswordPage')
const ResetPasswordPage = lazyPage(() => import('@/pages/auth/ResetPasswordPage'), 'ResetPasswordPage')
const DashboardPage = lazyPage(() => import('@/pages/admin/DashboardPage'), 'DashboardPage')
const UsersPage = lazyPage(() => import('@/pages/admin/UsersPage'), 'UsersPage')
const DevicesPage = lazyPage(() => import('@/pages/admin/DevicesPage'), 'DevicesPage')
const RolesPage = lazyPage(() => import('@/pages/admin/RolesPage'), 'RolesPage')
const PermissionsPage = lazyPage(() => import('@/pages/admin/PermissionsPage'), 'PermissionsPage')
const LoginLogsPage = lazyPage(() => import('@/pages/admin/LoginLogsPage'), 'LoginLogsPage')
const OperationLogsPage = lazyPage(() => import('@/pages/admin/OperationLogsPage'), 'OperationLogsPage')
const WebhooksPage = lazyPage(() => import('@/pages/admin/WebhooksPage'), 'WebhooksPage')
const ImportExportPage = lazyPage(() => import('@/pages/admin/ImportExportPage'), 'ImportExportPage')
const SettingsPage = lazyPage(() => import('@/pages/admin/SettingsPage'), 'SettingsPage')
const ProfilePage = lazyPage(() => import('@/pages/admin/ProfilePage'), 'ProfilePage')
const ProfileSecurityPage = lazyPage(() => import('@/pages/admin/ProfileSecurityPage'), 'ProfileSecurityPage')
const NotFoundPage = lazyPage(() => import('@/pages/NotFoundPage'), 'NotFoundPage')
export const router = createBrowserRouter(
[
// 根布局 - 提供 AuthProvider 上下文
{
element: <RootLayout />,
children: [
// 公开路由 - 认证页
{
path: '/login',
element: renderLazy(LoginPage),
},
{
path: '/register',
element: renderLazy(RegisterPage),
},
{
path: '/bootstrap-admin',
element: renderLazy(BootstrapAdminPage),
},
{
path: '/activate-account',
element: renderLazy(ActivateAccountPage),
},
{
path: '/login/oauth/callback',
element: renderLazy(OAuthCallbackPage),
},
{
path: '/forgot-password',
element: renderLazy(ForgotPasswordPage),
},
{
path: '/reset-password',
element: renderLazy(ResetPasswordPage),
},
// 受保护路由 - 管理后台
{
path: '/',
element: (
<RequireAuth>
<AdminLayout />
</RequireAuth>
),
children: [
// 默认跳转到 Dashboard
{
index: true,
element: <Navigate to="/dashboard" replace />,
},
// Dashboard - 需要登录
{
path: 'dashboard',
element: (
<RequireAdmin>
{renderLazy(DashboardPage)}
</RequireAdmin>
),
},
// 管理功能 - 需要管理员权限
{
path: 'users',
element: (
<RequireAdmin>
{renderLazy(UsersPage)}
</RequireAdmin>
),
},
{
path: 'devices',
element: (
<RequireAdmin>
{renderLazy(DevicesPage)}
</RequireAdmin>
),
},
{
path: 'roles',
element: (
<RequireAdmin>
{renderLazy(RolesPage)}
</RequireAdmin>
),
},
{
path: 'permissions',
element: (
<RequireAdmin>
{renderLazy(PermissionsPage)}
</RequireAdmin>
),
},
// 日志 - 需要管理员权限
{
path: 'logs/login',
element: (
<RequireAdmin>
{renderLazy(LoginLogsPage)}
</RequireAdmin>
),
},
{
path: 'logs/operation',
element: (
<RequireAdmin>
{renderLazy(OperationLogsPage)}
</RequireAdmin>
),
},
// 集成能力
{
path: 'webhooks',
element: renderLazy(WebhooksPage),
},
{
path: 'import-export',
element: (
<RequireAdmin>
{renderLazy(ImportExportPage)}
</RequireAdmin>
),
},
{
path: 'settings',
element: (
<RequireAdmin>
{renderLazy(SettingsPage)}
</RequireAdmin>
),
},
// 个人中心
{
path: 'profile',
element: renderLazy(ProfilePage),
},
{
path: 'profile/security',
element: renderLazy(ProfileSecurityPage),
},
],
},
// 404
{
path: '*',
element: renderLazy(NotFoundPage),
},
],
},
],
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

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'

View File

@@ -0,0 +1,3 @@
`src/features` 保留为业务复用层目录。
当前已补齐目录骨架,后续需要将页面内可复用的业务交互逐步下沉到对应子目录。

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,111 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}

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'

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import {
buildOAuthCallbackReturnTo,
parseOAuthCallbackHash,
sanitizeAuthRedirect,
} from './oauth'
describe('oauth auth helpers', () => {
it('sanitizes redirect paths to internal routes only', () => {
expect(sanitizeAuthRedirect('/users')).toBe('/users')
expect(sanitizeAuthRedirect('https://evil.example.com')).toBe('/dashboard')
expect(sanitizeAuthRedirect('//evil.example.com')).toBe('/dashboard')
expect(sanitizeAuthRedirect('users')).toBe('/dashboard')
})
it('builds oauth callback return url on current origin', () => {
expect(buildOAuthCallbackReturnTo('/users')).toBe('http://localhost:3000/login/oauth/callback?redirect=%2Fusers')
})
it('parses oauth callback hash payload', () => {
expect(parseOAuthCallbackHash('#status=success&code=abc&provider=github')).toEqual({
status: 'success',
code: 'abc',
provider: 'github',
message: '',
})
})
})

View File

@@ -0,0 +1,27 @@
export function sanitizeAuthRedirect(target: string | null | undefined, fallback: string = '/dashboard'): string {
const value = (target || '').trim()
if (!value.startsWith('/') || value.startsWith('//')) {
return fallback
}
return value
}
export function buildOAuthCallbackReturnTo(redirectPath: string): string {
const callbackUrl = new URL('/login/oauth/callback', window.location.origin)
if (redirectPath && redirectPath !== '/dashboard') {
callbackUrl.searchParams.set('redirect', redirectPath)
}
return callbackUrl.toString()
}
export function parseOAuthCallbackHash(hash: string): Record<string, string> {
const normalized = hash.startsWith('#') ? hash.slice(1) : hash
const values = new URLSearchParams(normalized)
return {
status: values.get('status') || '',
code: values.get('code') || '',
provider: values.get('provider') || '',
message: values.get('message') || '',
}
}

View File

@@ -0,0 +1,11 @@
/**
* 应用配置
* 从环境变量中读取配置项
*/
export const config = {
/**
* API 基础地址
*/
apiBaseUrl: import.meta.env.VITE_API_BASE_URL || '/api/v1',
} as const

View File

@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest'
import { AppError, ErrorType, isAppError } from './AppError'
import { getErrorMessage, isFormValidationError } from './index'
describe('AppError', () => {
it('uses the default status and type when options are omitted', () => {
const error = new AppError(1001, 'business failed')
expect(error).toBeInstanceOf(AppError)
expect(error).toBeInstanceOf(Error)
expect(error.name).toBe('AppError')
expect(error.code).toBe(1001)
expect(error.status).toBe(500)
expect(error.type).toBe(ErrorType.BUSINESS)
expect(error.cause).toBeUndefined()
})
it('keeps explicit options including cause and exposes type guards', () => {
const cause = new Error('root cause')
const authByStatus = new AppError(2001, 'status-auth', {
status: 401,
type: ErrorType.BUSINESS,
cause,
})
const forbiddenByStatus = new AppError(2002, 'status-forbidden', {
status: 403,
type: ErrorType.BUSINESS,
})
const networkError = AppError.network('network failed', cause)
expect(authByStatus.cause).toBe(cause)
expect(authByStatus.isAuthError()).toBe(true)
expect(forbiddenByStatus.isForbidden()).toBe(true)
expect(networkError.isNetworkError()).toBe(true)
})
it('maps backend responses to the expected error type for each status family', () => {
const unauthorized = AppError.fromResponse({ code: 40101, message: 'unauthorized' }, 401)
const forbidden = AppError.fromResponse({ code: 40301, message: 'forbidden' }, 403)
const notFound = AppError.fromResponse({ code: 40401, message: 'missing' }, 404)
const network = AppError.fromResponse({ code: 50001, message: 'server error' }, 500)
const business = AppError.fromResponse({ code: 40001, message: 'business error' }, 400)
expect(unauthorized.type).toBe(ErrorType.AUTH)
expect(forbidden.type).toBe(ErrorType.FORBIDDEN)
expect(notFound.type).toBe(ErrorType.NOT_FOUND)
expect(network.type).toBe(ErrorType.NETWORK)
expect(business.type).toBe(ErrorType.BUSINESS)
})
it('creates auth, forbidden, and validation errors with the expected defaults', () => {
const auth = AppError.auth()
const forbidden = AppError.forbidden()
const validation = AppError.validation('validation failed')
expect(auth.code).toBe(401)
expect(auth.status).toBe(401)
expect(auth.type).toBe(ErrorType.AUTH)
expect(auth.message.length).toBeGreaterThan(0)
expect(forbidden.code).toBe(403)
expect(forbidden.status).toBe(403)
expect(forbidden.type).toBe(ErrorType.FORBIDDEN)
expect(forbidden.message.length).toBeGreaterThan(0)
expect(validation.code).toBe(400)
expect(validation.status).toBe(400)
expect(validation.type).toBe(ErrorType.VALIDATION)
expect(validation.message).toBe('validation failed')
})
it('returns user-facing messages for each supported error type', () => {
const networkMessage = AppError.network('network failed').getUserMessage()
const authMessage = AppError.auth('custom auth').getUserMessage()
const forbiddenMessage = AppError.forbidden('custom forbidden').getUserMessage()
const notFoundMessage = new AppError(40401, 'missing', {
status: 404,
type: ErrorType.NOT_FOUND,
}).getUserMessage()
const validationMessage = AppError.validation('validation failed').getUserMessage()
const customUnknownMessage = new AppError(9001, 'custom unknown', {
type: ErrorType.UNKNOWN,
}).getUserMessage()
const fallbackUnknownMessage = new AppError(9002, '', {
type: ErrorType.UNKNOWN,
}).getUserMessage()
expect(networkMessage.length).toBeGreaterThan(0)
expect(networkMessage).not.toBe('network failed')
expect(authMessage.length).toBeGreaterThan(0)
expect(authMessage).not.toBe('custom auth')
expect(forbiddenMessage.length).toBeGreaterThan(0)
expect(forbiddenMessage).not.toBe('custom forbidden')
expect(notFoundMessage.length).toBeGreaterThan(0)
expect(notFoundMessage).not.toBe('missing')
expect(validationMessage).toBe('validation failed')
expect(customUnknownMessage).toBe('custom unknown')
expect(fallbackUnknownMessage.length).toBeGreaterThan(0)
})
it('identifies AppError instances correctly', () => {
expect(isAppError(new AppError(1, 'boom'))).toBe(true)
expect(isAppError(new Error('boom'))).toBe(false)
expect(isAppError('boom')).toBe(false)
})
})
describe('error helpers', () => {
it('uses the AppError user message when available', () => {
const error = AppError.validation('invalid form')
expect(getErrorMessage(error, 'fallback')).toBe('invalid form')
})
it('falls back to generic Error messages and finally to the provided fallback', () => {
expect(getErrorMessage(new Error('plain error'), 'fallback')).toBe('plain error')
expect(getErrorMessage({ foo: 'bar' }, 'fallback')).toBe('fallback')
})
it('detects form validation errors only for objects with an errorFields array', () => {
expect(isFormValidationError({ errorFields: [] })).toBe(true)
expect(isFormValidationError({ errorFields: 'nope' })).toBe(false)
expect(isFormValidationError(null)).toBe(false)
})
})

View File

@@ -0,0 +1,172 @@
/**
* AppError - 应用统一错误模型
*
* 用于统一处理后端业务错误和前端运行时错误
*/
/**
* 错误类型常量
*/
export const ErrorType = {
/** 业务错误 - 后端返回的业务逻辑错误 */
BUSINESS: 'BUSINESS',
/** 网络错误 - 请求失败、超时等 */
NETWORK: 'NETWORK',
/** 认证错误 - 401 未登录或 token 过期 */
AUTH: 'AUTH',
/** 权限错误 - 403 无权限访问 */
FORBIDDEN: 'FORBIDDEN',
/** 资源不存在 - 404 */
NOT_FOUND: 'NOT_FOUND',
/** 验证错误 - 表单校验失败 */
VALIDATION: 'VALIDATION',
/** 未知错误 */
UNKNOWN: 'UNKNOWN',
} as const
export type ErrorTypeValue = typeof ErrorType[keyof typeof ErrorType]
/**
* 应用错误类
*/
export class AppError extends Error {
/** 错误码 */
readonly code: number
/** HTTP 状态码 */
readonly status: number
/** 错误类型 */
readonly type: ErrorTypeValue
/** 原始错误 */
readonly cause?: Error
constructor(
code: number,
message: string,
options?: {
status?: number
type?: ErrorTypeValue
cause?: Error
}
) {
super(message)
this.name = 'AppError'
this.code = code
this.status = options?.status ?? 500
this.type = options?.type ?? ErrorType.BUSINESS
this.cause = options?.cause
// 确保 instanceof 正常工作
Object.setPrototypeOf(this, AppError.prototype)
}
/**
* 从后端响应创建错误
*/
static fromResponse(response: { code: number; message: string }, status: number): AppError {
let type: ErrorTypeValue = ErrorType.BUSINESS
if (status === 401) {
type = ErrorType.AUTH
} else if (status === 403) {
type = ErrorType.FORBIDDEN
} else if (status === 404) {
type = ErrorType.NOT_FOUND
} else if (status >= 500) {
type = ErrorType.NETWORK
}
return new AppError(response.code, response.message, { status, type })
}
/**
* 创建网络错误
*/
static network(message: string, cause?: Error): AppError {
return new AppError(0, message, {
status: 0,
type: ErrorType.NETWORK,
cause,
})
}
/**
* 创建认证错误
*/
static auth(message: string = '请先登录'): AppError {
return new AppError(401, message, {
status: 401,
type: ErrorType.AUTH,
})
}
/**
* 创建权限错误
*/
static forbidden(message: string = '无权限访问'): AppError {
return new AppError(403, message, {
status: 403,
type: ErrorType.FORBIDDEN,
})
}
/**
* 创建验证错误
*/
static validation(message: string): AppError {
return new AppError(400, message, {
status: 400,
type: ErrorType.VALIDATION,
})
}
/**
* 判断是否为认证错误
*/
isAuthError(): boolean {
return this.type === ErrorType.AUTH || this.status === 401
}
/**
* 判断是否为权限错误
*/
isForbidden(): boolean {
return this.type === ErrorType.FORBIDDEN || this.status === 403
}
/**
* 判断是否为网络错误
*/
isNetworkError(): boolean {
return this.type === ErrorType.NETWORK
}
/**
* 获取用户友好的错误消息
*/
getUserMessage(): string {
switch (this.type) {
case ErrorType.NETWORK:
return '网络连接失败,请检查网络后重试'
case ErrorType.AUTH:
return '登录已过期,请重新登录'
case ErrorType.FORBIDDEN:
return '您没有权限执行此操作'
case ErrorType.NOT_FOUND:
return '请求的资源不存在'
case ErrorType.VALIDATION:
return this.message
default:
return this.message || '操作失败,请稍后重试'
}
}
}
/**
* 判断是否为 AppError
*/
export function isAppError(error: unknown): error is AppError {
return error instanceof AppError
}

View File

@@ -0,0 +1,26 @@
import { AppError, ErrorType, isAppError } from './AppError'
export { AppError, ErrorType, isAppError }
export function getErrorMessage(error: unknown, fallback: string): string {
if (isAppError(error)) {
return error.getUserMessage()
}
if (error instanceof Error && error.message) {
return error.message
}
return fallback
}
export function isFormValidationError(
error: unknown,
): error is { errorFields: unknown[] } {
return (
typeof error === 'object' &&
error !== null &&
'errorFields' in error &&
Array.isArray((error as { errorFields?: unknown[] }).errorFields)
)
}

View File

@@ -0,0 +1,82 @@
import type { ReactNode } from 'react'
import { renderHook } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { describe, expect, it } from 'vitest'
import { useBreadcrumbs } from './useBreadcrumbs'
function createWrapper(pathname: string) {
return function Wrapper({ children }: { children: ReactNode }) {
return <MemoryRouter initialEntries={[pathname]}>{children}</MemoryRouter>
}
}
describe('useBreadcrumbs', () => {
it('returns an empty breadcrumb list at the root path', () => {
const { result } = renderHook(() => useBreadcrumbs(), {
wrapper: createWrapper('/'),
})
expect(result.current).toEqual([])
})
it('maps known single-segment routes to a terminal breadcrumb item', () => {
const { result } = renderHook(() => useBreadcrumbs(), {
wrapper: createWrapper('/dashboard'),
})
expect(result.current).toEqual([
{
title: '概览',
path: undefined,
},
])
})
it('builds nested breadcrumbs for supported child routes', () => {
const { logsResult } = {
logsResult: renderHook(() => useBreadcrumbs(), {
wrapper: createWrapper('/logs/login'),
}),
}
expect(logsResult.result.current).toEqual([
{
title: '审计日志',
path: '/logs',
},
{
title: '登录日志',
path: undefined,
},
])
const profileResult = renderHook(() => useBreadcrumbs(), {
wrapper: createWrapper('/profile/security'),
})
expect(profileResult.result.current).toEqual([
{
title: '个人资料',
path: '/profile',
},
{
title: '安全设置',
path: undefined,
},
])
})
it('skips unknown route segments while keeping known ancestors', () => {
const { result } = renderHook(() => useBreadcrumbs(), {
wrapper: createWrapper('/logs/unknown'),
})
expect(result.current).toEqual([
{
title: '审计日志',
path: '/logs',
},
])
})
})

View File

@@ -0,0 +1,48 @@
import { useMemo } from 'react'
import { useLocation } from 'react-router-dom'
import type { BreadcrumbProps } from 'antd'
const breadcrumbNameMap: Record<string, string> = {
'/dashboard': '概览',
'/users': '用户管理',
'/roles': '角色管理',
'/permissions': '权限管理',
'/logs': '审计日志',
'/logs/login': '登录日志',
'/logs/operation': '操作日志',
'/webhooks': 'Webhooks',
'/import-export': '导入导出',
'/profile': '个人资料',
'/profile/security': '安全设置',
}
export function useBreadcrumbs(): BreadcrumbProps['items'] {
const location = useLocation()
return useMemo(() => {
const pathSnippets = location.pathname.split('/').filter(Boolean)
if (pathSnippets.length === 0) {
return []
}
const items: BreadcrumbProps['items'] = []
let currentPath = ''
pathSnippets.forEach((snippet, index) => {
currentPath += `/${snippet}`
const name = breadcrumbNameMap[currentPath]
if (!name) {
return
}
items.push({
title: name,
path: index === pathSnippets.length - 1 ? undefined : currentPath,
})
})
return items
}, [location.pathname])
}

View File

@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const user = {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
nickname: 'Admin',
avatar: '',
status: 1 as const,
}
const roles = [
{
id: 1,
name: 'Administrator',
code: 'admin',
description: 'System administrator',
is_system: true,
is_default: false,
status: 1 as const,
},
]
describe('auth-session', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
})
it('stores and clears the session state in memory', async () => {
const session = await import('@/lib/http/auth-session')
session.setAccessToken('access-token', 60)
session.setCurrentUser(user)
session.setCurrentRoles(roles)
expect(session.getAccessToken()).toBe('access-token')
expect(session.getCurrentUser()).toEqual(user)
expect(session.getCurrentRoles()).toEqual(roles)
expect(session.getRoleCodes()).toEqual(['admin'])
expect(session.isAdmin()).toBe(true)
expect(session.isAuthenticated()).toBe(true)
session.clearSession()
expect(session.getAccessToken()).toBeNull()
expect(session.getCurrentUser()).toBeNull()
expect(session.getCurrentRoles()).toEqual([])
expect(session.isAuthenticated()).toBe(false)
})
it('starts empty after a module reload because the session is memory-only', async () => {
let session = await import('@/lib/http/auth-session')
session.setAccessToken('access-token', 60)
session.setCurrentUser(user)
session.setCurrentRoles(roles)
vi.resetModules()
session = await import('@/lib/http/auth-session')
expect(session.getAccessToken()).toBeNull()
expect(session.getCurrentUser()).toBeNull()
expect(session.getCurrentRoles()).toEqual([])
expect(session.isAuthenticated()).toBe(false)
})
it('marks the token as expired before the hard expiry time', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-03-21T00:00:00Z'))
const session = await import('@/lib/http/auth-session')
session.setAccessToken('access-token', 60)
expect(session.isAccessTokenExpired()).toBe(false)
vi.advanceTimersByTime(31_000)
expect(session.isAccessTokenExpired()).toBe(true)
session.clearSession()
vi.useRealTimers()
})
})

View File

@@ -0,0 +1,101 @@
import type { SessionUser, Role } from '@/types'
interface SessionState {
accessToken: string | null
expiresAt: number | null
user: SessionUser | null
roles: Role[]
isRefreshing: boolean
refreshPromise: Promise<void> | null
}
const sessionState: SessionState = {
accessToken: null,
expiresAt: null,
user: null,
roles: [],
isRefreshing: false,
refreshPromise: null,
}
export function getAccessToken(): string | null {
return sessionState.accessToken
}
export function setAccessToken(token: string, expiresIn: number): void {
sessionState.accessToken = token
sessionState.expiresAt = Date.now() + expiresIn * 1000
}
export function clearAccessToken(): void {
sessionState.accessToken = null
sessionState.expiresAt = null
}
export function isAccessTokenExpired(): boolean {
if (!sessionState.expiresAt) {
return true
}
return Date.now() > sessionState.expiresAt - 30_000
}
export function getCurrentUser(): SessionUser | null {
return sessionState.user
}
export function setCurrentUser(user: SessionUser): void {
sessionState.user = user
}
export function getCurrentRoles(): Role[] {
return sessionState.roles
}
export function setCurrentRoles(roles: Role[]): void {
sessionState.roles = roles
}
export function isAdmin(): boolean {
return sessionState.roles.some((role) => role.code === 'admin')
}
export function getRoleCodes(): string[] {
return sessionState.roles.map((role) => role.code)
}
export function isAuthenticated(): boolean {
return sessionState.accessToken !== null && sessionState.user !== null
}
export function clearSession(): void {
sessionState.accessToken = null
sessionState.expiresAt = null
sessionState.user = null
sessionState.roles = []
sessionState.isRefreshing = false
sessionState.refreshPromise = null
}
export function isRefreshing(): boolean {
return sessionState.isRefreshing
}
export function startRefreshing(): void {
sessionState.isRefreshing = true
}
export function endRefreshing(): void {
sessionState.isRefreshing = false
}
export function getRefreshPromise(): Promise<void> | null {
return sessionState.refreshPromise
}
export function setRefreshPromise(promise: Promise<void>): void {
sessionState.refreshPromise = promise
}
export function clearRefreshPromise(): void {
sessionState.refreshPromise = null
}

View File

@@ -0,0 +1,785 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
type JsonResponseInit = ResponseInit & {
status?: number
}
function jsonResponse(data: unknown, init: JsonResponseInit = {}) {
return new Response(JSON.stringify(data), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
...init,
})
}
async function loadModules() {
vi.resetModules()
const session = await import('@/lib/http/auth-session')
const storage = await import('@/lib/storage')
const csrf = await import('@/lib/http/csrf')
const errors = await import('@/lib/errors')
const client = await import('@/lib/http/client')
return {
...session,
...storage,
...csrf,
...errors,
...client,
}
}
describe('http client', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
vi.unstubAllEnvs()
vi.unstubAllGlobals()
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllEnvs()
vi.unstubAllGlobals()
})
it('builds query-string urls and skips undefined params without auth headers', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { ok: true },
}),
)
const { get } = await loadModules()
const result = await get(
'/users',
{ page: 2, active: true, keyword: undefined },
{ auth: false },
)
expect(result).toEqual({ ok: true })
expect(fetchMock).toHaveBeenCalledTimes(1)
const [requestUrl, requestInit] = fetchMock.mock.calls[0]
expect(String(requestUrl)).toBe(`${window.location.origin}/api/v1/users?page=2&active=true`)
expect(requestInit?.headers).not.toMatchObject({
Authorization: expect.any(String),
})
})
it('supports relative api base urls without a leading slash', async () => {
vi.stubEnv('VITE_API_BASE_URL', 'api/custom')
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { ok: true },
}),
)
const { get } = await loadModules()
await get('/status', undefined, { auth: false })
expect(fetchMock).toHaveBeenCalledWith(
`${window.location.origin}/api/custom/status`,
expect.any(Object),
)
})
it('supports absolute api base urls', async () => {
vi.stubEnv('VITE_API_BASE_URL', 'https://api.example.com/base')
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { ok: true },
}),
)
const { get } = await loadModules()
await get('/status', undefined, { auth: false })
expect(fetchMock).toHaveBeenCalledWith(
'https://api.example.com/base/status',
expect.any(Object),
)
})
it('sends FormData without forcing a JSON content type', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { uploaded: true },
}),
)
const { post } = await loadModules()
const formData = new FormData()
formData.append('file', new Blob(['demo'], { type: 'text/plain' }), 'demo.txt')
const result = await post('/upload', formData, { auth: false })
expect(result).toEqual({ uploaded: true })
expect(fetchMock).toHaveBeenCalledTimes(1)
const [requestUrl, requestInit] = fetchMock.mock.calls[0]
const headers = requestInit?.headers as Record<string, string> | undefined
expect(String(requestUrl)).toContain('/api/v1/upload')
expect(requestInit?.body).toBe(formData)
expect(requestInit?.credentials).toBe('include')
expect(headers?.['Content-Type']).toBeUndefined()
})
it('adds csrf and json headers for protected write requests', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { saved: true },
}),
)
const { CSRF_HEADER_NAME, put, setCSRFToken } = await loadModules()
setCSRFToken('csrf-token')
const result = await put('/users/1', { nickname: 'Demo' }, { auth: false })
expect(result).toEqual({ saved: true })
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(fetchMock.mock.calls[0][1]).toMatchObject({
method: 'PUT',
body: JSON.stringify({ nickname: 'Demo' }),
headers: {
'Content-Type': 'application/json',
[CSRF_HEADER_NAME]: 'csrf-token',
},
})
})
it('adds csrf headers to delete requests', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { deleted: true },
}),
)
const { CSRF_HEADER_NAME, del, setCSRFToken } = await loadModules()
setCSRFToken('csrf-token')
const result = await del('/users/1', { auth: false })
expect(result).toEqual({ deleted: true })
expect(fetchMock.mock.calls[0][1]).toMatchObject({
method: 'DELETE',
headers: {
[CSRF_HEADER_NAME]: 'csrf-token',
},
})
})
it('refreshes an expired access token before sending the business request', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock
.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: {
access_token: 'access-token-new',
refresh_token: 'refresh-token-new',
expires_in: 3600,
user: {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
nickname: 'Admin',
avatar: '',
status: 1,
},
},
}),
)
.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { ok: true },
}),
)
const { get, setAccessToken, setRefreshToken } = await loadModules()
setAccessToken('access-token-old', -1)
setRefreshToken('refresh-token-old')
const data = await get('/protected')
expect(data).toEqual({ ok: true })
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(String(fetchMock.mock.calls[0][0])).toContain('/api/v1/auth/refresh')
expect(fetchMock.mock.calls[0][1]).toMatchObject({
credentials: 'include',
method: 'POST',
body: JSON.stringify({ refresh_token: 'refresh-token-old' }),
})
expect(fetchMock.mock.calls[1][1]?.headers).toMatchObject({
Authorization: 'Bearer access-token-new',
})
})
it('waits for an in-flight refresh promise before sending the request', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { ok: true },
}),
)
const { get, setAccessToken, setRefreshPromise, startRefreshing } = await loadModules()
setAccessToken('queued-access-token', 3600)
startRefreshing()
setRefreshPromise(Promise.resolve())
const result = await get('/protected')
expect(result).toEqual({ ok: true })
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({
Authorization: 'Bearer queued-access-token',
})
})
it('clears the local session when refresh fails before the business request is sent', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(new Response(null, { status: 401 }))
const {
ErrorType,
get,
getAccessToken,
getRefreshToken,
setAccessToken,
setRefreshToken,
} = await loadModules()
setAccessToken('expired-access-token', -1)
setRefreshToken('refresh-token-old')
await expect(get('/protected')).rejects.toMatchObject({
status: 401,
type: ErrorType.AUTH,
})
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(getAccessToken()).toBeNull()
expect(getRefreshToken()).toBeNull()
})
it('clears the local session when refresh returns a business error payload', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 10001,
message: 'refresh failed',
data: null,
}),
)
const {
ErrorType,
get,
getAccessToken,
getRefreshToken,
setAccessToken,
setRefreshToken,
} = await loadModules()
setAccessToken('expired-access-token', -1)
setRefreshToken('refresh-token-old')
await expect(get('/protected')).rejects.toMatchObject({
status: 401,
type: ErrorType.AUTH,
})
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(getAccessToken()).toBeNull()
expect(getRefreshToken()).toBeNull()
})
it('retries once after a 401 response and rotates the in-memory refresh token', async () => {
const fetchMock = vi.mocked(fetch)
const capturedHeaders: Array<Record<string, string> | undefined> = []
fetchMock
.mockImplementationOnce(async (_url, requestInit) => {
capturedHeaders.push(
requestInit?.headers
? { ...(requestInit.headers as Record<string, string>) }
: undefined,
)
return new Response(null, { status: 401 })
})
.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: {
access_token: 'access-token-retried',
refresh_token: 'refresh-token-retried',
expires_in: 3600,
user: {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
nickname: 'Admin',
avatar: '',
status: 1,
},
},
}),
)
.mockImplementationOnce(async (_url, requestInit) => {
capturedHeaders.push(
requestInit?.headers
? { ...(requestInit.headers as Record<string, string>) }
: undefined,
)
return jsonResponse({
code: 0,
message: 'ok',
data: { retried: true },
})
})
const { get, getRefreshToken, setAccessToken, setRefreshToken } = await loadModules()
setAccessToken('access-token-old', 3600)
setRefreshToken('refresh-token-old')
const data = await get('/protected')
expect(data).toEqual({ retried: true })
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(capturedHeaders[0]).toMatchObject({
Authorization: 'Bearer access-token-old',
})
expect(String(fetchMock.mock.calls[1][0])).toContain('/api/v1/auth/refresh')
expect(fetchMock.mock.calls[1][1]).toMatchObject({
credentials: 'include',
method: 'POST',
body: JSON.stringify({ refresh_token: 'refresh-token-old' }),
})
expect(capturedHeaders[1]).toMatchObject({
Authorization: 'Bearer access-token-retried',
})
expect(getRefreshToken()).toBe('refresh-token-retried')
})
it('reuses an in-flight refresh token when a 401 retry happens during another refresh', async () => {
const fetchMock = vi.mocked(fetch)
const {
get,
setAccessToken,
setRefreshPromise,
startRefreshing,
} = await loadModules()
fetchMock
.mockImplementationOnce(async () => {
startRefreshing()
setAccessToken('shared-refresh-token', 3600)
setRefreshPromise(Promise.resolve())
return new Response(null, { status: 401 })
})
.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { retried: true },
}),
)
setAccessToken('access-token-old', 3600)
const data = await get('/protected')
expect(data).toEqual({ retried: true })
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(fetchMock.mock.calls[1][1]?.headers).toMatchObject({
Authorization: 'Bearer shared-refresh-token',
})
})
it('fails the 401 retry when the shared refresh finishes without an access token', async () => {
const fetchMock = vi.mocked(fetch)
const {
clearAccessToken,
ErrorType,
get,
getAccessToken,
getRefreshToken,
setAccessToken,
setRefreshPromise,
setRefreshToken,
startRefreshing,
} = await loadModules()
fetchMock.mockImplementationOnce(async () => {
startRefreshing()
clearAccessToken()
setRefreshPromise(Promise.resolve())
return new Response(null, { status: 401 })
})
setAccessToken('access-token-old', 3600)
setRefreshToken('refresh-token-old')
await expect(get('/protected')).rejects.toMatchObject({
status: 401,
type: ErrorType.AUTH,
})
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(getAccessToken()).toBeNull()
expect(getRefreshToken()).toBeNull()
})
it('clears the local session when the retried request still returns 401', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock
.mockResolvedValueOnce(new Response(null, { status: 401 }))
.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: {
access_token: 'access-token-retried',
refresh_token: 'refresh-token-retried',
expires_in: 3600,
user: {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
nickname: 'Admin',
avatar: '',
status: 1,
},
},
}),
)
.mockResolvedValueOnce(new Response(null, { status: 401 }))
const {
ErrorType,
get,
getAccessToken,
getRefreshToken,
setAccessToken,
setRefreshToken,
} = await loadModules()
setAccessToken('access-token-old', 3600)
setRefreshToken('refresh-token-old')
await expect(get('/protected')).rejects.toMatchObject({
status: 401,
type: ErrorType.AUTH,
})
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(getAccessToken()).toBeNull()
expect(getRefreshToken()).toBeNull()
})
it('maps 403 responses to forbidden errors', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(new Response(null, { status: 403 }))
const { ErrorType, get } = await loadModules()
await expect(get('/forbidden', undefined, { auth: false })).rejects.toMatchObject({
status: 403,
type: ErrorType.FORBIDDEN,
})
})
it('maps 404 responses to not-found errors', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(new Response(null, { status: 404 }))
const { ErrorType, get } = await loadModules()
await expect(get('/missing', undefined, { auth: false })).rejects.toMatchObject({
status: 404,
type: ErrorType.NOT_FOUND,
})
})
it('maps other non-ok responses to network errors', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 }))
const { ErrorType, get } = await loadModules()
await expect(get('/broken', undefined, { auth: false })).rejects.toMatchObject({
status: 0,
type: ErrorType.NETWORK,
})
})
it('maps non-zero business responses to AppError.fromResponse', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 10001,
message: 'business failure',
data: null,
}),
)
const { ErrorType, get } = await loadModules()
await expect(get('/business', undefined, { auth: false })).rejects.toMatchObject({
code: 10001,
status: 200,
type: ErrorType.BUSINESS,
})
})
it('converts aborted requests into timeout AppErrors', async () => {
vi.useFakeTimers()
const fetchMock = vi.mocked(fetch)
fetchMock.mockImplementation(
(_url, requestInit) =>
new Promise((_, reject) => {
;(requestInit?.signal as AbortSignal).addEventListener(
'abort',
() => reject(new DOMException('Aborted', 'AbortError')),
{ once: true },
)
}),
)
const { ErrorType, request } = await loadModules()
const requestPromise = expect(request('/slow', { auth: false })).rejects.toMatchObject({
status: 0,
type: ErrorType.NETWORK,
})
await vi.advanceTimersByTimeAsync(30_000)
await requestPromise
})
it('propagates a caller abort signal into the request timeout controller', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockImplementation(
(_url, requestInit) =>
new Promise((_, reject) => {
;(requestInit?.signal as AbortSignal).addEventListener(
'abort',
() => reject(new DOMException('Aborted', 'AbortError')),
{ once: true },
)
}),
)
const controller = new AbortController()
const { ErrorType, request } = await loadModules()
const requestPromise = expect(
request('/slow', { auth: false, signal: controller.signal }),
).rejects.toMatchObject({
status: 0,
type: ErrorType.NETWORK,
})
await Promise.resolve()
controller.abort()
await requestPromise
})
it('retries downloads after a 401 and returns the blob payload', async () => {
const fetchMock = vi.mocked(fetch)
const downloadedBlob = { kind: 'downloaded-blob' } as unknown as Blob
const successResponse = {
ok: true,
status: 200,
blob: vi.fn().mockResolvedValue(downloadedBlob),
} as unknown as Response
fetchMock
.mockResolvedValueOnce(new Response(null, { status: 401 }))
.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: {
access_token: 'download-access-token',
refresh_token: 'download-refresh-token',
expires_in: 3600,
user: {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
nickname: 'Admin',
avatar: '',
status: 1,
},
},
}),
)
.mockResolvedValueOnce(successResponse)
const { download, getRefreshToken, setAccessToken, setRefreshToken } = await loadModules()
setAccessToken('access-token-old', 3600)
setRefreshToken('refresh-token-old')
const blob = await download('/export')
expect(blob).toBe(downloadedBlob)
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(String(fetchMock.mock.calls[1][0])).toContain('/api/v1/auth/refresh')
expect(fetchMock.mock.calls[2][1]?.headers).toMatchObject({
Authorization: 'Bearer download-access-token',
})
expect(getRefreshToken()).toBe('download-refresh-token')
})
it('maps failed downloads to network AppErrors', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 }))
const { ErrorType, download } = await loadModules()
await expect(download('/export', undefined, { auth: false })).rejects.toMatchObject({
status: 0,
type: ErrorType.NETWORK,
})
})
it('clears the local session when a download retry still returns 401', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock
.mockResolvedValueOnce(new Response(null, { status: 401 }))
.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: {
access_token: 'download-access-token',
refresh_token: 'download-refresh-token',
expires_in: 3600,
user: {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
nickname: 'Admin',
avatar: '',
status: 1,
},
},
}),
)
.mockResolvedValueOnce(new Response(null, { status: 401 }))
const {
ErrorType,
download,
getAccessToken,
getRefreshToken,
setAccessToken,
setRefreshToken,
} = await loadModules()
setAccessToken('access-token-old', 3600)
setRefreshToken('refresh-token-old')
await expect(download('/export')).rejects.toMatchObject({
status: 401,
type: ErrorType.AUTH,
})
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(getAccessToken()).toBeNull()
expect(getRefreshToken()).toBeNull()
})
it('converts aborted downloads into timeout AppErrors', async () => {
vi.useFakeTimers()
const fetchMock = vi.mocked(fetch)
fetchMock.mockImplementation(
(_url, requestInit) =>
new Promise((_, reject) => {
;(requestInit?.signal as AbortSignal).addEventListener(
'abort',
() => reject(new DOMException('Aborted', 'AbortError')),
{ once: true },
)
}),
)
const { ErrorType, download } = await loadModules()
const downloadPromise = expect(
download('/export', undefined, { auth: false }),
).rejects.toMatchObject({
status: 0,
type: ErrorType.NETWORK,
})
await vi.advanceTimersByTimeAsync(30_000)
await downloadPromise
})
it('builds upload form data with additional fields', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { uploaded: true },
}),
)
const { upload } = await loadModules()
const file = new File(['demo'], 'avatar.png', { type: 'image/png' })
const result = await upload(
'/upload',
file,
'asset',
{ folder: 'avatars' },
{ auth: false },
)
expect(result).toEqual({ uploaded: true })
expect(fetchMock).toHaveBeenCalledTimes(1)
const requestInit = fetchMock.mock.calls[0][1]
const body = requestInit?.body as FormData
expect(requestInit?.method).toBe('POST')
expect(body.get('folder')).toBe('avatars')
expect(body.get('asset')).toBeInstanceOf(File)
expect((body.get('asset') as File).name).toBe('avatar.png')
})
})

View File

@@ -0,0 +1,367 @@
import { config } from '@/lib/config'
import { AppError, ErrorType } from '@/lib/errors'
import type { ApiResponse, RequestOptions } from '@/types'
import {
clearRefreshPromise,
clearSession,
endRefreshing,
getAccessToken,
getRefreshPromise,
isAccessTokenExpired,
isRefreshing,
setAccessToken,
setRefreshPromise,
startRefreshing,
} from './auth-session'
import { clearRefreshToken, getRefreshToken, setRefreshToken } from '../storage'
import { CSRF_PROTECTED_METHODS, getCSRFHeaders } from './csrf'
import type { TokenBundle } from '@/types'
const DEFAULT_TIMEOUT = 30_000
function isFormDataBody(body: unknown): body is FormData {
return typeof FormData !== 'undefined' && body instanceof FormData
}
function serializeBody(body: unknown): BodyInit | undefined {
if (body === undefined || body === null) {
return undefined
}
if (isFormDataBody(body)) {
return body
}
return JSON.stringify(body)
}
function resolveApiBaseUrl(): URL {
const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost'
const rawBaseUrl = /^https?:\/\//i.test(config.apiBaseUrl)
? config.apiBaseUrl
: config.apiBaseUrl.startsWith('/')
? config.apiBaseUrl
: `/${config.apiBaseUrl}`
const baseUrl = new URL(rawBaseUrl, origin)
if (!baseUrl.pathname.endsWith('/')) {
baseUrl.pathname = `${baseUrl.pathname}/`
}
return baseUrl
}
function buildUrl(path: string, params?: Record<string, string | number | boolean | undefined>): string {
const url = new URL(path.replace(/^\/+/, ''), resolveApiBaseUrl())
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) {
url.searchParams.append(key, String(value))
}
}
}
return url.toString()
}
function cleanupSessionOnAuthFailure(): never {
clearRefreshToken()
clearSession()
throw AppError.auth('会话已过期,请重新登录')
}
function createTimeoutSignal(signal?: AbortSignal): { signal: AbortSignal; cleanup: () => void } {
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), DEFAULT_TIMEOUT)
if (signal) {
signal.addEventListener('abort', () => controller.abort(), { once: true })
}
return {
signal: controller.signal,
cleanup: () => window.clearTimeout(timeoutId),
}
}
async function parseJsonResponse<T>(response: Response): Promise<ApiResponse<T>> {
return response.json() as Promise<ApiResponse<T>>
}
async function refreshAccessToken(): Promise<TokenBundle> {
const refreshToken = getRefreshToken()
const body = refreshToken ? JSON.stringify({ refresh_token: refreshToken }) : undefined
const response = await fetch(buildUrl('/auth/refresh'), {
method: 'POST',
credentials: 'include',
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body,
})
if (!response.ok) {
return cleanupSessionOnAuthFailure()
}
const result = await parseJsonResponse<TokenBundle>(response)
if (result.code !== 0) {
return cleanupSessionOnAuthFailure()
}
return result.data
}
async function performRefresh(): Promise<string> {
if (isRefreshing()) {
const promise = getRefreshPromise()
if (promise) {
await promise
}
const token = getAccessToken()
if (!token) {
return cleanupSessionOnAuthFailure()
}
return token
}
startRefreshing()
const promise = (async () => {
try {
const tokenBundle = await refreshAccessToken()
setAccessToken(tokenBundle.access_token, tokenBundle.expires_in)
setRefreshToken(tokenBundle.refresh_token)
return tokenBundle.access_token
} finally {
endRefreshing()
clearRefreshPromise()
}
})()
setRefreshPromise(
promise.then(
() => undefined,
() => undefined,
),
)
return promise
}
async function resolveAuthorizationHeader(auth: boolean): Promise<string | null> {
if (!auth) {
return null
}
let token = getAccessToken()
if (isRefreshing()) {
const promise = getRefreshPromise()
if (promise) {
await promise
token = getAccessToken()
}
}
if (token && isAccessTokenExpired()) {
token = await performRefresh()
}
return token
}
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const {
method = 'GET',
headers = {},
body,
params,
auth = true,
credentials = 'include',
signal,
} = options
const url = buildUrl(path, params)
const requestHeaders: Record<string, string> = { ...headers }
if (body !== undefined && body !== null && !isFormDataBody(body) && !requestHeaders['Content-Type']) {
requestHeaders['Content-Type'] = 'application/json'
}
if (CSRF_PROTECTED_METHODS.includes(method)) {
Object.assign(requestHeaders, getCSRFHeaders())
}
const authToken = await resolveAuthorizationHeader(auth)
if (authToken) {
requestHeaders.Authorization = `Bearer ${authToken}`
}
const timeout = createTimeoutSignal(signal)
try {
let response = await fetch(url, {
method,
headers: requestHeaders,
body: serializeBody(body),
credentials,
signal: timeout.signal,
})
if (response.status === 401 && auth) {
const refreshedToken = await performRefresh()
requestHeaders.Authorization = `Bearer ${refreshedToken}`
response = await fetch(url, {
method,
headers: requestHeaders,
body: serializeBody(body),
credentials,
signal: timeout.signal,
})
}
if (response.status === 401) {
return cleanupSessionOnAuthFailure()
}
if (!response.ok) {
if (response.status === 403) {
throw AppError.forbidden()
}
if (response.status === 404) {
throw new AppError(404, '请求的资源不存在', {
status: 404,
type: ErrorType.NOT_FOUND,
})
}
throw AppError.network(`请求失败: ${response.status}`)
}
const result = await parseJsonResponse<T>(response)
if (result.code !== 0) {
throw AppError.fromResponse(result, response.status)
}
return result.data
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw AppError.network('请求超时,请稍后重试')
}
throw error
} finally {
timeout.cleanup()
}
}
export function get<T>(
path: string,
params?: Record<string, string | number | boolean | undefined>,
options?: Omit<RequestOptions, 'method' | 'params' | 'body'>,
): Promise<T> {
return request<T>(path, { ...options, method: 'GET', params })
}
export function post<T>(
path: string,
body?: unknown,
options?: Omit<RequestOptions, 'method' | 'body'>,
): Promise<T> {
return request<T>(path, { ...options, method: 'POST', body })
}
export function put<T>(
path: string,
body?: unknown,
options?: Omit<RequestOptions, 'method' | 'body'>,
): Promise<T> {
return request<T>(path, { ...options, method: 'PUT', body })
}
export function del<T>(
path: string,
options?: Omit<RequestOptions, 'method'>,
): Promise<T> {
return request<T>(path, { ...options, method: 'DELETE' })
}
async function resolveAuthorizedHeaders(options?: Omit<RequestOptions, 'method' | 'params' | 'body'>): Promise<Record<string, string>> {
const headers: Record<string, string> = { ...(options?.headers ?? {}) }
if (options?.auth !== false) {
const token = await resolveAuthorizationHeader(true)
if (token) {
headers.Authorization = `Bearer ${token}`
}
}
return headers
}
export async function download(
path: string,
params?: Record<string, string | number | boolean | undefined>,
options?: Omit<RequestOptions, 'method' | 'params'>,
): Promise<Blob> {
const url = buildUrl(path, params)
const headers = await resolveAuthorizedHeaders(options)
const timeout = createTimeoutSignal(options?.signal)
try {
let response = await fetch(url, {
headers,
credentials: options?.credentials ?? 'include',
signal: timeout.signal,
})
if (response.status === 401 && options?.auth !== false) {
const refreshedToken = await performRefresh()
headers.Authorization = `Bearer ${refreshedToken}`
response = await fetch(url, {
headers,
credentials: options?.credentials ?? 'include',
signal: timeout.signal,
})
}
if (response.status === 401) {
return cleanupSessionOnAuthFailure()
}
if (!response.ok) {
throw AppError.network(`下载失败: ${response.status}`)
}
return response.blob()
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw AppError.network('下载超时,请稍后重试')
}
throw error
} finally {
timeout.cleanup()
}
}
export async function upload<T>(
path: string,
file: File,
fieldName: string = 'file',
additionalData?: Record<string, string>,
options?: Omit<RequestOptions, 'method' | 'body'>,
): Promise<T> {
const formData = new FormData()
formData.append(fieldName, file)
if (additionalData) {
for (const [key, value] of Object.entries(additionalData)) {
formData.append(key, value)
}
}
return request<T>(path, {
...options,
method: 'POST',
body: formData,
})
}
export { request }

View File

@@ -0,0 +1,192 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
function jsonResponse(data: unknown, init: ResponseInit = {}) {
return new Response(JSON.stringify(data), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
...init,
})
}
async function loadCsrfModule() {
vi.resetModules()
return import('./csrf')
}
function clearCsrfCookie() {
if (typeof document === 'undefined') {
return
}
document.cookie = 'csrftoken=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/'
}
describe('csrf helpers', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.unstubAllGlobals()
vi.unstubAllEnvs()
clearCsrfCookie()
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
clearCsrfCookie()
vi.restoreAllMocks()
vi.unstubAllGlobals()
vi.unstubAllEnvs()
})
it('returns null when cookie lookup runs without a document', async () => {
vi.stubGlobal('document', undefined)
const { getCSRFTokenFromCookie, getCSRFHeaders } = await loadCsrfModule()
expect(getCSRFTokenFromCookie()).toBeNull()
expect(getCSRFHeaders()).toEqual({})
})
it('stores csrf tokens in memory and falls back to the cookie for headers', async () => {
const {
CSRF_HEADER_NAME,
clearCSRFToken,
getCSRFHeaders,
getCSRFToken,
setCSRFToken,
} = await loadCsrfModule()
setCSRFToken('memory-token')
expect(getCSRFToken()).toBe('memory-token')
expect(getCSRFHeaders()).toEqual({
[CSRF_HEADER_NAME]: 'memory-token',
})
clearCSRFToken()
document.cookie = 'csrftoken=cookie-token; path=/'
expect(getCSRFToken()).toBeNull()
expect(getCSRFHeaders()).toEqual({
[CSRF_HEADER_NAME]: 'cookie-token',
})
})
it('prefers an existing csrf cookie and skips the network bootstrap', async () => {
const fetchMock = vi.mocked(fetch)
document.cookie = 'csrftoken=cookie-token; path=/'
const { getCSRFToken, initCSRFToken } = await loadCsrfModule()
const token = await initCSRFToken()
expect(token).toBe('cookie-token')
expect(getCSRFToken()).toBe('cookie-token')
expect(fetchMock).not.toHaveBeenCalled()
})
it('fetches and stores a csrf token from the default relative api base', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
data: {
csrf_token: 'api-token',
},
}),
)
const { getCSRFToken, initCSRFToken } = await loadCsrfModule()
const token = await initCSRFToken()
expect(token).toBe('api-token')
expect(getCSRFToken()).toBe('api-token')
expect(fetchMock).toHaveBeenCalledWith(
`${window.location.origin}/api/v1/auth/csrf-token`,
{
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
},
)
})
it('supports api base urls without a leading slash', async () => {
vi.stubEnv('VITE_API_BASE_URL', 'api/custom')
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
data: {
csrf_token: 'custom-token',
},
}),
)
const { initCSRFToken } = await loadCsrfModule()
await initCSRFToken()
expect(fetchMock).toHaveBeenCalledWith(
`${window.location.origin}/api/custom/auth/csrf-token`,
expect.any(Object),
)
})
it('supports absolute api base urls', async () => {
vi.stubEnv('VITE_API_BASE_URL', 'https://api.example.com/base')
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
data: {
csrf_token: 'absolute-token',
},
}),
)
const { initCSRFToken } = await loadCsrfModule()
await initCSRFToken()
expect(fetchMock).toHaveBeenCalledWith(
'https://api.example.com/base/auth/csrf-token',
expect.any(Object),
)
})
it('falls back to a cookie exposed after the csrf bootstrap request fails', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockRejectedValueOnce(new Error('network failed'))
const cookieSpy = vi
.spyOn(document, 'cookie', 'get')
.mockReturnValueOnce('')
.mockReturnValueOnce('csrftoken=fallback-token')
const { getCSRFToken, initCSRFToken } = await loadCsrfModule()
const token = await initCSRFToken()
expect(token).toBe('fallback-token')
expect(getCSRFToken()).toBe('fallback-token')
expect(fetchMock).toHaveBeenCalledTimes(1)
cookieSpy.mockRestore()
})
it('returns null when the bootstrap response does not contain a csrf token', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 1,
data: {},
}),
)
const { getCSRFHeaders, getCSRFToken, initCSRFToken } = await loadCsrfModule()
const token = await initCSRFToken()
expect(token).toBeNull()
expect(getCSRFToken()).toBeNull()
expect(getCSRFHeaders()).toEqual({})
})
})

View File

@@ -0,0 +1,145 @@
/**
* CSRF Token 管理
*
* CSRF 保护机制:
* 1. GET 请求获取 CSRF Token从 cookie 或 API
* 2. POST/PUT/DELETE 请求将 Token 添加到 X-CSRF-Token 头
*
* 注意:由于使用 Bearer Token 认证(存储在内存中),
* CSRF 风险相对较低,但为增强安全性仍建议对关键操作启用。
*/
// 注意:避免从 './client' 导入,防止循环依赖
// 使用原生 fetch 获取 CSRF Token
import { config } from '@/lib/config'
// CSRF Token 存储
let csrfToken: string | null = null
/**
* 获取 CSRF Token
*/
export function getCSRFToken(): string | null {
return csrfToken
}
/**
* 设置 CSRF Token
*/
export function setCSRFToken(token: string): void {
csrfToken = token
}
/**
* 从 cookie 中读取 CSRF Token
* Django/Laravel 等框架通常在 cookie 中设置 csrftoken
*/
export function getCSRFTokenFromCookie(): string | null {
if (typeof document === 'undefined') {
return null
}
const match = document.cookie.match(/csrftoken=([^;]+)/)
return match ? match[1] : null
}
/**
* 解析 API 基础 URL
* 注意:此函数复制自 client.ts 以避免循环依赖
*/
function resolveApiBaseUrl(): URL {
const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost'
const rawBaseUrl = /^https?:\/\//i.test(config.apiBaseUrl)
? config.apiBaseUrl
: config.apiBaseUrl.startsWith('/')
? config.apiBaseUrl
: `/${config.apiBaseUrl}`
const baseUrl = new URL(rawBaseUrl, origin)
if (!baseUrl.pathname.endsWith('/')) {
baseUrl.pathname = `${baseUrl.pathname}/`
}
return baseUrl
}
/**
* 构建完整 URL
*/
function buildUrl(path: string): string {
const normalizedPath = path.replace(/^\/+/, '')
const url = new URL(normalizedPath, resolveApiBaseUrl())
return url.toString()
}
/**
* 初始化 CSRF Token
* 从 cookie 或 API 获取 Token 并存储
*/
export async function initCSRFToken(): Promise<string | null> {
// 优先从 cookie 获取
let token = getCSRFTokenFromCookie()
if (!token) {
try {
// 使用原生 fetch 避免循环依赖
const response = await fetch(buildUrl('/auth/csrf-token'), {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
})
if (response.ok) {
const result = await response.json()
// 后端返回字段名为 csrf_token
if (result.code === 0 && result.data?.csrf_token) {
token = result.data.csrf_token
}
}
} catch {
// API 不支持,使用 cookie 中的 token如果有
token = getCSRFTokenFromCookie()
}
}
if (token) {
setCSRFToken(token)
}
return token
}
/**
* 清除 CSRF Token登出时调用
*/
export function clearCSRFToken(): void {
csrfToken = null
}
/**
* CSRF Token 头名称
*/
export const CSRF_HEADER_NAME = 'X-CSRF-Token'
/**
* 获取带 CSRF Token 的请求头
* 用于 POST/PUT/DELETE 请求
*/
export function getCSRFHeaders(): Record<string, string> {
const token = csrfToken || getCSRFTokenFromCookie()
if (!token) {
return {}
}
return {
[CSRF_HEADER_NAME]: token
}
}
/**
* 需要 CSRF 保护的方法列表
*/
export const CSRF_PROTECTED_METHODS = ['POST', 'PUT', 'DELETE', 'PATCH']

View File

@@ -0,0 +1,32 @@
export {
get,
post,
put,
del,
download,
upload,
request,
} from './client'
export {
getAccessToken,
setAccessToken,
clearAccessToken,
isAccessTokenExpired,
getCurrentUser,
setCurrentUser,
getCurrentRoles,
setCurrentRoles,
isAdmin,
getRoleCodes,
isAuthenticated,
clearSession,
isRefreshing,
startRefreshing,
endRefreshing,
getRefreshPromise,
setRefreshPromise,
clearRefreshPromise,
} from './auth-session'
export { AppError, ErrorType, isAppError } from '@/lib/errors'

View File

@@ -0,0 +1,4 @@
export * from './config'
export * from './errors'
export * from './http'
export * from './storage'

View File

@@ -0,0 +1,7 @@
export {
getRefreshToken,
setRefreshToken,
clearRefreshToken,
hasRefreshToken,
hasSessionPresenceCookie,
} from './token-storage'

View File

@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
clearRefreshToken,
getRefreshToken,
hasRefreshToken,
hasSessionPresenceCookie,
setRefreshToken,
} from './token-storage'
const originalDocument = globalThis.document
describe('token-storage', () => {
afterEach(() => {
clearRefreshToken()
vi.restoreAllMocks()
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: originalDocument,
})
})
it('stores refresh tokens in memory and normalizes empty values to null', () => {
setRefreshToken(' refresh-token ')
expect(getRefreshToken()).toBe('refresh-token')
expect(hasRefreshToken()).toBe(true)
setRefreshToken(' ')
expect(getRefreshToken()).toBeNull()
expect(hasRefreshToken()).toBe(false)
setRefreshToken(undefined)
expect(getRefreshToken()).toBeNull()
})
it('clears the in-memory refresh token explicitly', () => {
setRefreshToken('token-to-clear')
expect(hasRefreshToken()).toBe(true)
clearRefreshToken()
expect(getRefreshToken()).toBeNull()
expect(hasRefreshToken()).toBe(false)
})
it('detects the session presence cookie when it is present among other cookies', () => {
vi.spyOn(document, 'cookie', 'get').mockReturnValue('foo=bar; ums_session_present=1; theme=dark')
expect(hasSessionPresenceCookie()).toBe(true)
})
it('returns false when the session presence cookie is absent', () => {
vi.spyOn(document, 'cookie', 'get').mockReturnValue('foo=bar; theme=dark')
expect(hasSessionPresenceCookie()).toBe(false)
})
it('returns false when document is unavailable', () => {
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: undefined,
})
expect(hasSessionPresenceCookie()).toBe(false)
})
})

View File

@@ -0,0 +1,38 @@
/**
* In-memory refresh token storage.
*
* The authoritative session continuity mechanism is now the backend-managed
* HttpOnly refresh cookie. This module only keeps a process-local copy so the
* current tab can still send an explicit logout payload when available.
*/
let refreshToken: string | null = null
const SESSION_PRESENCE_COOKIE_NAME = 'ums_session_present'
export function getRefreshToken(): string | null {
return refreshToken
}
export function setRefreshToken(token: string | null | undefined): void {
const value = (token || '').trim()
refreshToken = value || null
}
export function clearRefreshToken(): void {
refreshToken = null
}
export function hasRefreshToken(): boolean {
return refreshToken !== null
}
export function hasSessionPresenceCookie(): boolean {
if (typeof document === 'undefined') {
return false
}
return document.cookie
.split(';')
.map((cookie) => cookie.trim())
.some((cookie) => cookie.startsWith(`${SESSION_PRESENCE_COOKIE_NAME}=`))
}

View File

@@ -0,0 +1,18 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { installWindowGuards } from '@/app/bootstrap/installWindowGuards'
import { ThemeProvider } from '@/app/providers/ThemeProvider'
// 使用 @/ 别名导入 App
import App from '@/app/App'
// 全局样式
import '@/styles/global.css'
installWindowGuards()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider>
<App />
</ThemeProvider>
</StrictMode>,
)

View File

@@ -0,0 +1,30 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { NotFoundPage } from './NotFoundPage'
const navigateMock = vi.fn()
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom')
return {
...actual,
useNavigate: () => navigateMock,
}
})
describe('NotFoundPage', () => {
it('renders the 404 state and routes users back to the dashboard', async () => {
const user = userEvent.setup()
render(<NotFoundPage />)
expect(screen.getByText('404')).toBeInTheDocument()
expect(screen.getByText('抱歉,您访问的页面不存在')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '返回首页' }))
expect(navigateMock).toHaveBeenCalledWith('/dashboard')
})
})

View File

@@ -0,0 +1,31 @@
/**
* 404 页面
*/
import { Result, Button } from 'antd'
import { useNavigate } from 'react-router-dom'
export function NotFoundPage() {
const navigate = useNavigate()
return (
<div style={{
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--color-canvas)',
}}>
<Result
status="404"
title="404"
subTitle="抱歉,您访问的页面不存在"
extra={
<Button type="primary" onClick={() => navigate('/dashboard')}>
</Button>
}
/>
</div>
)
}

View File

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

View File

@@ -0,0 +1,47 @@
/**
* Dashboard 页面样式
*/
.section {
margin-bottom: 32px;
}
.sectionTitle {
display: block;
margin-bottom: 16px;
font-size: 14px;
font-weight: 500;
}
.statCard {
border-radius: var(--radius-md);
border: 1px solid var(--color-border-soft);
transition: box-shadow var(--motion-fast);
}
.statCard:hover {
box-shadow: var(--shadow-sm);
}
.statCard :global(.ant-statistic-title) {
color: var(--color-text-muted);
font-size: 13px;
}
.statCard :global(.ant-statistic-content) {
font-size: 28px;
}
.infoCard {
margin-top: 24px;
border-radius: var(--radius-md);
background: var(--color-surface-muted);
border: none;
}
/* 响应式 */
@media (max-width: 768px) {
.statCard :global(.ant-statistic-content) {
font-size: 24px;
}
}

View File

@@ -0,0 +1,218 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DashboardStats } from '@/types/stats'
import { DashboardPage } from './DashboardPage'
const getDashboardStatsMock = vi.fn<() => Promise<DashboardStats>>()
const getErrorMessageMock = vi.fn<(error: unknown, fallback: string) => string>()
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 ''
}
function createDeferred<T>() {
let resolve!: (value: T) => void
let reject!: (error?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
const sampleStats: DashboardStats = {
total_users: 101,
active_users: 102,
inactive_users: 103,
locked_users: 104,
disabled_users: 105,
today_new_users: 106,
week_new_users: 107,
month_new_users: 108,
today_success_logins: 109,
today_failed_logins: 110,
week_success_logins: 111,
}
vi.mock('antd', () => ({
Col: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
Row: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
Statistic: ({
title,
value,
}: {
title?: ReactNode
value?: ReactNode
}) => (
<div data-testid="statistic">
<span>{flattenChildren(title)}</span>
<span>{String(value)}</span>
</div>
),
Tooltip: ({
children,
title,
}: {
children?: ReactNode
title?: ReactNode
}) => <span data-tooltip={flattenChildren(title)}>{children}</span>,
Typography: {
Text: ({
children,
className,
type,
}: {
children?: ReactNode
className?: string
type?: string
}) => {
void className
void type
return <span>{children}</span>
},
},
}))
vi.mock('@ant-design/icons', () => ({
CloseCircleOutlined: () => <span>close-icon</span>,
InfoCircleOutlined: () => <span>info-icon</span>,
LockOutlined: () => <span>lock-icon</span>,
LoginOutlined: () => <span>login-icon</span>,
StopOutlined: () => <span>stop-icon</span>,
TeamOutlined: () => <span>team-icon</span>,
UserAddOutlined: () => <span>user-add-icon</span>,
UserOutlined: () => <span>user-icon</span>,
}))
vi.mock('@/components/common', () => ({
PageHeader: ({
title,
description,
}: {
title: ReactNode
description?: ReactNode
}) => (
<section data-testid="page-header">
<h1>{flattenChildren(title)}</h1>
<p>{flattenChildren(description)}</p>
</section>
),
}))
vi.mock('@/components/feedback', () => ({
PageLoading: ({ tip }: { tip?: ReactNode }) => (
<div data-testid="page-loading">{flattenChildren(tip)}</div>
),
PageError: ({
description,
onRetry,
}: {
description?: ReactNode
onRetry?: () => void
}) => (
<div data-testid="page-error" data-has-retry={String(Boolean(onRetry))}>
<span>{flattenChildren(description)}</span>
{onRetry ? (
<button type="button" onClick={onRetry}>
retry
</button>
) : null}
</div>
),
}))
vi.mock('@/components/layout', () => ({
ContentCard: ({ children }: { children?: ReactNode }) => (
<div data-testid="content-card">{children}</div>
),
PageLayout: ({ children }: { children?: ReactNode }) => (
<div data-testid="page-layout">{children}</div>
),
}))
vi.mock('@/lib/errors', () => ({
getErrorMessage: (error: unknown, fallback: string) => getErrorMessageMock(error, fallback),
}))
vi.mock('@/services/stats', () => ({
getDashboardStats: () => getDashboardStatsMock(),
}))
describe('DashboardPage', () => {
beforeEach(() => {
getDashboardStatsMock.mockReset()
getErrorMessageMock.mockReset()
getErrorMessageMock.mockImplementation((_, fallback) => fallback)
})
it('shows loading first and then renders the dashboard cards after stats load', async () => {
const deferred = createDeferred<DashboardStats>()
getDashboardStatsMock.mockReturnValueOnce(deferred.promise)
render(<DashboardPage />)
expect(screen.getByTestId('page-loading')).toBeInTheDocument()
expect(getDashboardStatsMock).toHaveBeenCalledTimes(1)
deferred.resolve(sampleStats)
expect(await screen.findByTestId('page-layout')).toBeInTheDocument()
expect(screen.getByTestId('page-header')).toBeInTheDocument()
expect(screen.getAllByTestId('content-card')).toHaveLength(12)
for (const value of Object.values(sampleStats)) {
expect(screen.getByText(String(value))).toBeInTheDocument()
}
})
it('renders a retriable error state and reloads data successfully on retry', async () => {
const user = userEvent.setup()
getDashboardStatsMock
.mockRejectedValueOnce(new Error('network down'))
.mockResolvedValueOnce(sampleStats)
getErrorMessageMock.mockReturnValue('dashboard load failed')
render(<DashboardPage />)
expect(await screen.findByTestId('page-error')).toHaveAttribute('data-has-retry', 'true')
expect(screen.getByText('dashboard load failed')).toBeInTheDocument()
expect(getErrorMessageMock).toHaveBeenCalledWith(expect.any(Error), expect.any(String))
await user.click(screen.getByRole('button', { name: 'retry' }))
expect(await screen.findByTestId('page-layout')).toBeInTheDocument()
expect(getDashboardStatsMock).toHaveBeenCalledTimes(2)
})
it('renders a non-retriable empty-state error when the stats payload is missing', async () => {
getDashboardStatsMock.mockResolvedValueOnce(null as unknown as DashboardStats)
render(<DashboardPage />)
expect(await screen.findByTestId('page-error')).toHaveAttribute('data-has-retry', 'false')
expect(screen.queryByRole('button', { name: 'retry' })).not.toBeInTheDocument()
expect(screen.queryByTestId('page-layout')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,224 @@
/**
* Dashboard 页面
*
* 展示系统统计信息:
* - 用户统计卡片
* - 登录统计卡片
*/
import { useState, useEffect, useCallback } from 'react'
import { Row, Col, Statistic, Typography, Tooltip } from 'antd'
import {
UserOutlined,
TeamOutlined,
LockOutlined,
StopOutlined,
UserAddOutlined,
LoginOutlined,
CloseCircleOutlined,
InfoCircleOutlined,
} from '@ant-design/icons'
import { PageHeader } from '@/components/common'
import { PageLoading, PageError } from '@/components/feedback'
import { PageLayout, ContentCard } from '@/components/layout'
import { getErrorMessage } from '@/lib/errors'
import { getDashboardStats } from '@/services/stats'
import type { DashboardStats } from '@/types/stats'
import styles from './DashboardPage.module.css'
const { Text } = Typography
export function DashboardPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [stats, setStats] = useState<DashboardStats | null>(null)
const fetchStats = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await getDashboardStats()
setStats(data)
} catch (err) {
setError(getErrorMessage(err, '获取统计数据失败'))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchStats()
}, [fetchStats])
if (loading) {
return <PageLoading tip="加载统计数据..." />
}
if (error) {
return <PageError description={error} onRetry={fetchStats} />
}
if (!stats) {
return <PageError description="暂无统计数据" />
}
return (
<PageLayout>
<PageHeader
title="总览"
description="系统运行状态与用户统计"
/>
{/* 用户状态统计 */}
<div className={styles.section}>
<Text type="secondary" className={styles.sectionTitle}>
</Text>
<Row gutter={[16, 16]}>
<Col xs={12} sm={8} lg={6}>
<ContentCard>
<Statistic
title="用户总数"
value={stats.total_users}
prefix={<TeamOutlined />}
valueStyle={{ color: 'var(--color-text-strong)' }}
/>
</ContentCard>
</Col>
<Col xs={12} sm={8} lg={6}>
<ContentCard>
<Statistic
title="已激活"
value={stats.active_users}
prefix={<UserOutlined />}
valueStyle={{ color: 'var(--color-success)' }}
/>
</ContentCard>
</Col>
<Col xs={12} sm={8} lg={6}>
<ContentCard>
<Statistic
title="未激活"
value={stats.inactive_users}
prefix={<UserOutlined />}
valueStyle={{ color: 'var(--color-text-muted)' }}
/>
</ContentCard>
</Col>
<Col xs={12} sm={8} lg={6}>
<ContentCard>
<Statistic
title="已锁定"
value={stats.locked_users}
prefix={<LockOutlined />}
valueStyle={{ color: 'var(--color-warning)' }}
/>
</ContentCard>
</Col>
<Col xs={12} sm={8} lg={6}>
<ContentCard>
<Statistic
title="已禁用"
value={stats.disabled_users}
prefix={<StopOutlined />}
valueStyle={{ color: 'var(--color-danger)' }}
/>
</ContentCard>
</Col>
</Row>
</div>
{/* 新增用户统计 */}
<div className={styles.section}>
<Text type="secondary" className={styles.sectionTitle}>
</Text>
<Row gutter={[16, 16]}>
<Col xs={12} sm={8} lg={6}>
<ContentCard>
<Statistic
title="今日新增"
value={stats.today_new_users}
prefix={<UserAddOutlined />}
valueStyle={{ color: 'var(--color-primary)' }}
/>
</ContentCard>
</Col>
<Col xs={12} sm={8} lg={6}>
<ContentCard>
<Statistic
title="本周新增"
value={stats.week_new_users}
prefix={<UserAddOutlined />}
valueStyle={{ color: 'var(--color-primary)' }}
/>
</ContentCard>
</Col>
<Col xs={12} sm={8} lg={6}>
<ContentCard>
<Statistic
title="本月新增"
value={stats.month_new_users}
prefix={<UserAddOutlined />}
valueStyle={{ color: 'var(--color-primary)' }}
/>
</ContentCard>
</Col>
</Row>
</div>
{/* 登录统计 */}
<div className={styles.section}>
<Text type="secondary" className={styles.sectionTitle}>
</Text>
<Row gutter={[16, 16]}>
<Col xs={12} sm={8} lg={6}>
<ContentCard>
<Statistic
title="今日成功登录"
value={stats.today_success_logins}
prefix={<LoginOutlined />}
valueStyle={{ color: 'var(--color-success)' }}
/>
</ContentCard>
</Col>
<Col xs={12} sm={8} lg={6}>
<ContentCard>
<Statistic
title={
<span>
<Tooltip title="包含密码错误、验证码错误、账号锁定等失败原因">
<InfoCircleOutlined style={{ marginLeft: 4, color: 'var(--color-text-muted)' }} />
</Tooltip>
</span>
}
value={stats.today_failed_logins}
prefix={<CloseCircleOutlined />}
valueStyle={{ color: 'var(--color-danger)' }}
/>
</ContentCard>
</Col>
<Col xs={12} sm={8} lg={6}>
<ContentCard>
<Statistic
title="本周成功登录"
value={stats.week_success_logins}
prefix={<LoginOutlined />}
valueStyle={{ color: 'var(--color-success)' }}
/>
</ContentCard>
</Col>
</Row>
</div>
{/* 说明卡片 */}
<ContentCard>
<Text type="secondary">
线
</Text>
</ContentCard>
</PageLayout>
)
}

View File

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

View File

@@ -0,0 +1,352 @@
/**
* 设备管理页
*
* 功能:
* - 全局设备列表、筛选(用户、状态、信任状态)、分页
* - 设备详情、信任/取消信任、删除
*/
import { useState, useEffect, useCallback } from 'react'
import {
Table,
Button,
Space,
Tag,
Input,
Select,
Popconfirm,
message,
type TableColumnsType,
type TablePaginationConfig,
} from 'antd'
import {
SearchOutlined,
ReloadOutlined,
DeleteOutlined,
} from '@ant-design/icons'
import dayjs from 'dayjs'
import { PageHeader } from '@/components/common'
import { PageEmpty, PageError } from '@/components/feedback'
import { PageLayout, FilterCard, TableCard } from '@/components/layout'
import { getErrorMessage } from '@/lib/errors'
import {
listAllDevices,
deleteDevice,
trustDevice,
untrustDevice,
} from '@/services/devices'
import type { Device, AdminDeviceListParams, DeviceStatus, DeviceType } from '@/types/device'
import { DeviceTypeText, DeviceStatusText, DeviceStatusColor, DeviceTrustText, DeviceTrustColor } from '@/types/device'
export function DevicesPage() {
// 列表数据
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [devices, setDevices] = useState<Device[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
// 筛选条件
const [keyword, setKeyword] = useState('')
const [userIdFilter, setUserIdFilter] = useState<number | undefined>()
const [statusFilter, setStatusFilter] = useState<DeviceStatus | undefined>()
const [trustFilter, setTrustFilter] = useState<boolean | undefined>()
// 加载设备列表
const fetchDevices = useCallback(async () => {
setLoading(true)
setError(null)
try {
const params: AdminDeviceListParams = {
page,
page_size: pageSize,
keyword: keyword || undefined,
user_id: userIdFilter,
status: statusFilter,
is_trusted: trustFilter,
}
const result = await listAllDevices(params)
setDevices(result.items)
setTotal(result.total)
} catch (err) {
setError(getErrorMessage(err, '获取设备列表失败'))
} finally {
setLoading(false)
}
}, [page, pageSize, keyword, userIdFilter, statusFilter, trustFilter])
useEffect(() => {
void fetchDevices()
}, [fetchDevices])
// 筛选条件变化时重置到第一页
useEffect(() => {
setPage(1)
}, [keyword, userIdFilter, statusFilter, trustFilter])
// 重置筛选
const handleReset = () => {
setKeyword('')
setUserIdFilter(undefined)
setStatusFilter(undefined)
setTrustFilter(undefined)
setPage(1)
}
// 删除设备
const handleDelete = async (device: Device) => {
try {
await deleteDevice(device.id)
message.success(`设备 ${device.device_name} 已删除`)
void fetchDevices()
} catch (err) {
message.error(getErrorMessage(err, '删除失败'))
}
}
// 信任设备
const handleTrust = async (device: Device) => {
try {
await trustDevice(device.id, '30d')
message.success(`设备 ${device.device_name} 已设为信任`)
void fetchDevices()
} catch (err) {
message.error(getErrorMessage(err, '操作失败'))
}
}
// 取消信任
const handleUntrust = async (device: Device) => {
try {
await untrustDevice(device.id)
message.success(`设备 ${device.device_name} 已取消信任`)
void fetchDevices()
} catch (err) {
message.error(getErrorMessage(err, '操作失败'))
}
}
// 表格列定义
const columns: TableColumnsType<Device> = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
},
{
title: '用户ID',
dataIndex: 'user_id',
key: 'user_id',
width: 80,
},
{
title: '设备名称',
dataIndex: 'device_name',
key: 'device_name',
width: 120,
render: (text) => text || '-',
},
{
title: '设备类型',
dataIndex: 'device_type',
key: 'device_type',
width: 80,
render: (type: DeviceType) => DeviceTypeText[type] || '未知',
},
{
title: '操作系统',
dataIndex: 'device_os',
key: 'device_os',
width: 80,
render: (text) => text || '-',
},
{
title: '浏览器',
dataIndex: 'device_browser',
key: 'device_browser',
width: 80,
render: (text) => text || '-',
},
{
title: 'IP地址',
dataIndex: 'ip',
key: 'ip',
width: 120,
},
{
title: '位置',
dataIndex: 'location',
key: 'location',
width: 120,
render: (text) => text || '-',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 80,
render: (status: DeviceStatus) => (
<Tag color={DeviceStatusColor[status]}>{DeviceStatusText[status]}</Tag>
),
},
{
title: '信任状态',
dataIndex: 'is_trusted',
key: 'is_trusted',
width: 80,
render: (isTrusted: boolean) => (
<Tag color={DeviceTrustColor[String(isTrusted)]}>{DeviceTrustText[String(isTrusted)]}</Tag>
),
},
{
title: '最后活跃',
dataIndex: 'last_active_time',
key: 'last_active_time',
width: 160,
render: (text) => text ? dayjs(text).format('YYYY-MM-DD HH:mm') : '-',
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 160,
render: (text) => dayjs(text).format('YYYY-MM-DD HH:mm'),
},
{
title: '操作',
key: 'action',
width: 180,
fixed: 'right',
render: (_, record) => (
<Space size="small">
{record.is_trusted ? (
<Button type="link" size="small" onClick={() => void handleUntrust(record)}>
</Button>
) : (
<Button type="link" size="small" onClick={() => void handleTrust(record)}>
</Button>
)}
<Popconfirm
title={`确定要删除设备「${record.device_name}」吗?此操作不可恢复。`}
onConfirm={() => void handleDelete(record)}
>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
>
</Button>
</Popconfirm>
</Space>
),
},
]
// 分页配置
const paginationConfig: TablePaginationConfig = {
current: page,
pageSize,
total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
onChange: (p, ps) => {
setPage(p)
setPageSize(ps)
},
}
if (error) {
return <PageError description={error} onRetry={() => void fetchDevices()} />
}
return (
<PageLayout>
<PageHeader
title="设备管理"
description="管理系统所有设备,支持查看、信任状态管理和删除"
actions={
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void fetchDevices()}>
</Button>
</Space>
}
/>
{/* 筛选区域 */}
<FilterCard>
<Space wrap size="middle">
<Input
placeholder="设备名/IP/位置"
prefix={<SearchOutlined />}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onPressEnter={() => void fetchDevices()}
style={{ width: 200 }}
allowClear
/>
<Input
placeholder="用户ID"
type="number"
value={userIdFilter}
onChange={(e) => setUserIdFilter(e.target.value ? Number(e.target.value) : undefined)}
style={{ width: 100 }}
allowClear
/>
<Select
placeholder="设备状态"
value={statusFilter}
onChange={setStatusFilter}
allowClear
style={{ width: 100 }}
options={[
{ value: 0, label: '离线' },
{ value: 1, label: '活跃' },
]}
/>
<Select
placeholder="信任状态"
value={trustFilter}
onChange={setTrustFilter}
allowClear
style={{ width: 100 }}
options={[
{ value: true, label: '已信任' },
{ value: false, label: '未信任' },
]}
/>
<Button type="primary" icon={<SearchOutlined />} onClick={() => void fetchDevices()}>
</Button>
<Button onClick={handleReset}></Button>
</Space>
</FilterCard>
{/* 设备列表 */}
<TableCard>
<Table
columns={columns}
dataSource={devices}
rowKey="id"
loading={loading}
pagination={paginationConfig}
scroll={{ x: 1400 }}
locale={{
emptyText: (
<PageEmpty
description="暂无设备数据"
/>
),
}}
/>
</TableCard>
</PageLayout>
)
}

View File

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

View File

@@ -0,0 +1,194 @@
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { message } from 'antd'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ImportUsersResult } from '@/types/import-export'
import { ImportExportPage } from './ImportExportPage'
const downloadImportTemplateMock = vi.fn<(format: 'csv' | 'xlsx') => Promise<void>>()
const exportUsersMock = vi.fn<(payload: unknown) => Promise<void>>()
const importUsersMock = vi.fn<(file: File) => Promise<ImportUsersResult>>()
const realGetComputedStyle = window.getComputedStyle.bind(window)
vi.mock('@/services/import-export', () => ({
downloadImportTemplate: (format: 'csv' | 'xlsx') => downloadImportTemplateMock(format),
exportUsers: (payload: unknown) => exportUsersMock(payload),
importUsers: (file: File) => importUsersMock(file),
}))
function getUploadInput() {
const uploadInput = document.querySelector('input[type="file"]')
expect(uploadInput).not.toBeNull()
return uploadInput as HTMLInputElement
}
describe('ImportExportPage', () => {
beforeEach(() => {
downloadImportTemplateMock.mockReset()
exportUsersMock.mockReset()
importUsersMock.mockReset()
vi.spyOn(message, 'success').mockImplementation(() => undefined as never)
vi.spyOn(message, 'error').mockImplementation(() => undefined as never)
vi.spyOn(window, 'getComputedStyle').mockImplementation((element) => realGetComputedStyle(element))
})
afterEach(() => {
vi.restoreAllMocks()
})
it('downloads the default import template format', async () => {
const user = userEvent.setup()
downloadImportTemplateMock.mockResolvedValue(undefined)
render(<ImportExportPage />)
const importPanel = screen.getByRole('tabpanel', { name: /导入用户/ })
await user.click(within(importPanel).getByRole('button', { name: /下载模板/ }))
await waitFor(() => expect(downloadImportTemplateMock).toHaveBeenCalledWith('csv'))
expect(message.success).toHaveBeenCalledTimes(1)
})
it('switches the import format and surfaces template download failures', async () => {
const user = userEvent.setup()
downloadImportTemplateMock.mockRejectedValue(new Error('template failed'))
render(<ImportExportPage />)
const importPanel = screen.getByRole('tabpanel', { name: /导入用户/ })
await user.click(within(importPanel).getByRole('radio', { name: 'Excel (.xlsx)' }))
await user.click(within(importPanel).getByRole('button', { name: /下载模板/ }))
await waitFor(() => expect(downloadImportTemplateMock).toHaveBeenCalledWith('xlsx'))
await waitFor(() => expect(message.error).toHaveBeenCalledWith('template failed'))
})
it('rejects unsupported and oversized import files before hitting the service', async () => {
const user = userEvent.setup({ applyAccept: false })
render(<ImportExportPage />)
await user.upload(getUploadInput(), new File(['hello'], 'users.txt', { type: 'text/plain' }))
await waitFor(() => expect(message.error).toHaveBeenCalledTimes(1))
const oversizedFile = new File([new Uint8Array(11 * 1024 * 1024)], 'users.csv', { type: 'text/csv' })
await user.upload(getUploadInput(), oversizedFile)
await waitFor(() => expect(message.error).toHaveBeenCalledTimes(2))
expect(importUsersMock).not.toHaveBeenCalled()
})
it('surfaces backend import failures for supported files', async () => {
const user = userEvent.setup()
importUsersMock.mockRejectedValue(new Error('import failed'))
render(<ImportExportPage />)
const csvFile = new File(['username,email'], 'users.csv', { type: 'text/csv' })
await user.upload(getUploadInput(), csvFile)
await waitFor(() => expect(importUsersMock).toHaveBeenCalledTimes(1))
await waitFor(() => expect(message.error).toHaveBeenCalledWith('import failed'))
})
it('submits a csv import and renders a success result summary', async () => {
const user = userEvent.setup()
importUsersMock.mockResolvedValue({
success_count: 2,
fail_count: 0,
errors: [],
message: '导入完成',
})
render(<ImportExportPage />)
const csvFile = new File(['username,email'], 'users.csv', { type: 'text/csv' })
await user.upload(getUploadInput(), csvFile)
await waitFor(() => expect(importUsersMock).toHaveBeenCalledTimes(1))
expect(await screen.findByText('成功 2 条,失败 0 条')).toBeInTheDocument()
expect(document.querySelector('.ant-alert-success')).not.toBeNull()
})
it('submits an xlsx import, renders warning details, and resets the import flow', async () => {
const user = userEvent.setup()
importUsersMock.mockResolvedValue({
success_count: 1,
fail_count: 2,
errors: ['row 2 invalid email', 'row 3 duplicate username'],
message: '',
})
render(<ImportExportPage />)
const xlsxFile = new File(['binary'], 'users.xlsx', {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
})
await user.upload(getUploadInput(), xlsxFile)
await waitFor(() => expect(importUsersMock).toHaveBeenCalledTimes(1))
expect(await screen.findByText('row 2 invalid email')).toBeInTheDocument()
expect(screen.getByText('row 3 duplicate username')).toBeInTheDocument()
expect(document.querySelector('.ant-alert-warning')).not.toBeNull()
expect(message.success).toHaveBeenCalledTimes(1)
await user.click(screen.getByRole('button', { name: '继续导入' }))
expect(screen.queryByText('row 2 invalid email')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /下载模板/ })).toBeInTheDocument()
})
it('requires at least one export field and submits the selected export payload', async () => {
const user = userEvent.setup()
exportUsersMock.mockResolvedValue(undefined)
render(<ImportExportPage />)
await user.click(screen.getByRole('tab', { name: /导出用户/ }))
const exportPanel = screen.getByRole('tabpanel', { name: /导出用户/ })
await user.click(within(exportPanel).getByRole('button', { name: '清空' }))
await user.click(within(exportPanel).getByRole('button', { name: /导出用户数据/ }))
expect(exportUsersMock).not.toHaveBeenCalled()
expect(message.error).toHaveBeenCalledTimes(1)
await user.click(within(exportPanel).getByRole('button', { name: '全选' }))
await user.type(within(exportPanel).getByRole('textbox'), 'alice')
await user.click(within(exportPanel).getByRole('radio', { name: /CSV \(\.csv\)/ }))
await user.click(within(exportPanel).getByRole('button', { name: /导出用户数据/ }))
await waitFor(() => expect(exportUsersMock).toHaveBeenCalledWith({
format: 'csv',
fields: ['id', 'username', 'email', 'phone', 'nickname', 'status', 'totp_enabled', 'last_login_time', 'created_at'],
keyword: 'alice',
status: undefined,
}))
expect(message.success).toHaveBeenCalledTimes(1)
})
it('updates export field selection and surfaces export failures with an empty keyword', async () => {
const user = userEvent.setup()
exportUsersMock.mockRejectedValue(new Error('export failed'))
render(<ImportExportPage />)
await user.click(screen.getByRole('tab', { name: /导出用户/ }))
const exportPanel = screen.getByRole('tabpanel', { name: /导出用户/ })
await user.click(within(exportPanel).getByRole('checkbox', { name: /ID/ }))
await user.click(within(exportPanel).getByRole('button', { name: /导出用户数据/ }))
await waitFor(() => expect(exportUsersMock).toHaveBeenCalledWith({
format: 'xlsx',
fields: ['username', 'email', 'phone', 'status', 'created_at'],
keyword: undefined,
status: undefined,
}))
await waitFor(() => expect(message.error).toHaveBeenCalledWith('export failed'))
})
})

View File

@@ -0,0 +1,361 @@
import { useState, type ChangeEvent } from 'react'
import {
Alert,
Button,
Card,
Checkbox,
Col,
Input,
Radio,
Row,
Select,
Space,
Steps,
Table,
Tabs,
Typography,
Upload,
message,
} from 'antd'
import {
DownloadOutlined,
FileExcelOutlined,
InboxOutlined,
UploadOutlined,
} from '@ant-design/icons'
import type { RcFile } from 'antd/es/upload'
import { getErrorMessage } from '@/lib/errors'
import { PageLayout, ContentCard } from '@/components/layout'
import { PageHeader } from '@/components/common'
import {
downloadImportTemplate,
exportUsers,
importUsers,
} from '@/services/import-export'
import type { ImportExportFormat, ImportUsersResult } from '@/types/import-export'
const { Text } = Typography
const { Dragger } = Upload
const exportableFields = [
{ key: 'id', label: '用户 ID' },
{ key: 'username', label: '用户名' },
{ key: 'email', label: '邮箱' },
{ key: 'phone', label: '手机号' },
{ key: 'nickname', label: '昵称' },
{ key: 'status', label: '状态' },
{ key: 'totp_enabled', label: 'TOTP 状态' },
{ key: 'last_login_time', label: '最后登录时间' },
{ key: 'created_at', label: '注册时间' },
]
export function ImportExportPage() {
const [activeTab, setActiveTab] = useState('import')
const [importLoading, setImportLoading] = useState(false)
const [importStep, setImportStep] = useState(0)
const [importFormat, setImportFormat] = useState<ImportExportFormat>('csv')
const [importResult, setImportResult] = useState<ImportUsersResult | null>(null)
const [exportLoading, setExportLoading] = useState(false)
const [exportFormat, setExportFormat] = useState<ImportExportFormat>('xlsx')
const [exportFields, setExportFields] = useState<string[]>([
'id',
'username',
'email',
'phone',
'status',
'created_at',
])
const [exportKeyword, setExportKeyword] = useState('')
const [exportStatus, setExportStatus] = useState<number | undefined>()
const handleImport = async (file: RcFile) => {
const lowerName = file.name.toLowerCase()
const isSupported = lowerName.endsWith('.csv') || lowerName.endsWith('.xlsx')
if (!isSupported) {
message.error('只能上传 CSV 或 XLSX 文件')
return false
}
const isLt10M = file.size / 1024 / 1024 < 10
if (!isLt10M) {
message.error('文件大小不能超过 10MB')
return false
}
try {
setImportLoading(true)
setImportStep(1)
const result = await importUsers(file)
setImportResult(result)
setImportStep(2)
message.success(result.message || '导入完成')
} catch (error) {
setImportStep(0)
message.error(getErrorMessage(error, '导入失败'))
} finally {
setImportLoading(false)
}
return false
}
const handleDownloadTemplate = async () => {
try {
await downloadImportTemplate(importFormat)
message.success('模板下载已开始')
} catch (error) {
message.error(getErrorMessage(error, '下载模板失败'))
}
}
const handleExport = async () => {
if (exportFields.length === 0) {
message.error('请至少选择一个导出字段')
return
}
try {
setExportLoading(true)
await exportUsers({
format: exportFormat,
fields: exportFields,
keyword: exportKeyword || undefined,
status: exportStatus,
})
message.success('导出任务已开始')
} catch (error) {
message.error(getErrorMessage(error, '导出失败'))
} finally {
setExportLoading(false)
}
}
const handleResetImport = () => {
setImportStep(0)
setImportResult(null)
}
const importErrorRows = (importResult?.errors || []).map((error, index) => ({
key: `${index}`,
index: index + 1,
message: error,
}))
const tabItems = [
{
key: 'import',
label: (
<span>
<UploadOutlined />
</span>
),
children: (
<div>
<Steps
current={importStep}
style={{ marginBottom: 24 }}
items={[
{ title: '上传文件' },
{ title: '服务端处理' },
{ title: '完成' },
]}
/>
{importStep !== 2 && (
<>
<Alert
type="info"
showIcon
message="当前真实导入能力"
description={(
<div>
<p>1. `.csv` `.xlsx`</p>
<p>2. 10MB</p>
<p>3. </p>
</div>
)}
style={{ marginBottom: 16 }}
/>
<Card title="模板格式" size="small" style={{ marginBottom: 16 }}>
<Space direction="vertical" style={{ width: '100%' }}>
<Radio.Group value={importFormat} onChange={(event) => setImportFormat(event.target.value)}>
<Radio value="csv">CSV (.csv)</Radio>
<Radio value="xlsx">Excel (.xlsx)</Radio>
</Radio.Group>
<Button icon={<DownloadOutlined />} onClick={() => void handleDownloadTemplate()}>
</Button>
</Space>
</Card>
<Dragger
accept=".csv,.xlsx"
showUploadList={false}
beforeUpload={(file) => {
void handleImport(file)
return false
}}
disabled={importLoading}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint">
CSV / XLSX 10MB
</p>
</Dragger>
</>
)}
{importStep === 2 && importResult && (
<Card>
<Space direction="vertical" style={{ width: '100%' }} size="large">
<Alert
type={importResult.fail_count > 0 ? 'warning' : 'success'}
showIcon
message={importResult.message}
description={`成功 ${importResult.success_count} 条,失败 ${importResult.fail_count}`}
/>
{importErrorRows.length > 0 && (
<Table
rowKey="key"
dataSource={importErrorRows}
pagination={false}
size="small"
columns={[
{ title: '#', dataIndex: 'index', key: 'index', width: 80 },
{ title: '错误信息', dataIndex: 'message', key: 'message' },
]}
/>
)}
<Button onClick={handleResetImport}></Button>
</Space>
</Card>
)}
</div>
),
},
{
key: 'export',
label: (
<span>
<DownloadOutlined />
</span>
),
children: (
<div>
<Alert
type="info"
showIcon
message="当前真实导出能力"
description="后端支持 format、fields、keyword、status 四类参数;不再走前端假流程。"
style={{ marginBottom: 16 }}
/>
<Card title="导出筛选" size="small" style={{ marginBottom: 16 }}>
<Row gutter={[16, 16]}>
<Col xs={24} md={12}>
<Input
placeholder="关键字"
value={exportKeyword}
onChange={(event: ChangeEvent<HTMLInputElement>) => setExportKeyword(event.target.value)}
/>
</Col>
<Col xs={24} md={12}>
<Select
placeholder="用户状态"
value={exportStatus}
onChange={setExportStatus}
allowClear
style={{ width: '100%' }}
options={[
{ value: 0, label: '未激活' },
{ value: 1, label: '已激活' },
{ value: 2, label: '已锁定' },
{ value: 3, label: '已禁用' },
]}
/>
</Col>
</Row>
</Card>
<Card title="导出字段" size="small" style={{ marginBottom: 16 }}>
<Checkbox.Group
value={exportFields}
onChange={(values) => setExportFields(values as string[])}
style={{ width: '100%' }}
>
<Row gutter={[8, 8]}>
{exportableFields.map((field) => (
<Col xs={24} sm={12} md={8} key={field.key}>
<Checkbox value={field.key}>{field.label}</Checkbox>
</Col>
))}
</Row>
</Checkbox.Group>
<div style={{ marginTop: 8 }}>
<Button type="link" size="small" onClick={() => setExportFields(exportableFields.map((field) => field.key))}>
</Button>
<Button type="link" size="small" onClick={() => setExportFields([])}>
</Button>
</div>
</Card>
<Card title="导出格式" size="small" style={{ marginBottom: 16 }}>
<Radio.Group value={exportFormat} onChange={(event) => setExportFormat(event.target.value)}>
<Radio value="xlsx">
<Space>
<FileExcelOutlined />
Excel (.xlsx)
</Space>
</Radio>
<Radio value="csv">
<Space>
<FileExcelOutlined />
CSV (.csv)
</Space>
</Radio>
</Radio.Group>
</Card>
<div style={{ textAlign: 'center' }}>
<Button
type="primary"
size="large"
icon={<DownloadOutlined />}
onClick={() => void handleExport()}
loading={exportLoading}
>
</Button>
</div>
<div style={{ marginTop: 16 }}>
<Text type="secondary">
{exportFields.length} .{exportFormat}
</Text>
</div>
</div>
),
},
]
return (
<PageLayout>
<PageHeader
title="导入导出"
description="对齐真实后端 `/admin/users/import|export|import/template`"
/>
<ContentCard>
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
</ContentCard>
</PageLayout>
)
}

View File

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

View File

@@ -0,0 +1,38 @@
import { Descriptions, Drawer, Tag } from 'antd'
import dayjs from 'dayjs'
import type { LoginLog } from '@/types/login-log'
import { LoginStatusColor, LoginStatusText, LoginTypeText } from '@/types/login-log'
interface LoginLogDetailDrawerProps {
open: boolean
log: LoginLog | null
onClose: () => void
}
export function LoginLogDetailDrawer({ open, log, onClose }: LoginLogDetailDrawerProps) {
if (!log) {
return null
}
return (
<Drawer title="登录日志详情" placement="right" width={640} open={open} onClose={onClose}>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="日志 ID">{log.id}</Descriptions.Item>
<Descriptions.Item label="用户 ID">{log.user_id ?? '-'}</Descriptions.Item>
<Descriptions.Item label="登录类型">
{LoginTypeText[log.login_type] || log.login_type}
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={LoginStatusColor[log.status]}>{LoginStatusText[log.status]}</Tag>
</Descriptions.Item>
<Descriptions.Item label="设备 ID">{log.device_id || '-'}</Descriptions.Item>
<Descriptions.Item label="登录 IP">{log.ip}</Descriptions.Item>
<Descriptions.Item label="地理位置">{log.location || '-'}</Descriptions.Item>
<Descriptions.Item label="失败原因">{log.fail_reason || '-'}</Descriptions.Item>
<Descriptions.Item label="登录时间">
{dayjs(log.created_at).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
</Descriptions>
</Drawer>
)
}

View File

@@ -0,0 +1,21 @@
.filterCard {
margin-bottom: 16px;
}
.tableCard {
margin-bottom: 16px;
}
.detailItem {
margin-bottom: 12px;
}
.detailLabel {
font-weight: 500;
color: var(--color-text);
margin-bottom: 4px;
}
.detailValue {
color: var(--color-text-secondary);
}

View File

@@ -0,0 +1,438 @@
import type { ReactNode } from 'react'
import { 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 type { LoginLog, LoginLogListParams, LoginLogListResponse } from '@/types/login-log'
import { LoginLogsPage } from './LoginLogsPage'
const listLoginLogsMock = vi.fn<(params?: LoginLogListParams) => Promise<LoginLogListResponse>>()
vi.mock('antd', async () => {
const React = await import('react')
function resolveRowKey<RecordType extends Record<string, unknown>>(
record: RecordType,
rowKey: string | ((row: RecordType) => string | number) | undefined,
index: number,
): string {
if (typeof rowKey === 'function') {
return String(rowKey(record))
}
if (typeof rowKey === 'string') {
return String(record[rowKey] ?? index)
}
return String(index)
}
function flattenChildren(children: ReactNode): string {
if (typeof children === 'string' || typeof children === 'number') {
return String(children)
}
if (Array.isArray(children)) {
return children.map(flattenChildren).join(' ').trim()
}
return ''
}
const RangePicker = ({
placeholder = ['start', 'end'],
onChange,
}: {
placeholder?: [string, string]
onChange?: (dates: null, dateStrings: [string, string]) => void
}) => {
const [start, setStart] = React.useState('')
const [end, setEnd] = React.useState('')
return (
<div>
<input
aria-label={placeholder[0]}
value={start}
onChange={(event) => {
const nextStart = event.target.value
setStart(nextStart)
onChange?.(null, [nextStart, end])
}}
/>
<input
aria-label={placeholder[1]}
value={end}
onChange={(event) => {
const nextEnd = event.target.value
setEnd(nextEnd)
onChange?.(null, [start, nextEnd])
}}
/>
</div>
)
}
return {
Button: ({
children,
onClick,
htmlType,
type: buttonType,
icon,
...props
}: {
children?: ReactNode
onClick?: () => void
htmlType?: 'button' | 'submit' | 'reset'
[key: string]: unknown
}) => {
void buttonType
void icon
return (
<button type={htmlType ?? 'button'} onClick={onClick} {...props}>
{children}
</button>
)
},
DatePicker: { RangePicker },
Input: ({
onPressEnter,
prefix,
allowClear,
...props
}: {
onPressEnter?: () => void
prefix?: ReactNode
allowClear?: boolean
[key: string]: unknown
}) => {
void prefix
void allowClear
return (
<input
{...props}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onPressEnter?.()
}
}}
/>
)
},
Select: ({
value,
onChange,
options = [],
placeholder,
allowClear,
...props
}: {
value?: string | number
onChange?: (value: unknown) => void
options?: Array<{ value: string | number, label: ReactNode }>
placeholder?: string
allowClear?: boolean
[key: string]: unknown
}) => {
void allowClear
return (
<select
aria-label={placeholder ?? 'select'}
value={value === undefined ? '' : String(value)}
onChange={(event) => {
const nextValue = event.target.value
if (nextValue === '') {
onChange?.(undefined)
return
}
const matchedOption = options.find((option) => String(option.value) === nextValue)
onChange?.(matchedOption?.value ?? nextValue)
}}
{...props}
>
<option value="">{placeholder ?? 'all'}</option>
{options.map((option) => (
<option key={String(option.value)} value={String(option.value)}>
{flattenChildren(option.label)}
</option>
))}
</select>
)
},
Space: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
Table: ({
columns,
dataSource,
rowKey,
locale,
pagination,
}: {
columns: Array<{
key?: string
title?: ReactNode
dataIndex?: string
render?: (value: unknown, record: Record<string, unknown>, index: number) => ReactNode
}>
dataSource?: Array<Record<string, unknown>>
rowKey?: string | ((row: Record<string, unknown>) => string | number)
locale?: { emptyText?: ReactNode }
pagination?: {
current?: number
pageSize?: number
total?: number
onChange?: (page: number, pageSize: number) => void
}
}) => {
const rows = dataSource ?? []
if (rows.length === 0) {
return <div>{locale?.emptyText ?? null}</div>
}
return (
<div>
<table>
<thead>
<tr>
{columns.map((column, index) => (
<th key={column.key ?? column.dataIndex ?? index}>{column.title}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((record, rowIndex) => (
<tr
key={resolveRowKey(record, rowKey, rowIndex)}
data-testid={`table-row-${resolveRowKey(record, rowKey, rowIndex)}`}
>
{columns.map((column, columnIndex) => {
const value = column.dataIndex ? record[column.dataIndex] : undefined
const content = column.render ? column.render(value, record, rowIndex) : value
return (
<td key={column.key ?? column.dataIndex ?? columnIndex}>
{content as ReactNode}
</td>
)
})}
</tr>
))}
</tbody>
</table>
<button
type="button"
onClick={() => pagination?.onChange?.(1, 50)}
>
paginate
</button>
<span>{`${pagination?.current ?? 1}-${pagination?.pageSize ?? 20}-${pagination?.total ?? rows.length}`}</span>
</div>
)
},
Tag: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
}
})
vi.mock('@ant-design/icons', () => ({
EyeOutlined: () => <span>eye</span>,
ReloadOutlined: () => <span>reload</span>,
SearchOutlined: () => <span>search</span>,
}))
vi.mock('@/components/common', () => ({
PageHeader: ({
title,
description,
actions,
}: {
title: ReactNode
description?: ReactNode
actions?: ReactNode
}) => (
<section data-testid="page-header">
<h1>{title}</h1>
<p>{description}</p>
{actions}
</section>
),
}))
vi.mock('@/components/feedback', () => ({
PageEmpty: ({ description }: { description?: ReactNode }) => <div>{description ?? 'empty'}</div>,
PageError: ({
description,
onRetry,
}: {
description?: ReactNode
onRetry?: () => void
}) => (
<div>
<p>{description}</p>
<button type="button" onClick={onRetry}>retry</button>
</div>
),
}))
vi.mock('@/components/layout', () => ({
PageLayout: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
FilterCard: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
TableCard: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/services/login-logs', () => ({
listLoginLogs: (params?: LoginLogListParams) => listLoginLogsMock(params),
}))
vi.mock('./LoginLogDetailDrawer', () => ({
LoginLogDetailDrawer: ({
open,
log,
onClose,
}: {
open: boolean
log: LoginLog | null
onClose: () => void
}) => (
open ? (
<div data-testid="login-log-detail-drawer">
<span>{`detail:${log?.id ?? 'none'}`}</span>
<button type="button" onClick={onClose}>close detail</button>
</div>
) : null
),
}))
function buildLog(id: number, userId: number, status: 0 | 1): LoginLog {
return {
id,
user_id: userId,
login_type: 1,
device_id: `device-${id}`,
ip: `10.0.0.${id}`,
location: 'Shanghai',
status,
fail_reason: status === 0 ? 'bad password' : undefined,
created_at: `2026-03-27 0${id}:00:00`,
}
}
describe('LoginLogsPage', () => {
let currentLogs: LoginLog[]
beforeEach(() => {
currentLogs = [
buildLog(1, 7, 1),
buildLog(2, 8, 0),
buildLog(3, 7, 0),
]
listLoginLogsMock.mockReset()
listLoginLogsMock.mockImplementation(async (params?: LoginLogListParams) => {
const page = params?.page ?? 1
const pageSize = params?.page_size ?? 20
let items = currentLogs
if (params?.user_id !== undefined) {
items = items.filter((log) => log.user_id === params.user_id)
}
if (params?.status !== undefined) {
items = items.filter((log) => log.status === params.status)
}
const start = (page - 1) * pageSize
const pagedItems = items.slice(start, start + pageSize)
return {
items: pagedItems.map((log) => ({ ...log })),
total: items.length,
page,
page_size: pageSize,
}
})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('loads, filters, resets, paginates, refreshes, and opens detail drawer', async () => {
const user = userEvent.setup()
render(<LoginLogsPage />)
expect(await screen.findByText('10.0.0.1')).toBeInTheDocument()
expect(screen.getByText('10.0.0.2')).toBeInTheDocument()
expect(listLoginLogsMock).toHaveBeenLastCalledWith(expect.objectContaining({
page: 1,
page_size: 20,
user_id: undefined,
status: undefined,
}))
const [refreshButton, searchButton, resetButton] = screen.getAllByRole('button').slice(0, 3)
const [userIdInput] = screen.getAllByRole('textbox')
const statusSelect = screen.getByRole('combobox')
await user.clear(userIdInput)
await user.type(userIdInput, '7abc')
await user.selectOptions(statusSelect, '0')
await user.click(searchButton)
await waitFor(() => expect(screen.queryByText('10.0.0.1')).not.toBeInTheDocument())
expect(screen.getByText('10.0.0.3')).toBeInTheDocument()
expect(listLoginLogsMock).toHaveBeenLastCalledWith(expect.objectContaining({
user_id: 7,
status: 0,
}))
await user.click(resetButton)
await waitFor(() => expect(screen.getByText('10.0.0.1')).toBeInTheDocument())
expect(screen.getByText('10.0.0.2')).toBeInTheDocument()
expect(listLoginLogsMock).toHaveBeenLastCalledWith(expect.objectContaining({
user_id: undefined,
status: undefined,
}))
const callCountBeforeRefresh = listLoginLogsMock.mock.calls.length
await user.click(refreshButton)
await waitFor(() => expect(listLoginLogsMock.mock.calls.length).toBeGreaterThan(callCountBeforeRefresh))
await user.click(screen.getByRole('button', { name: 'paginate' }))
await waitFor(() => expect(listLoginLogsMock).toHaveBeenLastCalledWith(expect.objectContaining({
page: 1,
page_size: 50,
})))
const firstRow = screen.getByTestId('table-row-1')
await user.click(within(firstRow).getByRole('button'))
expect(screen.getByTestId('login-log-detail-drawer')).toHaveTextContent('detail:1')
await user.click(screen.getByRole('button', { name: 'close detail' }))
await waitFor(() => expect(screen.queryByTestId('login-log-detail-drawer')).not.toBeInTheDocument())
})
it('renders the page error state and retries fetching', async () => {
const user = userEvent.setup()
listLoginLogsMock.mockReset()
listLoginLogsMock.mockRejectedValueOnce(new Error('login logs failed'))
listLoginLogsMock.mockResolvedValue({
items: currentLogs.map((log) => ({ ...log })),
total: currentLogs.length,
page: 1,
page_size: 20,
})
render(<LoginLogsPage />)
expect(await screen.findByText('login logs failed')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'retry' }))
await waitFor(() => expect(screen.getByText('10.0.0.1')).toBeInTheDocument())
expect(listLoginLogsMock).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,272 @@
import { useCallback, useEffect, useState } from 'react'
import {
Button,
DatePicker,
Input,
message,
Select,
Space,
Table,
Tag,
type TableColumnsType,
type TablePaginationConfig,
} from 'antd'
import { DownloadOutlined, EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'
import dayjs from 'dayjs'
import { PageHeader } from '@/components/common'
import { PageEmpty, PageError } from '@/components/feedback'
import { PageLayout, FilterCard, TableCard } from '@/components/layout'
import { getErrorMessage } from '@/lib/errors'
import { exportLoginLogs, listLoginLogs } from '@/services/login-logs'
import {
LoginStatusColor,
LoginStatusText,
LoginTypeText,
type LoginLog,
type LoginLogListParams,
type LoginStatus,
} from '@/types/login-log'
import { LoginLogDetailDrawer } from './LoginLogDetailDrawer'
const { RangePicker } = DatePicker
export function LoginLogsPage() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [logs, setLogs] = useState<LoginLog[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [userId, setUserId] = useState('')
const [statusFilter, setStatusFilter] = useState<LoginStatus | undefined>()
const [startAt, setStartAt] = useState<string | undefined>()
const [endAt, setEndAt] = useState<string | undefined>()
const [detailVisible, setDetailVisible] = useState(false)
const [selectedLog, setSelectedLog] = useState<LoginLog | null>(null)
const fetchLogs = useCallback(async () => {
setLoading(true)
setError(null)
try {
const params: LoginLogListParams = {
page,
page_size: pageSize,
user_id: userId ? Number(userId) : undefined,
status: statusFilter,
start_at: startAt,
end_at: endAt,
}
const result = await listLoginLogs(params)
setLogs(result.items)
setTotal(result.total)
} catch (err) {
setError(getErrorMessage(err, '获取登录日志失败'))
} finally {
setLoading(false)
}
}, [endAt, page, pageSize, startAt, statusFilter, userId])
useEffect(() => {
void fetchLogs()
}, [fetchLogs])
// 筛选条件变化时重置到第一页
useEffect(() => {
setPage(1)
}, [userId, statusFilter, startAt, endAt])
const handleReset = () => {
setUserId('')
setStatusFilter(undefined)
setStartAt(undefined)
setEndAt(undefined)
setPage(1)
}
const handleExport = async () => {
try {
await exportLoginLogs({
user_id: userId ? Number(userId) : undefined,
status: statusFilter,
format: 'csv',
start_at: startAt,
end_at: endAt,
})
message.success('导出成功')
} catch (err) {
message.error(getErrorMessage(err, '导出失败'))
}
}
const columns: TableColumnsType<LoginLog> = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
},
{
title: '用户 ID',
dataIndex: 'user_id',
key: 'user_id',
width: 100,
render: (value) => value ?? '-',
},
{
title: '登录类型',
dataIndex: 'login_type',
key: 'login_type',
width: 140,
render: (value: LoginLog['login_type']) => LoginTypeText[value] || value,
},
{
title: '设备 ID',
dataIndex: 'device_id',
key: 'device_id',
width: 180,
render: (value) => value || '-',
},
{
title: 'IP',
dataIndex: 'ip',
key: 'ip',
width: 140,
},
{
title: '位置',
dataIndex: 'location',
key: 'location',
width: 180,
render: (value) => value || '-',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: LoginLog['status']) => (
<Tag color={LoginStatusColor[status]}>{LoginStatusText[status]}</Tag>
),
},
{
title: '失败原因',
dataIndex: 'fail_reason',
key: 'fail_reason',
width: 220,
render: (value) => value || '-',
},
{
title: '登录时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180,
render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: '操作',
key: 'action',
width: 100,
fixed: 'right',
render: (_, record) => (
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => {
setSelectedLog(record)
setDetailVisible(true)
}}>
</Button>
),
},
]
const paginationConfig: TablePaginationConfig = {
current: page,
pageSize,
total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (value) => `${value}`,
onChange: (current, size) => {
setPage(current)
setPageSize(size)
},
}
if (error) {
return <PageError description={error} onRetry={fetchLogs} />
}
return (
<PageLayout>
<PageHeader
title="登录日志"
description="对齐后端 `/logs/login` 的真实查询模型,只保留稳定支持的筛选项"
actions={(
<Space>
<Button icon={<DownloadOutlined />} onClick={() => void handleExport()}>
</Button>
<Button icon={<ReloadOutlined />} onClick={() => void fetchLogs()}>
</Button>
</Space>
)}
/>
<FilterCard>
<Space wrap size="middle">
<Input
placeholder="用户 ID"
prefix={<SearchOutlined />}
value={userId}
onChange={(event) => setUserId(event.target.value.replace(/[^\d]/g, ''))}
onPressEnter={() => void fetchLogs()}
style={{ width: 160 }}
allowClear
/>
<Select
placeholder="登录状态"
value={statusFilter}
onChange={setStatusFilter}
allowClear
style={{ width: 120 }}
options={[
{ value: 1, label: '成功' },
{ value: 0, label: '失败' },
]}
/>
<RangePicker
placeholder={['开始时间', '结束时间']}
showTime
onChange={(_, dateStrings) => {
setStartAt(dateStrings[0] || undefined)
setEndAt(dateStrings[1] || undefined)
}}
/>
<Button type="primary" icon={<SearchOutlined />} onClick={() => void fetchLogs()}>
</Button>
<Button onClick={handleReset}></Button>
</Space>
</FilterCard>
<TableCard>
<Table
columns={columns}
dataSource={logs}
rowKey="id"
loading={loading}
pagination={paginationConfig}
scroll={{ x: 1380 }}
locale={{ emptyText: <PageEmpty description="暂无登录日志" /> }}
/>
</TableCard>
<LoginLogDetailDrawer
open={detailVisible}
log={selectedLog}
onClose={() => setDetailVisible(false)}
/>
</PageLayout>
)
}

View File

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

View File

@@ -0,0 +1,60 @@
import { Descriptions, Drawer, Tag, Typography } from 'antd'
import dayjs from 'dayjs'
import type { OperationLog } from '@/types/operation-log'
import styles from './OperationLogsPage.module.css'
const { Paragraph, Text } = Typography
interface OperationLogDetailDrawerProps {
open: boolean
log: OperationLog | null
onClose: () => void
}
// Strip HTML tags to prevent XSS when rendering user-controlled fields
function stripHtmlTags(text: string | undefined | null): string {
if (!text) return '-'
return text.replace(/<[^>]*>/g, '')
}
export function OperationLogDetailDrawer({ open, log, onClose }: OperationLogDetailDrawerProps) {
if (!log) {
return null
}
return (
<Drawer title="操作日志详情" placement="right" width={720} open={open} onClose={onClose}>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="日志 ID">{log.id}</Descriptions.Item>
<Descriptions.Item label="用户 ID">{log.user_id ?? '-'}</Descriptions.Item>
<Descriptions.Item label="操作类型">{log.operation_type || '-'}</Descriptions.Item>
<Descriptions.Item label="操作名称">{log.operation_name || '-'}</Descriptions.Item>
<Descriptions.Item label="请求方法">
<Tag>{log.request_method}</Tag>
</Descriptions.Item>
<Descriptions.Item label="请求路径">
<Paragraph copyable style={{ marginBottom: 0 }}>
<Text>{stripHtmlTags(log.request_path)}</Text>
</Paragraph>
</Descriptions.Item>
<Descriptions.Item label="请求参数">
<pre className={styles.codeBlock}><Text>{stripHtmlTags(log.request_params)}</Text></pre>
</Descriptions.Item>
<Descriptions.Item label="状态码">
<Tag color={log.response_status >= 200 && log.response_status < 300 ? 'success' : 'error'}>
{log.response_status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="IP 地址">{log.ip}</Descriptions.Item>
<Descriptions.Item label="User Agent">
<div style={{ wordBreak: 'break-all', fontSize: 12 }}>
<Text>{stripHtmlTags(log.user_agent)}</Text>
</div>
</Descriptions.Item>
<Descriptions.Item label="操作时间">
{dayjs(log.created_at).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
</Descriptions>
</Drawer>
)
}

View File

@@ -0,0 +1,17 @@
.filterCard {
margin-bottom: 16px;
}
.tableCard {
margin-bottom: 16px;
}
.codeBlock {
background: #f5f5f5;
padding: 8px 12px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 12px;
max-height: 200px;
overflow: auto;
}

View File

@@ -0,0 +1,451 @@
import type { ReactNode } from 'react'
import { 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 type { OperationLog, OperationLogListParams, OperationLogListResponse } from '@/types/operation-log'
import { OperationLogsPage } from './OperationLogsPage'
const listOperationLogsMock = vi.fn<(params?: OperationLogListParams) => Promise<OperationLogListResponse>>()
vi.mock('antd', async () => {
const React = await import('react')
function resolveRowKey<RecordType extends Record<string, unknown>>(
record: RecordType,
rowKey: string | ((row: RecordType) => string | number) | undefined,
index: number,
): string {
if (typeof rowKey === 'function') {
return String(rowKey(record))
}
if (typeof rowKey === 'string') {
return String(record[rowKey] ?? index)
}
return String(index)
}
function flattenChildren(children: ReactNode): string {
if (typeof children === 'string' || typeof children === 'number') {
return String(children)
}
if (Array.isArray(children)) {
return children.map(flattenChildren).join(' ').trim()
}
return ''
}
const RangePicker = ({
placeholder = ['start', 'end'],
onChange,
}: {
placeholder?: [string, string]
onChange?: (dates: null, dateStrings: [string, string]) => void
}) => {
const [start, setStart] = React.useState('')
const [end, setEnd] = React.useState('')
return (
<div>
<input
aria-label={placeholder[0]}
value={start}
onChange={(event) => {
const nextStart = event.target.value
setStart(nextStart)
onChange?.(null, [nextStart, end])
}}
/>
<input
aria-label={placeholder[1]}
value={end}
onChange={(event) => {
const nextEnd = event.target.value
setEnd(nextEnd)
onChange?.(null, [start, nextEnd])
}}
/>
</div>
)
}
return {
Button: ({
children,
onClick,
htmlType,
type: buttonType,
icon,
...props
}: {
children?: ReactNode
onClick?: () => void
htmlType?: 'button' | 'submit' | 'reset'
[key: string]: unknown
}) => {
void buttonType
void icon
return (
<button type={htmlType ?? 'button'} onClick={onClick} {...props}>
{children}
</button>
)
},
DatePicker: { RangePicker },
Input: ({
onPressEnter,
prefix,
allowClear,
...props
}: {
onPressEnter?: () => void
prefix?: ReactNode
allowClear?: boolean
[key: string]: unknown
}) => {
void prefix
void allowClear
return (
<input
{...props}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onPressEnter?.()
}
}}
/>
)
},
Select: ({
value,
onChange,
options = [],
placeholder,
allowClear,
...props
}: {
value?: string | number
onChange?: (value: unknown) => void
options?: Array<{ value: string | number, label: ReactNode }>
placeholder?: string
allowClear?: boolean
[key: string]: unknown
}) => {
void allowClear
return (
<select
aria-label={placeholder ?? 'select'}
value={value === undefined ? '' : String(value)}
onChange={(event) => {
const nextValue = event.target.value
if (nextValue === '') {
onChange?.(undefined)
return
}
const matchedOption = options.find((option) => String(option.value) === nextValue)
onChange?.(matchedOption?.value ?? nextValue)
}}
{...props}
>
<option value="">{placeholder ?? 'all'}</option>
{options.map((option) => (
<option key={String(option.value)} value={String(option.value)}>
{flattenChildren(option.label)}
</option>
))}
</select>
)
},
Space: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
Table: ({
columns,
dataSource,
rowKey,
locale,
pagination,
}: {
columns: Array<{
key?: string
title?: ReactNode
dataIndex?: string
render?: (value: unknown, record: Record<string, unknown>, index: number) => ReactNode
}>
dataSource?: Array<Record<string, unknown>>
rowKey?: string | ((row: Record<string, unknown>) => string | number)
locale?: { emptyText?: ReactNode }
pagination?: {
current?: number
pageSize?: number
total?: number
onChange?: (page: number, pageSize: number) => void
}
}) => {
const rows = dataSource ?? []
if (rows.length === 0) {
return <div>{locale?.emptyText ?? null}</div>
}
return (
<div>
<table>
<thead>
<tr>
{columns.map((column, index) => (
<th key={column.key ?? column.dataIndex ?? index}>{column.title}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((record, rowIndex) => (
<tr
key={resolveRowKey(record, rowKey, rowIndex)}
data-testid={`table-row-${resolveRowKey(record, rowKey, rowIndex)}`}
>
{columns.map((column, columnIndex) => {
const value = column.dataIndex ? record[column.dataIndex] : undefined
const content = column.render ? column.render(value, record, rowIndex) : value
return (
<td key={column.key ?? column.dataIndex ?? columnIndex}>
{content as ReactNode}
</td>
)
})}
</tr>
))}
</tbody>
</table>
<button
type="button"
onClick={() => pagination?.onChange?.(1, 50)}
>
paginate
</button>
<span>{`${pagination?.current ?? 1}-${pagination?.pageSize ?? 20}-${pagination?.total ?? rows.length}`}</span>
</div>
)
},
Tag: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
}
})
vi.mock('@ant-design/icons', () => ({
EyeOutlined: () => <span>eye</span>,
ReloadOutlined: () => <span>reload</span>,
SearchOutlined: () => <span>search</span>,
}))
vi.mock('@/components/common', () => ({
PageHeader: ({
title,
description,
actions,
}: {
title: ReactNode
description?: ReactNode
actions?: ReactNode
}) => (
<section data-testid="page-header">
<h1>{title}</h1>
<p>{description}</p>
{actions}
</section>
),
}))
vi.mock('@/components/feedback', () => ({
PageEmpty: ({ description }: { description?: ReactNode }) => <div>{description ?? 'empty'}</div>,
PageError: ({
description,
onRetry,
}: {
description?: ReactNode
onRetry?: () => void
}) => (
<div>
<p>{description}</p>
<button type="button" onClick={onRetry}>retry</button>
</div>
),
}))
vi.mock('@/components/layout', () => ({
PageLayout: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
FilterCard: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
TableCard: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/services/operation-logs', () => ({
listOperationLogs: (params?: OperationLogListParams) => listOperationLogsMock(params),
}))
vi.mock('./OperationLogDetailDrawer', () => ({
OperationLogDetailDrawer: ({
open,
log,
onClose,
}: {
open: boolean
log: OperationLog | null
onClose: () => void
}) => (
open ? (
<div data-testid="operation-log-detail-drawer">
<span>{`detail:${log?.id ?? 'none'}`}</span>
<button type="button" onClick={onClose}>close detail</button>
</div>
) : null
),
}))
function buildLog(id: number, userId: number, requestMethod: string, operationName: string): OperationLog {
return {
id,
user_id: userId,
operation_type: 'user',
operation_name: operationName,
request_method: requestMethod,
request_path: `/api/v1/logs/${id}`,
request_params: `{"id":${id}}`,
response_status: requestMethod === 'GET' ? 200 : 500,
ip: `10.0.1.${id}`,
user_agent: 'Chrome',
created_at: `2026-03-27 1${id}:00:00`,
}
}
describe('OperationLogsPage', () => {
let currentLogs: OperationLog[]
beforeEach(() => {
currentLogs = [
buildLog(1, 9, 'GET', 'fetch users'),
buildLog(2, 9, 'POST', 'create user'),
buildLog(3, 12, 'DELETE', 'delete user'),
]
listOperationLogsMock.mockReset()
listOperationLogsMock.mockImplementation(async (params?: OperationLogListParams) => {
const page = params?.page ?? 1
const pageSize = params?.page_size ?? 20
let items = currentLogs
if (params?.user_id !== undefined) {
items = items.filter((log) => log.user_id === params.user_id)
}
if (params?.method) {
items = items.filter((log) => log.request_method === params.method)
}
if (params?.keyword) {
const keyword = params.keyword.toLowerCase()
items = items.filter((log) => (
log.operation_name.toLowerCase().includes(keyword) ||
log.request_path.toLowerCase().includes(keyword)
))
}
const start = (page - 1) * pageSize
const pagedItems = items.slice(start, start + pageSize)
return {
items: pagedItems.map((log) => ({ ...log })),
total: items.length,
page,
page_size: pageSize,
}
})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('loads, filters, resets, paginates, refreshes, and opens detail drawer', async () => {
const user = userEvent.setup()
render(<OperationLogsPage />)
expect(await screen.findByText('fetch users')).toBeInTheDocument()
expect(screen.getByText('create user')).toBeInTheDocument()
expect(listOperationLogsMock).toHaveBeenLastCalledWith(expect.objectContaining({
page: 1,
page_size: 20,
user_id: undefined,
method: undefined,
keyword: undefined,
}))
const [refreshButton, searchButton, resetButton] = screen.getAllByRole('button').slice(0, 3)
const [userIdInput, keywordInput] = screen.getAllByRole('textbox')
const methodSelect = screen.getByRole('combobox')
await user.type(userIdInput, '9')
await user.selectOptions(methodSelect, 'POST')
await user.type(keywordInput, 'create')
await user.click(searchButton)
await waitFor(() => expect(screen.queryByText('fetch users')).not.toBeInTheDocument())
expect(screen.getByText('create user')).toBeInTheDocument()
expect(listOperationLogsMock).toHaveBeenLastCalledWith(expect.objectContaining({
user_id: 9,
method: 'POST',
keyword: 'create',
}))
await user.click(resetButton)
await waitFor(() => expect(screen.getByText('fetch users')).toBeInTheDocument())
expect(screen.getByText('delete user')).toBeInTheDocument()
expect(listOperationLogsMock).toHaveBeenLastCalledWith(expect.objectContaining({
user_id: undefined,
method: undefined,
keyword: undefined,
}))
const callCountBeforeRefresh = listOperationLogsMock.mock.calls.length
await user.click(refreshButton)
await waitFor(() => expect(listOperationLogsMock.mock.calls.length).toBeGreaterThan(callCountBeforeRefresh))
await user.click(screen.getByRole('button', { name: 'paginate' }))
await waitFor(() => expect(listOperationLogsMock).toHaveBeenLastCalledWith(expect.objectContaining({
page: 1,
page_size: 50,
})))
const firstRow = screen.getByTestId('table-row-1')
await user.click(within(firstRow).getByRole('button'))
expect(screen.getByTestId('operation-log-detail-drawer')).toHaveTextContent('detail:1')
await user.click(screen.getByRole('button', { name: 'close detail' }))
await waitFor(() => expect(screen.queryByTestId('operation-log-detail-drawer')).not.toBeInTheDocument())
})
it('renders the page error state and retries fetching', async () => {
const user = userEvent.setup()
listOperationLogsMock.mockReset()
listOperationLogsMock.mockRejectedValueOnce(new Error('operation logs failed'))
listOperationLogsMock.mockResolvedValue({
items: currentLogs.map((log) => ({ ...log })),
total: currentLogs.length,
page: 1,
page_size: 20,
})
render(<OperationLogsPage />)
expect(await screen.findByText('operation logs failed')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'retry' }))
await waitFor(() => expect(screen.getByText('fetch users')).toBeInTheDocument())
expect(listOperationLogsMock).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,256 @@
import { useCallback, useEffect, useState } from 'react'
import {
Button,
DatePicker,
Input,
Select,
Space,
Table,
Tag,
type TableColumnsType,
type TablePaginationConfig,
} from 'antd'
import { EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'
import dayjs from 'dayjs'
import { PageHeader } from '@/components/common'
import { PageEmpty, PageError } from '@/components/feedback'
import { PageLayout, FilterCard, TableCard } from '@/components/layout'
import { getErrorMessage } from '@/lib/errors'
import { listOperationLogs } from '@/services/operation-logs'
import type { OperationLog, OperationLogListParams } from '@/types/operation-log'
import { OperationLogDetailDrawer } from './OperationLogDetailDrawer'
const { RangePicker } = DatePicker
export function OperationLogsPage() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [logs, setLogs] = useState<OperationLog[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [userId, setUserId] = useState('')
const [method, setMethod] = useState<string | undefined>()
const [keyword, setKeyword] = useState('')
const [startAt, setStartAt] = useState<string | undefined>()
const [endAt, setEndAt] = useState<string | undefined>()
const [detailVisible, setDetailVisible] = useState(false)
const [selectedLog, setSelectedLog] = useState<OperationLog | null>(null)
const fetchLogs = useCallback(async () => {
setLoading(true)
setError(null)
try {
const params: OperationLogListParams = {
page,
page_size: pageSize,
user_id: userId ? Number(userId) : undefined,
method,
keyword: keyword || undefined,
start_at: startAt,
end_at: endAt,
}
const result = await listOperationLogs(params)
setLogs(result.items)
setTotal(result.total)
} catch (err) {
setError(getErrorMessage(err, '获取操作日志失败'))
} finally {
setLoading(false)
}
}, [endAt, keyword, method, page, pageSize, startAt, userId])
useEffect(() => {
void fetchLogs()
}, [fetchLogs])
// 筛选条件变化时重置到第一页
useEffect(() => {
setPage(1)
}, [userId, method, keyword, startAt, endAt])
const handleReset = () => {
setUserId('')
setMethod(undefined)
setKeyword('')
setStartAt(undefined)
setEndAt(undefined)
setPage(1)
}
const columns: TableColumnsType<OperationLog> = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
},
{
title: '用户 ID',
dataIndex: 'user_id',
key: 'user_id',
width: 100,
render: (value) => value ?? '-',
},
{
title: '操作类型',
dataIndex: 'operation_type',
key: 'operation_type',
width: 140,
},
{
title: '操作名称',
dataIndex: 'operation_name',
key: 'operation_name',
width: 180,
},
{
title: '请求方法',
dataIndex: 'request_method',
key: 'request_method',
width: 100,
render: (value) => <Tag>{value}</Tag>,
},
{
title: '请求路径',
dataIndex: 'request_path',
key: 'request_path',
width: 260,
ellipsis: true,
},
{
title: '状态码',
dataIndex: 'response_status',
key: 'response_status',
width: 100,
render: (value) => (
<Tag color={value >= 200 && value < 300 ? 'success' : 'error'}>{value}</Tag>
),
},
{
title: 'IP',
dataIndex: 'ip',
key: 'ip',
width: 140,
},
{
title: '操作时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180,
render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: '操作',
key: 'action',
width: 100,
fixed: 'right',
render: (_, record) => (
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => {
setSelectedLog(record)
setDetailVisible(true)
}}>
</Button>
),
},
]
const paginationConfig: TablePaginationConfig = {
current: page,
pageSize,
total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (value) => `${value}`,
onChange: (current, size) => {
setPage(current)
setPageSize(size)
},
}
if (error) {
return <PageError description={error} onRetry={fetchLogs} />
}
return (
<PageLayout>
<PageHeader
title="操作日志"
description="对齐后端 `/logs/operation` 的真实筛选参数和字段模型"
actions={(
<Button icon={<ReloadOutlined />} onClick={() => void fetchLogs()}>
</Button>
)}
/>
<FilterCard>
<Space wrap size="middle">
<Input
placeholder="用户 ID"
prefix={<SearchOutlined />}
value={userId}
onChange={(event) => setUserId(event.target.value.replace(/[^\d]/g, ''))}
onPressEnter={() => void fetchLogs()}
style={{ width: 160 }}
allowClear
/>
<Select
placeholder="请求方法"
value={method}
onChange={setMethod}
allowClear
style={{ width: 120 }}
options={[
{ value: 'GET', label: 'GET' },
{ value: 'POST', label: 'POST' },
{ value: 'PUT', label: 'PUT' },
{ value: 'DELETE', label: 'DELETE' },
{ value: 'PATCH', label: 'PATCH' },
]}
/>
<Input
placeholder="关键字"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
onPressEnter={() => void fetchLogs()}
style={{ width: 200 }}
allowClear
/>
<RangePicker
placeholder={['开始时间', '结束时间']}
showTime
onChange={(_, dateStrings) => {
setStartAt(dateStrings[0] || undefined)
setEndAt(dateStrings[1] || undefined)
}}
/>
<Button type="primary" icon={<SearchOutlined />} onClick={() => void fetchLogs()}>
</Button>
<Button onClick={handleReset}></Button>
</Space>
</FilterCard>
<TableCard>
<Table
columns={columns}
dataSource={logs}
rowKey="id"
loading={loading}
pagination={paginationConfig}
scroll={{ x: 1460 }}
locale={{ emptyText: <PageEmpty description="暂无操作日志" /> }}
/>
</TableCard>
<OperationLogDetailDrawer
open={detailVisible}
log={selectedLog}
onClose={() => setDetailVisible(false)}
/>
</PageLayout>
)
}

Some files were not shown because too many files have changed in this diff Show More