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,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>