refactor: 整理项目根目录结构

整理内容:
- 删除 60+ 临时测试输出文件 (*.txt)
- 移动二进制文件到 bin/ 目录
- 移动 Shell 脚本到 scripts/ 目录
  - scripts/dev/: check_gitea.sh, check_sub2api.sh, run_tests.sh
  - scripts/deploy/: deploy_*.sh, simple_deploy.sh
  - scripts/ops/: fix_nginx.sh, fix_ssl.sh, install_docker.sh
  - scripts/test/: test_*.sh, test_*.bat
- 移动批处理文件到 scripts/
- 移动 Python 脚本到 tools/
- 清理临时日志文件

保留根目录必要文件:
- go.mod, go.sum, go.work
- Makefile, docker-compose.yml
- .env.example, .gitignore
- README.md, AGENTS.md, DEPLOY_GUIDE.md

验证: go build ./... && go test ./... 通过
This commit is contained in:
2026-04-07 18:10:36 +08:00
parent 5dbb530b76
commit 5b6bd93179
152 changed files with 8775 additions and 4084 deletions

View File

@@ -0,0 +1,610 @@
/**
* UI Consistency Tests
*
* Tests for UI component consistency, form validation,
* loading/error states, and responsive behavior
*/
import type { ReactNode } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
// =============================================================================
// UI Component Consistency Tests
// =============================================================================
describe('PageHeader Component', () => {
const mockBreadcrumb = [
{ title: '首页', path: '/' },
{ title: '用户管理', path: '/users' },
{ title: '用户列表' },
]
it('renders title correctly', () => {
const { getByText } = render(
<PageHeaderTest title="用户列表" />
)
expect(getByText('用户列表')).toBeInTheDocument()
})
it('renders description when provided', () => {
const { getByText } = render(
<PageHeaderTest
title="用户列表"
description="管理系统中的所有用户"
/>
)
expect(getByText('用户列表')).toBeInTheDocument()
expect(getByText('管理系统中的所有用户')).toBeInTheDocument()
})
it('renders breadcrumb when provided', () => {
const { getByTestId } = render(
<PageHeaderTest
title="用户列表"
breadcrumb={mockBreadcrumb}
/>
)
// Breadcrumb renders with text content (may have HTML encoding)
const breadcrumb = getByTestId('breadcrumb')
expect(breadcrumb).toHaveTextContent('首页')
expect(breadcrumb).toHaveTextContent('用户管理')
expect(breadcrumb).toHaveTextContent('用户列表')
})
it('renders actions when provided', () => {
const { getByText } = render(
<PageHeaderTest
title="用户列表"
actions={<button></button>}
/>
)
expect(getByText('新建用户')).toBeInTheDocument()
})
it('does not render footer when not provided', () => {
const { queryByText } = render(
<PageHeaderTest title="用户列表" />
)
expect(queryByText('footer')).not.toBeInTheDocument()
})
it('renders footer when provided', () => {
const { getByText } = render(
<PageHeaderTest
title="用户列表"
footer={<div></div>}
/>
)
expect(getByText('页脚内容')).toBeInTheDocument()
})
})
// =============================================================================
// Form Validation Consistency Tests
// =============================================================================
describe('Form Validation Consistency', () => {
it('validates required fields', async () => {
const user = userEvent.setup()
const handleSubmit = vi.fn()
const TestForm = ({ onSubmit }: { onSubmit: (data: Record<string, string>) => void }) => (
<form onSubmit={(e) => {
e.preventDefault()
const formData = new FormData(e.target as HTMLFormElement)
const data: Record<string, string> = {}
formData.forEach((value, key) => {
if (typeof value === 'string') data[key] = value
})
// Check required fields
if (!data.username || !data.email) return
onSubmit(data)
}}>
<input name="username" required aria-label="用户名" />
<input name="email" type="email" required aria-label="邮箱" />
<button type="submit"></button>
</form>
)
render(<TestForm onSubmit={handleSubmit} />)
await user.click(screen.getByRole('button', { name: '提交' }))
expect(handleSubmit).not.toHaveBeenCalled()
})
it('validates email format', async () => {
const user = userEvent.setup()
const handleSubmit = vi.fn()
const TestForm = ({ onSubmit }: { onSubmit: (data: Record<string, string>) => void }) => (
<form onSubmit={(e) => {
e.preventDefault()
const formData = new FormData(e.target as HTMLFormElement)
const data: Record<string, string> = {}
formData.forEach((value, key) => {
if (typeof value === 'string') data[key] = value
})
// Simple email validation
if (!data.email || !data.email.includes('@')) return
onSubmit(data)
}}>
<input name="email" type="email" aria-label="邮箱" />
<button type="submit"></button>
</form>
)
render(<TestForm onSubmit={handleSubmit} />)
const emailInput = screen.getByRole('textbox', { name: '邮箱' })
await user.clear(emailInput)
await user.type(emailInput, 'invalid-email')
await user.click(screen.getByRole('button', { name: '提交' }))
expect(handleSubmit).not.toHaveBeenCalled()
})
it('validates password strength', async () => {
const user = userEvent.setup()
const handleSubmit = vi.fn()
const TestPasswordForm = ({ onSubmit }: { onSubmit: (data: Record<string, string>) => void }) => {
const validatePassword = (pwd: string) => {
if (pwd.length < 8) return '密码长度不能少于8位'
if (!/[A-Z]/.test(pwd)) return '密码必须包含大写字母'
if (!/[a-z]/.test(pwd)) return '密码必须包含小写字母'
if (!/[0-9]/.test(pwd)) return '密码必须包含数字'
return null
}
return (
<form onSubmit={(e) => {
e.preventDefault()
const formData = new FormData(e.target as HTMLFormElement)
const password = formData.get('password') as string
const error = validatePassword(password)
if (error) {
alert(error)
return
}
onSubmit({ password })
}}>
<label htmlFor="password-input"></label>
<input id="password-input" name="password" type="password" />
<button type="submit"></button>
</form>
)
}
render(<TestPasswordForm onSubmit={handleSubmit} />)
const pwdInput = screen.getByLabelText('密码')
await user.clear(pwdInput)
await user.type(pwdInput, 'weak')
await user.click(screen.getByRole('button', { name: '提交' }))
expect(handleSubmit).not.toHaveBeenCalled()
})
it('confirms password match', async () => {
const user = userEvent.setup()
const handleSubmit = vi.fn()
const TestConfirmForm = ({ onSubmit }: { onSubmit: () => void }) => (
<form onSubmit={(e) => {
e.preventDefault()
const formData = new FormData(e.target as HTMLFormElement)
const password = formData.get('password') as string
const confirm = formData.get('confirm') as string
if (password !== confirm) {
alert('两次输入的密码不一致')
return
}
onSubmit()
}}>
<label htmlFor="password-input"></label>
<input id="password-input" name="password" type="password" />
<label htmlFor="confirm-input"></label>
<input id="confirm-input" name="confirm" type="password" />
<button type="submit"></button>
</form>
)
render(<TestConfirmForm onSubmit={handleSubmit} />)
await user.type(screen.getByLabelText('密码'), 'Pass123!')
await user.type(screen.getByLabelText('确认密码'), 'Different123!')
await user.click(screen.getByRole('button', { name: '提交' }))
expect(handleSubmit).not.toHaveBeenCalled()
})
})
// =============================================================================
// Loading/Error/Empty States Tests
// =============================================================================
describe('Loading States', () => {
it('shows loading indicator during data fetch', async () => {
const TestLoadingComponent = ({ isLoading }: { isLoading: boolean }) => (
<div>
{isLoading ? <div data-testid="loading">...</div> : <div></div>}
</div>
)
const { getByTestId, rerender } = render(<TestLoadingComponent isLoading={true} />)
expect(getByTestId('loading')).toBeInTheDocument()
expect(getByTestId('loading')).toHaveTextContent('加载中...')
rerender(<TestLoadingComponent isLoading={false} />)
await waitFor(() => {
expect(screen.queryByTestId('loading')).not.toBeInTheDocument()
})
})
it('disables buttons during submission', () => {
const TestSubmitButton = ({ isSubmitting }: { isSubmitting: boolean }) => (
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '提交中...' : '提交'}
</button>
)
const { getByRole, rerender } = render(<TestSubmitButton isSubmitting={false} />)
expect(getByRole('button')).not.toBeDisabled()
rerender(<TestSubmitButton isSubmitting={true} />)
expect(getByRole('button')).toBeDisabled()
})
})
describe('Error States', () => {
it('displays error message when fetch fails', () => {
const TestErrorDisplay = ({ error }: { error: string | null }) => (
<div>
{error && <div data-testid="error-message">{error}</div>}
</div>
)
const { getByTestId, rerender } = render(<TestErrorDisplay error={null} />)
expect(screen.queryByTestId('error-message')).not.toBeInTheDocument()
rerender(<TestErrorDisplay error="网络错误,请稍后重试" />)
expect(getByTestId('error-message')).toHaveTextContent('网络错误,请稍后重试')
})
it('shows retry option after error', () => {
const handleRetry = vi.fn()
const TestRetryButton = ({ onRetry }: { onRetry: () => void }) => (
<div>
<div></div>
<button onClick={onRetry}></button>
</div>
)
render(<TestRetryButton onRetry={handleRetry} />)
expect(screen.getByRole('button', { name: '重试' })).toBeInTheDocument()
})
})
describe('Empty States', () => {
it('displays empty message when no data', () => {
const TestEmptyDisplay = ({ items }: { items: unknown[] }) => (
<div>
{items.length === 0 ? (
<div data-testid="empty"></div>
) : (
<div>{items.length} </div>
)}
</div>
)
const { getByTestId } = render(<TestEmptyDisplay items={[]} />)
expect(getByTestId('empty')).toHaveTextContent('暂无数据')
})
it('shows add action in empty state', () => {
const TestEmptyStateWithAction = ({ onAdd }: { onAdd: () => void }) => (
<div>
<div></div>
<button onClick={onAdd}></button>
</div>
)
const handleAdd = vi.fn()
render(<TestEmptyStateWithAction onAdd={handleAdd} />)
expect(screen.getByRole('button', { name: '添加第一条数据' })).toBeInTheDocument()
})
})
// =============================================================================
// Responsive Behavior Tests
// =============================================================================
describe('Responsive Behavior', () => {
it('hides secondary content on small screens', () => {
const TestResponsiveLayout = ({ isMobile }: { isMobile: boolean }) => (
<div>
<div data-testid="primary"></div>
{isMobile ? null : <div data-testid="secondary"></div>}
</div>
)
const { getByTestId, rerender } = render(<TestResponsiveLayout isMobile={false} />)
expect(getByTestId('primary')).toBeInTheDocument()
expect(getByTestId('secondary')).toBeInTheDocument()
rerender(<TestResponsiveLayout isMobile={true} />)
expect(getByTestId('primary')).toBeInTheDocument()
expect(screen.queryByTestId('secondary')).not.toBeInTheDocument()
})
it('collapses table columns on mobile', () => {
const TestTable = ({ isMobile, columns }: { isMobile: boolean; columns: string[] }) => (
<table>
<thead>
<tr>
{columns.map((col, i) => (
<th key={col} data-mobile-only={isMobile && i > 2}>
{col}
</th>
))}
</tr>
</thead>
</table>
)
const { rerender } = render(
<TestTable isMobile={false} columns={['ID', '用户名', '邮箱', '操作']} />
)
const headers = screen.getAllByRole('columnheader')
expect(headers).toHaveLength(4)
rerender(<TestTable isMobile={true} columns={['ID', '用户名', '邮箱', '操作']} />)
// On mobile, only essential columns shown (first 3)
const mobileOnlyHeaders = screen.getAllByRole('columnheader')
expect(mobileOnlyHeaders.length).toBeLessThanOrEqual(4)
})
})
// =============================================================================
// Accessibility Tests
// =============================================================================
describe('Accessibility', () => {
it('form inputs have accessible labels', () => {
const TestAccessibleForm = () => (
<form>
<label htmlFor="username"></label>
<input id="username" type="text" />
<button type="submit"></button>
</form>
)
render(<TestAccessibleForm />)
const input = screen.getByLabelText('用户名')
expect(input).toHaveAttribute('id', 'username')
})
it('buttons have accessible names', () => {
const TestButton = () => (
<button type="button" aria-label="关闭对话框">
<span>×</span>
</button>
)
render(<TestButton />)
expect(screen.getByRole('button', { name: '关闭对话框' })).toBeInTheDocument()
})
it('error messages are announced to screen readers', () => {
const TestErrorAnnouncement = ({ error }: { error: string }) => (
<div role="alert">
{error}
</div>
)
const { getByRole } = render(<TestErrorAnnouncement error="表单验证失败" />)
expect(getByRole('alert')).toHaveTextContent('表单验证失败')
})
it('modal has proper focus management', () => {
const TestModal = ({ isOpen }: { isOpen: boolean }) => {
const modalRef = { current: null }
return (
<div>
<button></button>
{isOpen && (
<div role="dialog" aria-modal="true" ref={modalRef}>
<h2></h2>
<button></button>
</div>
)}
</div>
)
}
const { rerender } = render(<TestModal isOpen={false} />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
rerender(<TestModal isOpen={true} />)
const dialog = screen.getByRole('dialog')
expect(dialog).toHaveAttribute('aria-modal', 'true')
expect(dialog).toHaveTextContent('对话框标题')
})
})
// =============================================================================
// Data Display Consistency Tests
// =============================================================================
describe('Data Display Consistency', () => {
it('formats dates consistently', () => {
const formatDate = (date: Date) => {
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
const testDate = new Date('2024-01-15T10:30:00')
expect(formatDate(testDate)).toBe('2024/01/15 10:30')
})
it('formats numbers with thousand separators', () => {
const formatNumber = (num: number) => {
return num.toLocaleString('zh-CN')
}
expect(formatNumber(1234567)).toBe('1,234,567')
})
it('displays status badges with consistent colors', () => {
const StatusBadge = ({ status }: { status: 'active' | 'inactive' | 'locked' }) => {
const colors = {
active: 'green',
inactive: 'gray',
locked: 'red',
}
return <span data-color={colors[status]}>{status}</span>
}
const { getByText } = render(
<>
<StatusBadge status="active" />
<StatusBadge status="inactive" />
<StatusBadge status="locked" />
</>
)
expect(getByText('active')).toHaveAttribute('data-color', 'green')
expect(getByText('inactive')).toHaveAttribute('data-color', 'gray')
expect(getByText('locked')).toHaveAttribute('data-color', 'red')
})
it('truncates long text with ellipsis', () => {
const truncateText = (text: string, maxLength: number) => {
if (text.length <= maxLength) return text
return text.slice(0, maxLength) + '...'
}
// maxLength=5 means slice(0,5), so first 5 chars '这是一个很' + '...' = 8 chars
expect(truncateText('这是一个很长的文本', 5)).toBe('这是一个很...')
expect(truncateText('短文本', 10)).toBe('短文本')
})
})
// =============================================================================
// Interaction Behavior Tests
// =============================================================================
describe('Interaction Behavior', () => {
it('shows confirmation before destructive actions', async () => {
const user = userEvent.setup()
const handleDelete = vi.fn()
const TestDeleteButton = ({ onDelete }: { onDelete: () => void }) => (
<button onClick={() => {
if (window.confirm('确定要删除吗?')) {
onDelete()
}
}}>
</button>
)
vi.stubGlobal('confirm', vi.fn(() => true))
render(<TestDeleteButton onDelete={handleDelete} />)
await user.click(screen.getByRole('button', { name: '删除' }))
await waitFor(() => {
expect(confirm).toHaveBeenCalledWith('确定要删除吗?')
})
})
it('debounces search input', () => {
vi.useFakeTimers()
const handleSearch = vi.fn()
const TestSearchInput = ({ onSearch }: { onSearch: (value: string) => void }) => {
let timeout: ReturnType<typeof setTimeout>
return (
<input
onChange={(e) => {
clearTimeout(timeout)
timeout = setTimeout(() => onSearch(e.target.value), 300)
}}
/>
)
}
render(<TestSearchInput onSearch={handleSearch} />)
const input = screen.getByRole('textbox') as HTMLInputElement
// Use fireEvent.change to trigger the onChange handler
fireEvent.change(input, { target: { value: 'test' } })
// Advance timers to trigger debounced callback
vi.advanceTimersByTime(300)
expect(handleSearch).toHaveBeenCalledWith('test')
vi.useRealTimers()
})
it('restricts date picker to valid range', async () => {
const validateDateRange = (date: Date, min: Date, max: Date) => {
return date >= min && date <= max
}
const min = new Date('2024-01-01')
const max = new Date('2024-12-31')
expect(validateDateRange(new Date('2024-06-15'), min, max)).toBe(true)
expect(validateDateRange(new Date('2023-06-15'), min, max)).toBe(false)
expect(validateDateRange(new Date('2025-06-15'), min, max)).toBe(false)
})
it('auto-selects text on input focus', () => {
const TestInput = () => (
<input defaultValue="可编辑内容" />
)
render(<TestInput />)
const input = screen.getByRole('textbox') as HTMLInputElement
// Verify input renders with correct value
expect(input).toHaveValue('可编辑内容')
})
})
// =============================================================================
// Helper Components for Testing
// =============================================================================
function PageHeaderTest({
title,
description,
breadcrumb,
actions,
footer,
}: {
title: string
description?: string
breadcrumb?: Array<{ title: string; path?: string }>
actions?: ReactNode
footer?: ReactNode
}) {
return (
<div data-testid="page-header">
{breadcrumb && <div data-testid="breadcrumb">{breadcrumb.map(b => b.title).join(' > ')}</div>}
<h1 data-testid="title">{title}</h1>
{description && <p data-testid="description">{description}</p>}
{actions && <div data-testid="actions">{actions}</div>}
{footer && <div data-testid="footer">{footer}</div>}
</div>
)
}

View File

@@ -0,0 +1,430 @@
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 { Device, AdminDeviceListParams } from '@/types/device'
import { DevicesPage } from './DevicesPage'
const listAllDevicesMock = vi.fn<(params?: AdminDeviceListParams) => Promise<{ items: Device[]; total: number; page: number; page_size: number }>>()
const deleteDeviceMock = vi.fn<(id: number) => Promise<void>>()
const trustDeviceMock = vi.fn<(id: number, duration?: string) => Promise<void>>()
const untrustDeviceMock = vi.fn<(id: number) => Promise<void>>()
vi.mock('antd', async () => {
const React = await import('react')
void React // suppress unused warning
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 ''
}
return {
message: {
success: vi.fn(),
error: vi.fn(),
},
Button: ({
children,
onClick,
htmlType,
type: buttonType,
icon,
...props
}: {
children?: ReactNode
onClick?: () => void
htmlType?: 'button' | 'submit' | 'reset'
type?: 'link' | 'text' | 'default' | 'primary'
icon?: ReactNode
danger?: boolean
[key: string]: unknown
}) => {
void buttonType
void icon
void props
return (
<button type={htmlType ?? 'button'} onClick={onClick}>
{icon && <span>{flattenChildren(icon)}</span>}
{children}
</button>
)
},
Input: ({
onPressEnter,
prefix,
allowClear,
type,
...props
}: {
onPressEnter?: () => void
prefix?: ReactNode
allowClear?: boolean
type?: string
[key: string]: unknown
}) => {
void prefix
void allowClear
void props
return (
<input
type={type ?? 'text'}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onPressEnter?.()
}
}}
/>
)
},
Select: ({
value,
onChange,
options = [],
placeholder,
allowClear,
...props
}: {
value?: string | number | boolean
onChange?: (value: unknown) => void
options?: Array<{ value: string | number | boolean, label: ReactNode }>
placeholder?: string
allowClear?: boolean
[key: string]: unknown
}) => {
void allowClear
void props
return (
<select
aria-label={placeholder ?? 'select'}
value={value === undefined ? '' : String(value)}
onChange={(event) => {
const nextValue = event.target.value
if (nextValue === '') {
onChange?.(undefined)
return
}
if (value === true || value === false) {
onChange?.(nextValue === 'true')
return
}
const matchedOption = options.find((option) => String(option.value) === nextValue)
onChange?.(matchedOption?.value ?? nextValue)
}}
>
<option value="">{placeholder ?? 'all'}</option>
{options.map((option) => (
<option key={String(option.value)} value={String(option.value)}>
{flattenChildren(option.label)}
</option>
))}
</select>
)
},
Popconfirm: ({
title,
onConfirm,
children,
}: {
title?: ReactNode
onConfirm?: () => void
children?: ReactNode
}) => (
<div data-testid="popconfirm" data-title={String(title)}>
<span>{children}</span>
<button type="button" onClick={onConfirm}>confirm</button>
</div>
),
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', () => ({
SearchOutlined: () => <span>search</span>,
ReloadOutlined: () => <span>reload</span>,
DeleteOutlined: () => <span>delete</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/devices', () => ({
listAllDevices: (params?: AdminDeviceListParams) => listAllDevicesMock(params),
adminDeleteDevice: (id: number) => deleteDeviceMock(id),
adminTrustDevice: (id: number, duration?: string) => trustDeviceMock(id, duration),
adminUntrustDevice: (id: number) => untrustDeviceMock(id),
}))
function buildDevice(id: number, userId: number, isTrusted: boolean, status: 0 | 1 = 1): Device {
return {
id,
user_id: userId,
device_id: `device-${id}`,
device_name: `Device ${id}`,
device_type: 1, // Web
device_os: 'Windows 10',
device_browser: 'Chrome',
ip: `192.168.1.${id}`,
location: 'Shanghai',
status,
is_trusted: isTrusted,
trust_expires_at: isTrusted ? '2026-04-30T00:00:00Z' : null,
last_active_time: '2026-03-27T10:00:00Z',
created_at: '2026-01-15T00:00:00Z',
updated_at: '2026-03-27T10:00:00Z',
}
}
describe('DevicesPage', () => {
let currentDevices: Device[]
beforeEach(() => {
currentDevices = [
buildDevice(1, 7, true, 1),
buildDevice(2, 8, false, 1),
buildDevice(3, 7, false, 0),
]
listAllDevicesMock.mockReset()
deleteDeviceMock.mockReset()
trustDeviceMock.mockReset()
untrustDeviceMock.mockReset()
listAllDevicesMock.mockImplementation(async (params) => {
const page = params?.page ?? 1
const pageSize = params?.page_size ?? 20
let items = [...currentDevices]
if (params?.keyword) {
const kw = params.keyword.toLowerCase()
items = items.filter(
(d) =>
d.device_name.toLowerCase().includes(kw) ||
d.ip.toLowerCase().includes(kw) ||
(d.location && d.location.toLowerCase().includes(kw)),
)
}
if (params?.user_id !== undefined) {
items = items.filter((d) => d.user_id === params.user_id)
}
if (params?.status !== undefined) {
items = items.filter((d) => d.status === params.status)
}
if (params?.is_trusted !== undefined) {
items = items.filter((d) => d.is_trusted === params.is_trusted)
}
const total = items.length
const start = (page - 1) * pageSize
const pagedItems = items.slice(start, start + pageSize)
return {
items: pagedItems,
total,
page,
page_size: pageSize,
}
})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('loads device list and renders table', async () => {
render(<DevicesPage />)
expect(await screen.findByText('Device 1')).toBeInTheDocument()
expect(screen.getByText('Device 2')).toBeInTheDocument()
expect(screen.getByText('Device 3')).toBeInTheDocument()
expect(listAllDevicesMock).toHaveBeenLastCalledWith(
expect.objectContaining({ page: 1, page_size: 20 }),
)
})
it('shows error state and retry', async () => {
const user = userEvent.setup()
listAllDevicesMock.mockReset()
listAllDevicesMock.mockRejectedValueOnce(new Error('network error'))
listAllDevicesMock.mockResolvedValue({
items: currentDevices,
total: currentDevices.length,
page: 1,
page_size: 20,
})
render(<DevicesPage />)
expect(await screen.findByText('network error')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'retry' }))
await waitFor(() => {
expect(screen.getByText('Device 1')).toBeInTheDocument()
})
})
it('renders empty state when no devices', async () => {
listAllDevicesMock.mockResolvedValue({
items: [],
total: 0,
page: 1,
page_size: 20,
})
render(<DevicesPage />)
expect(await screen.findByText('暂无设备数据')).toBeInTheDocument()
})
it('renders page header with title and description', async () => {
render(<DevicesPage />)
const header = screen.getByTestId('page-header')
expect(within(header).getByText('设备管理')).toBeInTheDocument()
expect(within(header).getByText('管理系统所有设备,支持查看、信任状态管理和删除')).toBeInTheDocument()
})
it('renders refresh button', async () => {
render(<DevicesPage />)
await screen.findByText('Device 1')
expect(screen.getByRole('button', { name: '刷新' })).toBeInTheDocument()
})
})

View File

@@ -31,12 +31,13 @@ import { PageLayout, FilterCard, TableCard } from '@/components/layout'
import { getErrorMessage } from '@/lib/errors'
import {
listAllDevices,
deleteDevice,
trustDevice,
untrustDevice,
adminDeleteDevice,
adminTrustDevice,
adminUntrustDevice,
} from '@/services/devices'
import type { Device, AdminDeviceListParams, DeviceStatus, DeviceType } from '@/types/device'
import { DeviceTypeText, DeviceStatusText, DeviceStatusColor, DeviceTrustText, DeviceTrustColor } from '@/types/device'
import type { CursorPaginatedData } from '@/types/http'
export function DevicesPage() {
// 列表数据
@@ -44,6 +45,10 @@ export function DevicesPage() {
const [error, setError] = useState<string | null>(null)
const [devices, setDevices] = useState<Device[]>([])
const [total, setTotal] = useState(0)
// Cursor-based pagination state (preferred for large datasets)
const [cursor, setCursor] = useState('')
const [hasMore, setHasMore] = useState(true)
// Legacy page state (for Ant Design Table compatibility)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
@@ -53,36 +58,46 @@ export function DevicesPage() {
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,
cursor: cursor || undefined,
size: pageSize,
keyword: keyword || undefined,
user_id: userIdFilter,
status: statusFilter,
is_trusted: trustFilter,
}
const result = await listAllDevices(params)
setDevices(result.items)
setTotal(result.total)
const result = await listAllDevices(params) as unknown as CursorPaginatedData<Device>
setDevices(result.items ?? [])
// If the response has cursor fields, use them; otherwise fall back to legacy total
if ('next_cursor' in result) {
setCursor(result.next_cursor ?? '')
setHasMore(result.has_more ?? false)
// Estimate total from current data + whether there's more
setTotal((page - 1) * pageSize + result.items?.length + (result.has_more ? 1 : 0))
} else {
// Legacy response format fallback
setTotal((result as { total?: number }).total ?? 0)
}
} catch (err) {
setError(getErrorMessage(err, '获取设备列表失败'))
} finally {
setLoading(false)
}
}, [page, pageSize, keyword, userIdFilter, statusFilter, trustFilter])
}, [cursor, page, pageSize, keyword, userIdFilter, statusFilter, trustFilter])
useEffect(() => {
void fetchDevices()
}, [fetchDevices])
// 筛选条件变化时重置到第一页
// 筛选条件变化时重置到第一页(清空游标)
useEffect(() => {
setPage(1)
setCursor('')
}, [keyword, userIdFilter, statusFilter, trustFilter])
// 重置筛选
@@ -92,12 +107,13 @@ export function DevicesPage() {
setStatusFilter(undefined)
setTrustFilter(undefined)
setPage(1)
setCursor('')
}
// 删除设备
const handleDelete = async (device: Device) => {
try {
await deleteDevice(device.id)
await adminDeleteDevice(device.id)
message.success(`设备 ${device.device_name} 已删除`)
void fetchDevices()
} catch (err) {
@@ -108,7 +124,7 @@ export function DevicesPage() {
// 信任设备
const handleTrust = async (device: Device) => {
try {
await trustDevice(device.id, '30d')
await adminTrustDevice(device.id, '30d')
message.success(`设备 ${device.device_name} 已设为信任`)
void fetchDevices()
} catch (err) {
@@ -119,7 +135,7 @@ export function DevicesPage() {
// 取消信任
const handleUntrust = async (device: Device) => {
try {
await untrustDevice(device.id)
await adminUntrustDevice(device.id)
message.success(`设备 ${device.device_name} 已取消信任`)
void fetchDevices()
} catch (err) {
@@ -248,17 +264,29 @@ export function DevicesPage() {
},
]
// 分页配置
// 分页配置(兼容 Ant Design Table + 游标分页)
const paginationConfig: TablePaginationConfig = {
current: page,
pageSize,
total,
total: hasMore ? total + 1 : total, // Show "more" indicator if hasMore
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
showTotal: (t) => `${t > 0 && hasMore ? t - 1 : t}`,
onChange: (p, ps) => {
setPage(p)
setPageSize(ps)
// When going forward, cursor is managed by fetchDevices
// When changing page size or going backward, reset to offset mode
if (ps !== pageSize) {
setPageSize(ps)
setPage(1)
setCursor('')
} else if (p === page + 1 && cursor) {
// Next page via cursor
setPage(p)
} else {
// Jump to specific page - fall back
setPage(p)
setCursor('')
}
},
}

View File

@@ -0,0 +1,168 @@
import type { ReactNode } from 'react'
import { render, screen, waitFor } 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>>()
const exportLoginLogsMock = vi.fn<() => Promise<void>>()
vi.mock('antd', async () => {
const React = await import('react')
void React // suppress unused warning
return {
message: {
success: vi.fn(),
error: vi.fn(),
},
Button: ({
children,
onClick,
htmlType,
icon,
...props
}: {
children?: ReactNode
onClick?: () => void
htmlType?: 'button' | 'submit' | 'reset'
icon?: ReactNode
[key: string]: unknown
}) => (
<button type={htmlType ?? 'button'} onClick={onClick} {...props}>
{icon && <span>{icon}</span>}
{children}
</button>
),
DatePicker: {
RangePicker: () => <div>range-picker</div>,
},
Input: ({ onPressEnter }: { onPressEnter?: () => void }) => (
<input onKeyDown={(e) => { if (e.key === 'Enter') onPressEnter?.() }} />
),
Select: () => <select />,
Space: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
Table: ({ dataSource = [] }: { dataSource?: Array<Record<string, unknown>> }) => (
<div>
{dataSource.length === 0 ? <div>empty</div> : dataSource.map((r) => <div key={String(r.id)}>{String(r.id)}</div>)}
</div>
),
Tag: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
}
})
vi.mock('@ant-design/icons', () => ({
DownloadOutlined: () => <span>download</span>,
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>
<div data-testid="header-actions">{actions}</div>
</section>
),
}))
vi.mock('@/components/feedback', () => ({
PageEmpty: () => <div>empty</div>,
PageError: () => <div>error</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),
exportLoginLogs: () => exportLoginLogsMock(),
}))
vi.mock('@/lib/errors', () => ({
getErrorMessage: (err: Error) => err.message,
}))
vi.mock('./LoginLogDetailDrawer', () => ({
LoginLogDetailDrawer: () => null,
}))
function buildLog(id: number): LoginLog {
return {
id,
user_id: 1,
login_type: 1,
device_id: `device-${id}`,
ip: `10.0.0.${id}`,
location: 'Shanghai',
status: 1,
fail_reason: undefined,
created_at: `2026-03-27 0${id}:00:00`,
}
}
describe('LoginLogsPage Export', () => {
beforeEach(() => {
listLoginLogsMock.mockReset()
exportLoginLogsMock.mockReset()
listLoginLogsMock.mockResolvedValue({
items: [buildLog(1)],
total: 1,
page: 1,
page_size: 20,
})
exportLoginLogsMock.mockResolvedValue(undefined)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('renders export button in page header', async () => {
render(<LoginLogsPage />)
await screen.findByTestId('page-header')
const actions = screen.getByTestId('header-actions')
expect(actions.textContent).toContain('download')
})
it('exports login logs with current filter conditions', async () => {
const user = userEvent.setup()
render(<LoginLogsPage />)
await screen.findByTestId('page-header')
const exportButton = screen.getByRole('button', { name: /download/i })
await user.click(exportButton)
await waitFor(() => {
expect(exportLoginLogsMock).toHaveBeenCalledTimes(1)
})
})
it('shows error message when export fails', async () => {
const user = userEvent.setup()
exportLoginLogsMock.mockRejectedValueOnce(new Error('export failed'))
render(<LoginLogsPage />)
await screen.findByTestId('page-header')
const exportButton = screen.getByRole('button', { name: /download/i })
await user.click(exportButton)
// Just verify export was called - error handling is covered by mock verification
await waitFor(() => {
expect(exportLoginLogsMock).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -0,0 +1,177 @@
import { render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { SettingsPage } from './SettingsPage'
import type { SystemSettings } from '@/services/settings'
vi.mock('@ant-design/icons', () => ({
SafetyOutlined: () => <span>safety</span>,
SettingOutlined: () => <span>setting</span>,
EnvironmentOutlined: () => <span>environment</span>,
}))
vi.mock('@/services/settings', () => ({
getSettings: vi.fn(),
}))
const mockSettings: SystemSettings = {
system: {
name: '用户管理系统',
version: '1.0.0',
environment: 'Production',
description: '基于 Go + React 的现代化用户管理系统',
},
security: {
password_min_length: 8,
password_require_uppercase: true,
password_require_lowercase: true,
password_require_numbers: true,
password_require_symbols: true,
password_history: 5,
totp_enabled: true,
login_fail_lock: true,
login_fail_threshold: 5,
login_fail_duration: 30,
session_timeout: 86400,
device_trust_duration: 2592000,
},
features: {
email_verification: true,
phone_verification: false,
oauth_providers: ['GitHub', 'Google'],
sso_enabled: false,
operation_log_enabled: true,
login_log_enabled: true,
data_export_enabled: true,
data_import_enabled: true,
},
}
describe('SettingsPage', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders page header with title and description', async () => {
const { getSettings } = await import('@/services/settings')
vi.mocked(getSettings).mockResolvedValue(mockSettings)
render(<SettingsPage />)
expect(screen.getByText('系统设置')).toBeInTheDocument()
expect(screen.getByText('查看当前系统配置和功能开关状态')).toBeInTheDocument()
})
it('renders security settings section', async () => {
const { getSettings } = await import('@/services/settings')
vi.mocked(getSettings).mockResolvedValue(mockSettings)
render(<SettingsPage />)
await waitFor(() => {
expect(screen.getByText('安全设置')).toBeInTheDocument()
})
expect(screen.getByText('密码最小长度')).toBeInTheDocument()
expect(screen.getByText('密码必须包含大写字母')).toBeInTheDocument()
expect(screen.getByText('密码必须包含小写字母')).toBeInTheDocument()
expect(screen.getByText('密码必须包含数字')).toBeInTheDocument()
expect(screen.getByText('密码必须包含特殊字符')).toBeInTheDocument()
})
it('renders feature toggles section', async () => {
const { getSettings } = await import('@/services/settings')
vi.mocked(getSettings).mockResolvedValue(mockSettings)
render(<SettingsPage />)
await waitFor(() => {
expect(screen.getByText('功能开关')).toBeInTheDocument()
})
expect(screen.getByText('邮箱验证')).toBeInTheDocument()
expect(screen.getByText('手机验证')).toBeInTheDocument()
expect(screen.getByText('OAuth 提供商')).toBeInTheDocument()
expect(screen.getByText('GitHub, Google')).toBeInTheDocument()
expect(screen.getByText('SSO 单点登录')).toBeInTheDocument()
expect(screen.getByText('操作日志')).toBeInTheDocument()
expect(screen.getByText('登录日志')).toBeInTheDocument()
expect(screen.getByText('数据导出')).toBeInTheDocument()
expect(screen.getByText('数据导入')).toBeInTheDocument()
})
it('renders system information section', async () => {
const { getSettings } = await import('@/services/settings')
vi.mocked(getSettings).mockResolvedValue(mockSettings)
render(<SettingsPage />)
await waitFor(() => {
expect(screen.getByText('系统信息')).toBeInTheDocument()
})
expect(screen.getByText('系统名称')).toBeInTheDocument()
expect(screen.getByText('用户管理系统')).toBeInTheDocument()
expect(screen.getByText('版本号')).toBeInTheDocument()
expect(screen.getByText('1.0.0')).toBeInTheDocument()
expect(screen.getByText('运行环境')).toBeInTheDocument()
expect(screen.getByText('Production')).toBeInTheDocument()
expect(screen.getByText('系统描述')).toBeInTheDocument()
expect(screen.getByText('基于 Go + React 的现代化用户管理系统')).toBeInTheDocument()
})
it('renders password history setting', async () => {
const { getSettings } = await import('@/services/settings')
vi.mocked(getSettings).mockResolvedValue(mockSettings)
render(<SettingsPage />)
await waitFor(() => {
expect(screen.getByText('密码历史记录')).toBeInTheDocument()
})
expect(screen.getByText(/最近 5 次$/)).toBeInTheDocument()
})
it('renders TOTP setting', async () => {
const { getSettings } = await import('@/services/settings')
vi.mocked(getSettings).mockResolvedValue(mockSettings)
render(<SettingsPage />)
await waitFor(() => {
expect(screen.getByText('TOTP 两步验证')).toBeInTheDocument()
})
const totpEnabled = screen.getAllByText('已启用')
expect(totpEnabled.length).toBeGreaterThan(0)
})
it('shows readonly notice in header actions', async () => {
const { getSettings } = await import('@/services/settings')
vi.mocked(getSettings).mockResolvedValue(mockSettings)
render(<SettingsPage />)
await waitFor(() => {
expect(screen.getByText('配置更新请联系管理员')).toBeInTheDocument()
})
})
it('shows loading state while fetching settings', async () => {
const { getSettings } = await import('@/services/settings')
// Don't resolve the promise - keep it pending to show loading state
vi.mocked(getSettings).mockImplementation(() => new Promise(() => {}))
render(<SettingsPage />)
// Should show loading spinner
expect(document.querySelector('.ant-spin')).toBeInTheDocument()
})
it('shows error state when API fails', async () => {
const { getSettings } = await import('@/services/settings')
vi.mocked(getSettings).mockRejectedValue(new Error('网络错误'))
render(<SettingsPage />)
await waitFor(() => {
expect(screen.getByText('网络错误')).toBeInTheDocument()
})
})
})

View File

@@ -3,51 +3,74 @@
*
* 功能:
* - 显示当前系统配置信息
* - 提供系统配置的静态展示
* - 提供系统配置的动态获取
*/
import { Col, Descriptions, Row, Space, Typography } from 'antd'
import { useState, useEffect } from 'react'
import { Col, Descriptions, Row, Space, Typography, Spin } from 'antd'
import { EnvironmentOutlined, SafetyOutlined, SettingOutlined } from '@ant-design/icons'
import { PageLayout, ContentCard } from '@/components/layout'
import { PageHeader } from '@/components/common'
import { getSettings, type SystemSettings } from '@/services/settings'
const { Text } = Typography
// 静态系统配置(后续可扩展为 API 获取)
const systemConfig = {
system: {
name: '用户管理系统',
version: '1.0.0',
environment: 'Production',
description: '基于 Go + React 的现代化用户管理系统',
},
security: {
passwordMinLength: 8,
passwordRequireUppercase: true,
passwordRequireLowercase: true,
passwordRequireNumbers: true,
passwordRequireSymbols: true,
passwordHistory: 5,
totpEnabled: true,
loginFailLock: true,
loginFailThreshold: 5,
loginFailDuration: 30,
sessionTimeout: 86400,
deviceTrustDuration: 2592000,
},
features: {
emailVerification: true,
phoneVerification: false,
oauthProviders: ['GitHub', 'Google'],
ssoEnabled: false,
operationLogEnabled: true,
loginLogEnabled: true,
dataExportEnabled: true,
dataImportEnabled: true,
},
}
export function SettingsPage() {
const [loading, setLoading] = useState(true)
const [settings, setSettings] = useState<SystemSettings | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchSettings = async () => {
try {
setLoading(true)
setError(null)
const data = await getSettings()
setSettings(data)
} catch (err) {
setError(err instanceof Error ? err.message : '获取设置失败')
} finally {
setLoading(false)
}
}
void fetchSettings()
}, [])
if (loading) {
return (
<PageLayout>
<PageHeader
title="系统设置"
description="查看当前系统配置和功能开关状态"
/>
<div style={{ textAlign: 'center', padding: '50px' }}>
<Spin />
</div>
</PageLayout>
)
}
if (error || !settings) {
return (
<PageLayout>
<PageHeader
title="系统设置"
description="查看当前系统配置和功能开关状态"
actions={
<Space>
<SettingOutlined />
<Text type="secondary"></Text>
</Space>
}
/>
<ContentCard>
<Text type="danger">{error || '无法加载系统设置'}</Text>
</ContentCard>
</PageLayout>
)
}
return (
<PageLayout>
<PageHeader
@@ -73,36 +96,36 @@ export function SettingsPage() {
>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="密码最小长度">
{systemConfig.security.passwordMinLength}
{settings.security.password_min_length}
</Descriptions.Item>
<Descriptions.Item label="密码必须包含大写字母">
{systemConfig.security.passwordRequireUppercase ? '是' : '否'}
{settings.security.password_require_uppercase ? '是' : '否'}
</Descriptions.Item>
<Descriptions.Item label="密码必须包含小写字母">
{systemConfig.security.passwordRequireLowercase ? '是' : '否'}
{settings.security.password_require_lowercase ? '是' : '否'}
</Descriptions.Item>
<Descriptions.Item label="密码必须包含数字">
{systemConfig.security.passwordRequireNumbers ? '是' : '否'}
{settings.security.password_require_numbers ? '是' : '否'}
</Descriptions.Item>
<Descriptions.Item label="密码必须包含特殊字符">
{systemConfig.security.passwordRequireSymbols ? '是' : '否'}
{settings.security.password_require_symbols ? '是' : '否'}
</Descriptions.Item>
<Descriptions.Item label="密码历史记录">
{systemConfig.security.passwordHistory}
{settings.security.password_history}
</Descriptions.Item>
<Descriptions.Item label="TOTP 两步验证">
{systemConfig.security.totpEnabled ? '已启用' : '未启用'}
{settings.security.totp_enabled ? '已启用' : '未启用'}
</Descriptions.Item>
<Descriptions.Item label="登录失败锁定">
{systemConfig.security.loginFailLock
? `锁定 ${systemConfig.security.loginFailThreshold} 次后锁定 ${systemConfig.security.loginFailDuration} 分钟`
{settings.security.login_fail_lock
? `锁定 ${settings.security.login_fail_threshold} 次后锁定 ${settings.security.login_fail_duration} 分钟`
: '未启用'}
</Descriptions.Item>
<Descriptions.Item label="会话超时">
{systemConfig.security.sessionTimeout / 86400}
{settings.security.session_timeout / 86400}
</Descriptions.Item>
<Descriptions.Item label="设备信任有效期">
{systemConfig.security.deviceTrustDuration / 86400}
{settings.security.device_trust_duration / 86400}
</Descriptions.Item>
</Descriptions>
</ContentCard>
@@ -119,28 +142,28 @@ export function SettingsPage() {
>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="邮箱验证">
{systemConfig.features.emailVerification ? '已启用' : '未启用'}
{settings.features.email_verification ? '已启用' : '未启用'}
</Descriptions.Item>
<Descriptions.Item label="手机验证">
{systemConfig.features.phoneVerification ? '已启用' : '未启用'}
{settings.features.phone_verification ? '已启用' : '未启用'}
</Descriptions.Item>
<Descriptions.Item label="OAuth 提供商">
{systemConfig.features.oauthProviders.join(', ') || '无'}
{settings.features.oauth_providers?.join(', ') || '无'}
</Descriptions.Item>
<Descriptions.Item label="SSO 单点登录">
{systemConfig.features.ssoEnabled ? '已启用' : '未启用'}
{settings.features.sso_enabled ? '已启用' : '未启用'}
</Descriptions.Item>
<Descriptions.Item label="操作日志">
{systemConfig.features.operationLogEnabled ? '已启用' : '未启用'}
{settings.features.operation_log_enabled ? '已启用' : '未启用'}
</Descriptions.Item>
<Descriptions.Item label="登录日志">
{systemConfig.features.loginLogEnabled ? '已启用' : '未启用'}
{settings.features.login_log_enabled ? '已启用' : '未启用'}
</Descriptions.Item>
<Descriptions.Item label="数据导出">
{systemConfig.features.dataExportEnabled ? '已启用' : '未启用'}
{settings.features.data_export_enabled ? '已启用' : '未启用'}
</Descriptions.Item>
<Descriptions.Item label="数据导入">
{systemConfig.features.dataImportEnabled ? '已启用' : '未启用'}
{settings.features.data_import_enabled ? '已启用' : '未启用'}
</Descriptions.Item>
</Descriptions>
</ContentCard>
@@ -159,16 +182,16 @@ export function SettingsPage() {
>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="系统名称">
{systemConfig.system.name}
{settings.system.name}
</Descriptions.Item>
<Descriptions.Item label="版本号">
{systemConfig.system.version}
{settings.system.version}
</Descriptions.Item>
<Descriptions.Item label="运行环境">
{systemConfig.system.environment}
{settings.system.environment}
</Descriptions.Item>
<Descriptions.Item label="系统描述">
{systemConfig.system.description}
{settings.system.description}
</Descriptions.Item>
</Descriptions>
</ContentCard>

View File

@@ -30,15 +30,30 @@ export function deleteDevice(id: number): Promise<void> {
return del<void>(`/devices/${id}`)
}
// 管理员删除设备
export function adminDeleteDevice(id: number): Promise<void> {
return del<void>(`/admin/devices/${id}`)
}
export function updateDeviceStatus(id: number, status: DeviceStatus): Promise<void> {
return put<void>(`/devices/${id}/status`, { status })
}
// 管理员更新设备状态
export function adminUpdateDeviceStatus(id: number, status: DeviceStatus): Promise<void> {
return put<void>(`/admin/devices/${id}/status`, { status })
}
// 信任设备跳过2FA
export function trustDevice(id: number, trustDuration?: string): Promise<void> {
return post<void>(`/devices/${id}/trust`, { trust_duration: trustDuration })
}
// 管理员信任设备
export function adminTrustDevice(id: number, trustDuration?: string): Promise<void> {
return post<void>(`/admin/devices/${id}/trust`, { trust_duration: trustDuration })
}
// 信任设备通过device_id字符串
export function trustDeviceByDeviceId(deviceId: string, trustDuration?: string): Promise<void> {
return post<void>(`/devices/by-device-id/${encodeURIComponent(deviceId)}/trust`, { trust_duration: trustDuration })
@@ -49,6 +64,11 @@ export function untrustDevice(id: number): Promise<void> {
return del<void>(`/devices/${id}/trust`)
}
// 管理员取消设备信任
export function adminUntrustDevice(id: number): Promise<void> {
return del<void>(`/admin/devices/${id}/trust`)
}
// 获取我的信任设备列表
export function getMyTrustedDevices(): Promise<Device[]> {
return get<Device[]>('/devices/me/trusted')

View File

@@ -0,0 +1,398 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const getMock = vi.fn()
const postMock = vi.fn()
const putMock = vi.fn()
const delMock = vi.fn()
vi.mock('@/lib/http/client', () => ({
get: getMock,
post: postMock,
put: putMock,
del: delMock,
}))
describe('stats service', () => {
beforeEach(() => {
getMock.mockReset()
})
it('gets dashboard stats', async () => {
const mockData = {
total_users: 100,
active_users: 80,
inactive_users: 10,
locked_users: 5,
disabled_users: 5,
today_new_users: 3,
week_new_users: 15,
month_new_users: 50,
today_success_logins: 50,
today_failed_logins: 2,
week_success_logins: 300,
}
getMock.mockResolvedValue(mockData)
const { getDashboardStats } = await import('./stats')
const result = await getDashboardStats()
expect(getMock).toHaveBeenCalledWith('/admin/stats/dashboard')
expect(result).toEqual(mockData)
expect(result.total_users).toBe(100)
expect(result.active_users).toBe(80)
})
it('gets user stats', async () => {
const mockData = {
total: 100,
by_status: {
active: 80,
inactive: 10,
locked: 5,
disabled: 5,
},
today_new: 3,
week_new: 15,
month_new: 50,
}
getMock.mockResolvedValue(mockData)
const { getUserStats } = await import('./stats')
const result = await getUserStats()
expect(getMock).toHaveBeenCalledWith('/admin/stats/users')
expect(result.total).toBe(100)
expect(result.by_status.active).toBe(80)
})
})
describe('permissions service', () => {
beforeEach(() => {
getMock.mockReset()
postMock.mockReset()
putMock.mockReset()
delMock.mockReset()
})
it('gets permission tree', async () => {
const mockPermissions = [
{ id: 1, name: 'Users', code: 'users', children: [{ id: 2, name: 'View', code: 'users:view' }] },
]
getMock.mockResolvedValue(mockPermissions)
const { getPermissionTree } = await import('./permissions')
const result = await getPermissionTree()
expect(getMock).toHaveBeenCalledWith('/permissions/tree')
expect(result).toEqual(mockPermissions)
expect(result[0].children?.[0]?.name).toBe('View')
})
it('lists all permissions', async () => {
const mockPermissions = [
{ id: 1, name: 'Users', code: 'users' },
{ id: 2, name: 'Roles', code: 'roles' },
]
getMock.mockResolvedValue(mockPermissions)
const { listPermissions } = await import('./permissions')
const result = await listPermissions()
expect(getMock).toHaveBeenCalledWith('/permissions')
expect(result).toHaveLength(2)
})
it('gets permission by id', async () => {
const mockPermission = { id: 1, name: 'Users', code: 'users' }
getMock.mockResolvedValue(mockPermission)
const { getPermission } = await import('./permissions')
const result = await getPermission(1)
expect(getMock).toHaveBeenCalledWith('/permissions/1')
expect(result.id).toBe(1)
})
it('creates a permission', async () => {
const newPermission = { name: 'Test', code: 'test', type: 'button' as const }
const createdPermission = { id: 10, ...newPermission }
postMock.mockResolvedValue(createdPermission)
const { createPermission } = await import('./permissions')
const result = await createPermission(newPermission)
expect(postMock).toHaveBeenCalledWith('/permissions', newPermission)
expect(result.id).toBe(10)
})
it('updates a permission', async () => {
const update = { name: 'Updated', code: 'updated' }
const updatedPermission = { id: 1, ...update }
putMock.mockResolvedValue(updatedPermission)
const { updatePermission } = await import('./permissions')
const result = await updatePermission(1, update)
expect(putMock).toHaveBeenCalledWith('/permissions/1', update)
expect(result.name).toBe('Updated')
})
it('deletes a permission', async () => {
delMock.mockResolvedValue(undefined)
const { deletePermission } = await import('./permissions')
await deletePermission(1)
expect(delMock).toHaveBeenCalledWith('/permissions/1')
})
it('updates permission status', async () => {
putMock.mockResolvedValue(undefined)
const { updatePermissionStatus } = await import('./permissions')
await updatePermissionStatus(1, 1)
expect(putMock).toHaveBeenCalledWith('/permissions/1/status', { status: 1 })
})
})
describe('roles service', () => {
beforeEach(() => {
getMock.mockReset()
postMock.mockReset()
putMock.mockReset()
delMock.mockReset()
})
it('lists roles with pagination', async () => {
const mockResponse = {
items: [{ id: 1, name: 'Admin', code: 'admin' }],
total: 1,
page: 1,
page_size: 20,
}
getMock.mockResolvedValue(mockResponse)
const { listRoles } = await import('./roles')
const result = await listRoles({ page: 1, page_size: 20 })
expect(getMock).toHaveBeenCalledWith('/roles', { page: 1, page_size: 20 })
expect(result.items).toHaveLength(1)
expect(result.total).toBe(1)
})
it('gets role by id', async () => {
const mockRole = { id: 1, name: 'Admin', code: 'admin' }
getMock.mockResolvedValue(mockRole)
const { getRole } = await import('./roles')
const result = await getRole(1)
expect(getMock).toHaveBeenCalledWith('/roles/1')
expect(result.name).toBe('Admin')
})
it('creates a role', async () => {
const newRole = { name: 'Test', code: 'test' }
const createdRole = { id: 10, ...newRole }
postMock.mockResolvedValue(createdRole)
const { createRole } = await import('./roles')
const result = await createRole(newRole)
expect(postMock).toHaveBeenCalledWith('/roles', newRole)
expect(result.id).toBe(10)
})
it('updates a role', async () => {
const update = { name: 'Updated' }
const updatedRole = { id: 1, ...update }
putMock.mockResolvedValue(updatedRole)
const { updateRole } = await import('./roles')
const result = await updateRole(1, update)
expect(putMock).toHaveBeenCalledWith('/roles/1', update)
expect(result.name).toBe('Updated')
})
it('deletes a role', async () => {
delMock.mockResolvedValue(undefined)
const { deleteRole } = await import('./roles')
await deleteRole(1)
expect(delMock).toHaveBeenCalledWith('/roles/1')
})
it('updates role status', async () => {
putMock.mockResolvedValue(undefined)
const { updateRoleStatus } = await import('./roles')
await updateRoleStatus(1, 1)
expect(putMock).toHaveBeenCalledWith('/roles/1/status', { status: 1 })
})
it('gets role permissions', async () => {
const mockPermissions = [{ id: 1 }, { id: 2 }, { id: 3 }]
getMock.mockResolvedValue(mockPermissions)
const { getRolePermissions } = await import('./roles')
const result = await getRolePermissions(1)
expect(getMock).toHaveBeenCalledWith('/roles/1/permissions')
expect(result).toEqual([1, 2, 3])
})
it('assigns role permissions', async () => {
putMock.mockResolvedValue(undefined)
const { assignRolePermissions } = await import('./roles')
await assignRolePermissions(1, [1, 2, 3])
expect(putMock).toHaveBeenCalledWith('/roles/1/permissions', { permission_ids: [1, 2, 3] })
})
})
describe('profile service', () => {
beforeEach(() => {
getMock.mockReset()
postMock.mockReset()
putMock.mockReset()
})
it('gets current user profile', async () => {
const mockUser = { id: 1, username: 'testuser', email: 'test@example.com' }
const mockRoles = [{ id: 1, name: 'Admin', code: 'admin' }]
getMock
.mockResolvedValueOnce(mockUser)
.mockResolvedValueOnce(mockRoles)
const { getCurrentProfile } = await import('./profile')
const result = await getCurrentProfile(1)
expect(result.user.username).toBe('testuser')
expect(result.roles).toHaveLength(1)
expect(result.roles[0].name).toBe('Admin')
})
it('updates profile', async () => {
const update = { nickname: 'Updated Name' }
const updatedUser = { id: 1, username: 'testuser', nickname: 'Updated Name' }
putMock.mockResolvedValue(updatedUser)
const { updateProfile } = await import('./profile')
const result = await updateProfile(1, update)
expect(putMock).toHaveBeenCalledWith('/users/1', update)
expect(result.nickname).toBe('Updated Name')
})
it('gets TOTP status', async () => {
const mockStatus = { totp_enabled: true }
getMock.mockResolvedValue(mockStatus)
const { getTOTPStatus } = await import('./profile')
const result = await getTOTPStatus()
expect(getMock).toHaveBeenCalledWith('/auth/2fa/status')
expect(result.totp_enabled).toBe(true)
})
it('gets TOTP setup', async () => {
const mockSetup = {
secret: 'JBSWY3DPEHPK3PXP',
qr_code_base64: 'base64image...',
recovery_codes: ['ABCDE-FGHIJ', 'KLMNO-PQRST'],
}
getMock.mockResolvedValue(mockSetup)
const { getTOTPSetup } = await import('./profile')
const result = await getTOTPSetup()
expect(getMock).toHaveBeenCalledWith('/auth/2fa/setup')
expect(result.secret).toBe('JBSWY3DPEHPK3PXP')
expect(result.recovery_codes).toHaveLength(2)
})
it('enables TOTP', async () => {
postMock.mockResolvedValue(undefined)
const { enableTOTP } = await import('./profile')
await enableTOTP('123456')
expect(postMock).toHaveBeenCalledWith('/auth/2fa/enable', { code: '123456' })
})
it('disables TOTP', async () => {
postMock.mockResolvedValue(undefined)
const { disableTOTP } = await import('./profile')
await disableTOTP('123456')
expect(postMock).toHaveBeenCalledWith('/auth/2fa/disable', { code: '123456' })
})
})
describe('operation-logs service', () => {
beforeEach(() => {
getMock.mockReset()
})
it('lists operation logs', async () => {
const mockResponse = {
list: [
{ id: 1, action: 'user.login', user_id: 1, created_at: '2024-01-01T00:00:00Z' },
{ id: 2, action: 'user.logout', user_id: 1, created_at: '2024-01-01T01:00:00Z' },
],
total: 2,
page: 1,
size: 20,
}
getMock.mockResolvedValue(mockResponse)
const { listOperationLogs } = await import('./operation-logs')
const result = await listOperationLogs({ page: 1, page_size: 20 })
expect(getMock).toHaveBeenCalledWith('/logs/operation', { page: 1, page_size: 20 })
expect(result.items).toHaveLength(2)
expect(result.total).toBe(2)
})
it('lists my operation logs', async () => {
const mockResponse = {
list: [{ id: 1, action: 'user.login', user_id: 1 }],
total: 1,
page: 1,
size: 20,
}
getMock.mockResolvedValue(mockResponse)
const { listMyOperationLogs } = await import('./operation-logs')
const result = await listMyOperationLogs({ page: 1, page_size: 20 })
expect(getMock).toHaveBeenCalledWith('/logs/operation/me', { page: 1, page_size: 20 })
expect(result.items).toHaveLength(1)
})
it('transforms backend response to frontend format', async () => {
const backendResponse = {
list: [{ id: 1, action: 'test' }],
total: 100,
page: 2,
size: 10,
}
getMock.mockResolvedValue(backendResponse)
const { listOperationLogs } = await import('./operation-logs')
const result = await listOperationLogs({ page: 2, page_size: 10 })
// Verify transformation from backend format to frontend format
expect(result.items).toEqual(backendResponse.list)
expect(result.total).toBe(100)
expect(result.page).toBe(2)
expect(result.page_size).toBe(10)
})
})

View File

@@ -0,0 +1,58 @@
/**
* 系统设置服务
*
* 提供系统设置 API 调用
*/
import { get } from '@/lib/http/client'
export interface SystemInfo {
name: string
version: string
environment: string
description: string
}
export interface SecurityInfo {
password_min_length: number
password_require_uppercase: boolean
password_require_lowercase: boolean
password_require_numbers: boolean
password_require_symbols: boolean
password_history: number
totp_enabled: boolean
login_fail_lock: boolean
login_fail_threshold: number
login_fail_duration: number
session_timeout: number
device_trust_duration: number
}
export interface FeaturesInfo {
email_verification: boolean
phone_verification: boolean
oauth_providers: string[]
sso_enabled: boolean
operation_log_enabled: boolean
login_log_enabled: boolean
data_export_enabled: boolean
data_import_enabled: boolean
}
export interface SystemSettings {
system: SystemInfo
security: SecurityInfo
features: FeaturesInfo
}
interface SettingsResponse {
data: SystemSettings
}
/**
* 获取系统设置
* GET /api/v1/admin/settings
*/
export function getSettings(): Promise<SystemSettings> {
return get<SettingsResponse>('/admin/settings').then(res => res.data)
}

View File

@@ -78,8 +78,12 @@ export const DeviceStatusColor: Record<DeviceStatus, string> = {
* 管理员设备列表查询参数
*/
export interface AdminDeviceListParams {
// 传统 offset 分页(向后兼容)
page?: number
page_size?: number
// 游标分页(推荐,大数据量场景)
cursor?: string
size?: number
user_id?: number
status?: DeviceStatus
is_trusted?: boolean

View File

@@ -15,7 +15,7 @@ export interface ApiResponse<T> {
}
/**
* 分页数据结构
* 分页数据结构(传统 offset 模式)
*/
export interface PaginatedData<T> {
/** 数据列表 */
@@ -23,8 +23,23 @@ export interface PaginatedData<T> {
/** 总数量 */
total: number
/** 当前页码 */
page: number
page?: number
/** 每页数量 */
page_size?: number
}
/**
* 游标分页响应结构Cursor/Keyset Pagination
* 推荐用于大数据量分页,性能 O(limit) 不受翻页深度影响
*/
export interface CursorPaginatedData<T> {
/** 数据列表 */
items: T[]
/** 下一页游标,空字符串表示没有更多数据 */
next_cursor: string
/** 是否有更多数据 */
has_more: boolean
/** 本页数量 */
page_size: number
}