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:
430
frontend/admin/src/pages/admin/DevicesPage/DevicesPage.test.tsx
Normal file
430
frontend/admin/src/pages/admin/DevicesPage/DevicesPage.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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('')
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user