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