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:
@@ -9,6 +9,7 @@ import { chromium, expect } from '@playwright/test'
|
||||
|
||||
const TEXT = {
|
||||
accessControl: '\u8bbf\u95ee\u63a7\u5236',
|
||||
active: '\u542f\u7528',
|
||||
adminBootstrapTitle: '\u7cfb\u7edf\u5c1a\u672a\u521d\u59cb\u5316\u9996\u4e2a\u7ba1\u7406\u5458\u8d26\u53f7',
|
||||
adminRoleName: '\u7ba1\u7406\u5458',
|
||||
adminBootstrapAction: '\u521d\u59cb\u5316\u7ba1\u7406\u5458',
|
||||
@@ -23,21 +24,41 @@ const TEXT = {
|
||||
bootstrapAdminPasswordPlaceholder: '\u7ba1\u7406\u5458\u5bc6\u7801',
|
||||
bootstrapAdminSubmit: '\u5b8c\u6210\u521d\u59cb\u5316\u5e76\u8fdb\u5165\u7cfb\u7edf',
|
||||
bootstrapAdminUsernamePlaceholder: '\u7ba1\u7406\u5458\u7528\u6237\u540d',
|
||||
changePassword: '\u4fee\u6539\u5bc6\u7801',
|
||||
confirmPasswordPlaceholder: '\u786e\u8ba4\u5bc6\u7801',
|
||||
createAccount: '\u521b\u5efa\u8d26\u53f7',
|
||||
createUser: '\u521b\u5efa\u7528\u6237',
|
||||
createUser: '\u521b\u5efa\u7528\u5458',
|
||||
createUserEmailPlaceholder: '\u90ae\u7bb1\u5730\u5740',
|
||||
createUserPasswordPlaceholder: '\u8bf7\u8f93\u5165\u521d\u59cb\u5bc6\u7801',
|
||||
createUserUsernamePlaceholder: '\u8bf7\u8f93\u5165\u7528\u6237\u540d',
|
||||
createRole: '\u521b\u5efa\u89d2\u8272',
|
||||
createPermission: '\u521b\u5efa\u6743\u9650',
|
||||
dashboard: '\u603b\u89c8',
|
||||
delete: '\u5220\u9664',
|
||||
deleteConfirm: '\u786e\u5b9a\u5220\u9664',
|
||||
deviceManagement: '\u8bbe\u5907\u7ba1\u7406',
|
||||
devices: '\u8bbe\u5907',
|
||||
disabled: '\u7981\u7528',
|
||||
edit: '\u7f16\u8f91',
|
||||
editUser: '\u7f16\u8f91\u7528\u6237',
|
||||
emailCodeLogin: '\u90ae\u7bb1\u9a8c\u8bc1\u7801',
|
||||
emailActivationSuccess: '\u90ae\u7bb1\u9a8c\u8bc1\u6210\u529f',
|
||||
export: '\u5bfc\u51fa',
|
||||
forgotPassword: '\u5fd8\u8bb0\u5bc6\u7801\uff1f',
|
||||
loginAction: '\u767b\u5f55',
|
||||
loginLogs: '\u767b\u5f55\u65e5\u5fd7',
|
||||
loginNow: '\u7acb\u5373\u767b\u5f55',
|
||||
logout: '\u9000\u51fa\u767b\u5f55',
|
||||
logoutOthers: '\u9000\u51fa\u5176\u4ed6\u8bbe\u5907',
|
||||
name: '\u540d\u79f0',
|
||||
newPassword: '\u65b0\u5bc6\u7801',
|
||||
newPasswordPlaceholder: '\u8bf7\u8f93\u5165\u65b0\u5bc6\u7801',
|
||||
nickname: '\u6635\u79f0',
|
||||
oldPassword: '\u5f53\u524d\u5bc6\u7801',
|
||||
oldPasswordPlaceholder: '\u8bf7\u8f93\u5165\u5f53\u524d\u5bc6\u7801',
|
||||
operationLogs: '\u64cd\u4f5c\u65e5\u5fd7',
|
||||
passwordPlaceholder: '\u5bc6\u7801',
|
||||
permissions: '\u6743\u9650\u7ba1\u7406',
|
||||
permissionsAction: '\u6743\u9650',
|
||||
permissionsHint: '\u9009\u62e9\u8981\u5206\u914d\u7ed9\u8be5\u89d2\u8272\u7684\u6743\u9650',
|
||||
profile: '\u4e2a\u4eba\u8d44\u6599',
|
||||
@@ -45,15 +66,22 @@ const TEXT = {
|
||||
registerSuccess: '\u6ce8\u518c\u6210\u529f',
|
||||
roleFilter: '\u89d2\u8272\u540d\u79f0/\u4ee3\u7801',
|
||||
roles: '\u89d2\u8272\u7ba1\u7406',
|
||||
save: '\u4fdd\u5b58',
|
||||
security: '\u5b89\u5168\u8bbe\u7f6e',
|
||||
smsCodeLogin: '\u77ed\u4fe1\u9a8c\u8bc1\u7801',
|
||||
status: '\u72b6\u6001',
|
||||
systemManagement: '\u7cfb\u7edf\u7ba1\u7406',
|
||||
todaySuccessLogins: '\u4eca\u65e5\u6210\u529f\u767b\u5f55',
|
||||
totalUsers: '\u7528\u6237\u603b\u6570',
|
||||
trust: '\u4fe1\u4efb',
|
||||
untrust: '\u53d6\u6d88\u4fe1\u4efb',
|
||||
userDetail: '\u7528\u6237\u8be6\u60c5',
|
||||
userDetailAction: '\u8be6\u60c5',
|
||||
userId: '\u7528\u6237 ID',
|
||||
usernamePlaceholder: '\u7528\u6237\u540d',
|
||||
users: '\u7528\u6237\u7ba1\u7406',
|
||||
usersFilter: '\u7528\u6237\u540d/\u90ae\u7bb1/\u624b\u673a\u53f7',
|
||||
webhooks: 'Webhooks',
|
||||
welcomeLogin: '\u6b22\u8fce\u767b\u5f55',
|
||||
}
|
||||
|
||||
@@ -1125,6 +1153,198 @@ async function verifyDesktopAndMobileNavigation(page) {
|
||||
await expect(page.locator('body')).toContainText(TEXT.todaySuccessLogins, { timeout: 10 * 1000 })
|
||||
}
|
||||
|
||||
async function verifyUserManagementCRUD(page) {
|
||||
logDebug('verifyUserManagementCRUD: login /login')
|
||||
await loginFromLoginPage(page)
|
||||
|
||||
await expandSidebarGroup(page, TEXT.accessControl)
|
||||
await clickSidebarMenu(page, TEXT.users)
|
||||
await expect(page).toHaveURL(/\/users$/)
|
||||
|
||||
const testUsername = `e2e_crud_${Date.now()}`
|
||||
const testEmail = `${testUsername}@example.com`
|
||||
|
||||
const createUserModal = page.locator('.ant-modal').last()
|
||||
await forceClick(page.getByRole('button', { name: TEXT.createUser }).first())
|
||||
await expect(createUserModal).toBeVisible({ timeout: 10 * 1000 })
|
||||
|
||||
const createUserResponsePromise = page.waitForResponse((response) => {
|
||||
return response.url().includes('/api/v1/users') && response.request().method() === 'POST'
|
||||
})
|
||||
await forceFillInput(
|
||||
createUserModal.locator(`input[placeholder="${TEXT.createUserUsernamePlaceholder}"]`).first(),
|
||||
testUsername,
|
||||
)
|
||||
await forceFillInput(
|
||||
createUserModal.locator(`input[placeholder="${TEXT.createUserPasswordPlaceholder}"]`).first(),
|
||||
'Crud123!@#',
|
||||
)
|
||||
await forceFillInput(
|
||||
createUserModal.locator(`input[placeholder="${TEXT.createUserEmailPlaceholder}"]`).first(),
|
||||
testEmail,
|
||||
)
|
||||
await forceClick(createUserModal.locator('.ant-btn-primary').last())
|
||||
const createUserResponse = await createUserResponsePromise
|
||||
await assertApiSuccessResponse(createUserResponse, 'create user CRUD')
|
||||
|
||||
await expect(page.locator('tbody tr').filter({ hasText: testUsername }).first()).toBeVisible({ timeout: 20 * 1000 })
|
||||
|
||||
const userRow = page.locator('tbody tr').filter({ hasText: testUsername }).first()
|
||||
await forceClick(userRow.getByRole('button', { name: TEXT.edit }))
|
||||
const editDrawer = page.locator('.ant-drawer')
|
||||
await expect(editDrawer).toBeVisible({ timeout: 10 * 1000 })
|
||||
|
||||
const editResponsePromise = page.waitForResponse((response) => {
|
||||
return response.url().includes(`/api/v1/users/`) && response.request().method() === 'PUT'
|
||||
})
|
||||
await forceClick(editDrawer.locator('.ant-btn-primary').last())
|
||||
const editResponse = await editResponsePromise
|
||||
await assertApiSuccessResponse(editResponse, 'edit user CRUD')
|
||||
|
||||
await forceClick(userRow.getByRole('button', { name: TEXT.userDetailAction }))
|
||||
const detailDrawer = page.locator('.ant-drawer')
|
||||
await expect(detailDrawer).toBeVisible({ timeout: 10 * 1000 })
|
||||
await expect(detailDrawer).toContainText(testUsername)
|
||||
|
||||
await page.goto(appUrl('/users'))
|
||||
await forceFillInput(page.getByPlaceholder(TEXT.usersFilter), testUsername)
|
||||
await expect(page.locator('tbody tr').filter({ hasText: testUsername }).first()).toBeVisible({ timeout: 10 * 1000 })
|
||||
|
||||
await forceClick(userRow.getByRole('button', { name: TEXT.delete }))
|
||||
const deleteConfirmModal = page.locator('.ant-modal-confirm')
|
||||
await expect(deleteConfirmModal).toBeVisible({ timeout: 10 * 1000 })
|
||||
const deleteResponsePromise = page.waitForResponse((response) => {
|
||||
return response.url().includes(`/api/v1/users/`) && response.request().method() === 'DELETE'
|
||||
})
|
||||
await forceClick(deleteConfirmModal.locator('.ant-btn-primary').last())
|
||||
const deleteResponse = await deleteResponsePromise
|
||||
await assertApiSuccessResponse(deleteResponse, 'delete user CRUD')
|
||||
|
||||
await expect(page.locator('tbody tr').filter({ hasText: testUsername }).first()).toHaveCount(0, { timeout: 10 * 1000 })
|
||||
|
||||
await forceClick(page.locator('[class*="userTrigger"]'))
|
||||
await forceClick(page.getByText(TEXT.logout, { exact: true }))
|
||||
await expect(page).toHaveURL(/\/login$/)
|
||||
}
|
||||
|
||||
async function verifyRoleManagementCRUD(page) {
|
||||
logDebug('verifyRoleManagementCRUD: login /login')
|
||||
await loginFromLoginPage(page)
|
||||
|
||||
await expandSidebarGroup(page, TEXT.accessControl)
|
||||
await clickSidebarMenu(page, TEXT.roles)
|
||||
await expect(page).toHaveURL(/\/roles$/)
|
||||
|
||||
await expect(page.getByRole('button', { name: TEXT.createRole })).toBeVisible()
|
||||
await expect(page.locator('tbody tr').filter({ hasText: TEXT.adminRoleName }).first()).toBeVisible({ timeout: 10 * 1000 })
|
||||
|
||||
const adminRoleRow = page.locator('tbody tr').filter({ hasText: TEXT.adminRoleName }).first()
|
||||
await forceClick(adminRoleRow.getByRole('button', { name: TEXT.permissionsAction }))
|
||||
const permissionsModal = page.locator('.ant-modal')
|
||||
await expect(permissionsModal.locator('.ant-modal-title')).toContainText(TEXT.assignPermissions)
|
||||
|
||||
await forceClick(permissionsModal.locator('.ant-modal-close'))
|
||||
await expect(permissionsModal).not.toBeVisible({ timeout: 10 * 1000 })
|
||||
|
||||
await forceClick(page.locator('[class*="userTrigger"]'))
|
||||
await forceClick(page.getByText(TEXT.logout, { exact: true }))
|
||||
await expect(page).toHaveURL(/\/login$/)
|
||||
}
|
||||
|
||||
async function verifyDeviceManagement(page) {
|
||||
logDebug('verifyDeviceManagement: login /login')
|
||||
await loginFromLoginPage(page)
|
||||
|
||||
await expandSidebarGroup(page, TEXT.systemManagement)
|
||||
await clickSidebarMenu(page, TEXT.devices)
|
||||
await expect(page).toHaveURL(/\/devices$/)
|
||||
|
||||
await expect(page.getByText(TEXT.deviceManagement)).toBeVisible({ timeout: 10 * 1000 })
|
||||
|
||||
await forceClick(page.locator('[class*="userTrigger"]'))
|
||||
await forceClick(page.getByText(TEXT.logout, { exact: true }))
|
||||
await expect(page).toHaveURL(/\/login$/)
|
||||
}
|
||||
|
||||
async function verifyLoginLogs(page) {
|
||||
logDebug('verifyLoginLogs: login /login')
|
||||
await loginFromLoginPage(page)
|
||||
|
||||
await expandSidebarGroup(page, TEXT.systemManagement)
|
||||
await clickSidebarMenu(page, TEXT.loginLogs)
|
||||
await expect(page).toHaveURL(/\/login-logs$/)
|
||||
|
||||
await expect(page.getByText(TEXT.loginLogs)).toBeVisible({ timeout: 10 * 1000 })
|
||||
|
||||
await forceClick(page.locator('[class*="userTrigger"]'))
|
||||
await forceClick(page.getByText(TEXT.logout, { exact: true }))
|
||||
await expect(page).toHaveURL(/\/login$/)
|
||||
}
|
||||
|
||||
async function verifyOperationLogs(page) {
|
||||
logDebug('verifyOperationLogs: login /login')
|
||||
await loginFromLoginPage(page)
|
||||
|
||||
await expandSidebarGroup(page, TEXT.systemManagement)
|
||||
await clickSidebarMenu(page, TEXT.operationLogs)
|
||||
await expect(page).toHaveURL(/\/operation-logs$/)
|
||||
|
||||
await expect(page.getByText(TEXT.operationLogs)).toBeVisible({ timeout: 10 * 1000 })
|
||||
|
||||
await forceClick(page.locator('[class*="userTrigger"]'))
|
||||
await forceClick(page.getByText(TEXT.logout, { exact: true }))
|
||||
await expect(page).toHaveURL(/\/login$/)
|
||||
}
|
||||
|
||||
async function verifyWebhookManagement(page) {
|
||||
logDebug('verifyWebhookManagement: login /login')
|
||||
await loginFromLoginPage(page)
|
||||
|
||||
await expandSidebarGroup(page, TEXT.systemManagement)
|
||||
await clickSidebarMenu(page, TEXT.webhooks)
|
||||
await expect(page).toHaveURL(/\/webhooks$/)
|
||||
|
||||
await expect(page.getByText(TEXT.webhooks)).toBeVisible({ timeout: 10 * 1000 })
|
||||
|
||||
await forceClick(page.locator('[class*="userTrigger"]'))
|
||||
await forceClick(page.getByText(TEXT.logout, { exact: true }))
|
||||
await expect(page).toHaveURL(/\/login$/)
|
||||
}
|
||||
|
||||
async function verifyProfileAndSecurity(page) {
|
||||
logDebug('verifyProfileAndSecurity: login /login')
|
||||
const credentials = await loginFromLoginPage(page)
|
||||
|
||||
await forceClick(page.locator('[class*="userTrigger"]'))
|
||||
await forceClick(page.getByText(TEXT.profile, { exact: true }))
|
||||
await expect(page).toHaveURL(/\/profile$/)
|
||||
|
||||
await expect(page.locator('body')).toContainText(credentials.username, { timeout: 10 * 1000 })
|
||||
|
||||
await forceClick(page.locator('[class*="userTrigger"]'))
|
||||
await forceClick(page.getByText(TEXT.security))
|
||||
await expect(page).toHaveURL(/\/profile\/security$/)
|
||||
|
||||
await expect(page.getByText(TEXT.changePassword)).toBeVisible({ timeout: 10 * 1000 })
|
||||
|
||||
await forceClick(page.locator('[class*="userTrigger"]'))
|
||||
await forceClick(page.getByText(TEXT.logout, { exact: true }))
|
||||
await expect(page).toHaveURL(/\/login$/)
|
||||
}
|
||||
|
||||
async function verifyDashboardStats(page) {
|
||||
logDebug('verifyDashboardStats: login /login')
|
||||
await loginFromLoginPage(page)
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard$/)
|
||||
await expect(page.getByText(TEXT.todaySuccessLogins)).toBeVisible({ timeout: 10 * 1000 })
|
||||
await expect(page.getByText(TEXT.totalUsers)).toBeVisible()
|
||||
|
||||
await forceClick(page.locator('[class*="userTrigger"]'))
|
||||
await forceClick(page.getByText(TEXT.logout, { exact: true }))
|
||||
await expect(page).toHaveURL(/\/login$/)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let browser = null
|
||||
let managedBrowser = null
|
||||
@@ -1159,6 +1379,14 @@ async function main() {
|
||||
await runScenario(browser, context, 'auth-workflow', verifyAuthWorkflow)
|
||||
await runScenario(browser, context, 'responsive-login', verifyResponsiveLogin)
|
||||
await runScenario(browser, context, 'desktop-mobile-navigation', verifyDesktopAndMobileNavigation)
|
||||
await runScenario(browser, context, 'user-management-crud', verifyUserManagementCRUD)
|
||||
await runScenario(browser, context, 'role-management-crud', verifyRoleManagementCRUD)
|
||||
await runScenario(browser, context, 'device-management', verifyDeviceManagement)
|
||||
await runScenario(browser, context, 'login-logs', verifyLoginLogs)
|
||||
await runScenario(browser, context, 'operation-logs', verifyOperationLogs)
|
||||
await runScenario(browser, context, 'webhook-management', verifyWebhookManagement)
|
||||
await runScenario(browser, context, 'profile-and-security', verifyProfileAndSecurity)
|
||||
await runScenario(browser, context, 'dashboard-stats', verifyDashboardStats)
|
||||
console.log('Playwright CDP E2E completed successfully')
|
||||
} finally {
|
||||
await browser?.close().catch(() => {})
|
||||
|
||||
610
frontend/admin/src/components/common/ui-consistency.test.tsx
Normal file
610
frontend/admin/src/components/common/ui-consistency.test.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
398
frontend/admin/src/services/service_tests.test.ts
Normal file
398
frontend/admin/src/services/service_tests.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
58
frontend/admin/src/services/settings.ts
Normal file
58
frontend/admin/src/services/settings.ts
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user