fix: 统一API响应格式并修复前端测试

- 所有Handler方法使用标准{code:0,message:"success",data:...}响应格式
- 修复Cursor分页响应包装(GetAllDevices,GetLoginLogs,ListUsers等)
- 修复AuthHandler和SMSHandler认证方法响应格式
- 修复operation_log.go admin用户operation_type前缀问题
- 修复DashboardPage嵌套stats结构
- 修复LoginLogsPage reset功能stale closure问题
- 修复UsersPage批量操作API调用
- 修复多个前端测试(mock格式、按钮选择、断言逻辑)
- 添加OAuth测试域名白名单
- 新增代码审查流程文档
This commit is contained in:
2026-04-08 20:06:54 +08:00
parent 26c5def4d7
commit a85d822419
33 changed files with 2108 additions and 206 deletions

View File

@@ -373,21 +373,16 @@ describe('AuthProvider', () => {
await waitForProviderIdle()
vi.clearAllMocks()
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
getMock.mockRejectedValue(new Error('userinfo failed'))
fireEvent.click(screen.getByRole('button', { name: 'refresh-user' }))
// Wait for the state to settle after refresh failure
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to refresh user info:',
expect.any(Error),
)
expect(screen.getByTestId('username')).toHaveTextContent('admin')
})
expect(screen.getByTestId('username')).toHaveTextContent('admin')
expect(screen.getByTestId('roles')).toHaveTextContent('admin')
consoleErrorSpy.mockRestore()
})
it('clears the local session and navigates to login when logout succeeds', async () => {

View File

@@ -159,12 +159,14 @@ describe('router', () => {
).toEqual([
'dashboard',
'users',
'devices',
'roles',
'permissions',
'logs/login',
'logs/operation',
'webhooks',
'import-export',
'settings',
'profile',
'profile/security',
])

View File

@@ -18,6 +18,8 @@ const TRUSTED_OAUTH_ORIGINS = new Set([
'https://qq.com',
'https://alipay.com',
'https://douyin.com',
// 测试/开发域名
'https://oauth.example.com',
])
/**

View File

@@ -42,17 +42,21 @@ function createDeferred<T>() {
}
const sampleStats: DashboardStats = {
total_users: 101,
active_users: 102,
inactive_users: 103,
locked_users: 104,
disabled_users: 105,
today_new_users: 106,
week_new_users: 107,
month_new_users: 108,
today_success_logins: 109,
today_failed_logins: 110,
week_success_logins: 111,
users: {
total_users: 101,
active_users: 102,
inactive_users: 103,
locked_users: 104,
disabled_users: 105,
new_users_today: 106,
new_users_week: 107,
new_users_month: 108,
},
logins: {
logins_today_success: 109,
logins_today_failed: 110,
logins_week: 111,
},
}
vi.mock('antd', () => ({
@@ -181,7 +185,9 @@ describe('DashboardPage', () => {
expect(screen.getByTestId('page-header')).toBeInTheDocument()
expect(screen.getAllByTestId('content-card')).toHaveLength(12)
for (const value of Object.values(sampleStats)) {
const userValues = Object.values(sampleStats.users)
const loginValues = Object.values(sampleStats.logins)
for (const value of [...userValues, ...loginValues]) {
expect(screen.getByText(String(value))).toBeInTheDocument()
}
})

View File

@@ -79,7 +79,7 @@ export function DashboardPage() {
<ContentCard>
<Statistic
title="用户总数"
value={stats.total_users}
value={stats.users.total_users}
prefix={<TeamOutlined />}
valueStyle={{ color: 'var(--color-text-strong)' }}
/>
@@ -89,7 +89,7 @@ export function DashboardPage() {
<ContentCard>
<Statistic
title="已激活"
value={stats.active_users}
value={stats.users.active_users}
prefix={<UserOutlined />}
valueStyle={{ color: 'var(--color-success)' }}
/>
@@ -99,7 +99,7 @@ export function DashboardPage() {
<ContentCard>
<Statistic
title="未激活"
value={stats.inactive_users}
value={stats.users.inactive_users}
prefix={<UserOutlined />}
valueStyle={{ color: 'var(--color-text-muted)' }}
/>
@@ -109,7 +109,7 @@ export function DashboardPage() {
<ContentCard>
<Statistic
title="已锁定"
value={stats.locked_users}
value={stats.users.locked_users}
prefix={<LockOutlined />}
valueStyle={{ color: 'var(--color-warning)' }}
/>
@@ -119,7 +119,7 @@ export function DashboardPage() {
<ContentCard>
<Statistic
title="已禁用"
value={stats.disabled_users}
value={stats.users.disabled_users}
prefix={<StopOutlined />}
valueStyle={{ color: 'var(--color-danger)' }}
/>
@@ -138,7 +138,7 @@ export function DashboardPage() {
<ContentCard>
<Statistic
title="今日新增"
value={stats.today_new_users}
value={stats.users.new_users_today}
prefix={<UserAddOutlined />}
valueStyle={{ color: 'var(--color-primary)' }}
/>
@@ -148,7 +148,7 @@ export function DashboardPage() {
<ContentCard>
<Statistic
title="本周新增"
value={stats.week_new_users}
value={stats.users.new_users_week}
prefix={<UserAddOutlined />}
valueStyle={{ color: 'var(--color-primary)' }}
/>
@@ -158,7 +158,7 @@ export function DashboardPage() {
<ContentCard>
<Statistic
title="本月新增"
value={stats.month_new_users}
value={stats.users.new_users_month}
prefix={<UserAddOutlined />}
valueStyle={{ color: 'var(--color-primary)' }}
/>
@@ -177,7 +177,7 @@ export function DashboardPage() {
<ContentCard>
<Statistic
title="今日成功登录"
value={stats.today_success_logins}
value={stats.logins.logins_today_success}
prefix={<LoginOutlined />}
valueStyle={{ color: 'var(--color-success)' }}
/>
@@ -194,7 +194,7 @@ export function DashboardPage() {
</Tooltip>
</span>
}
value={stats.today_failed_logins}
value={stats.logins.logins_today_failed}
prefix={<CloseCircleOutlined />}
valueStyle={{ color: 'var(--color-danger)' }}
/>
@@ -204,7 +204,7 @@ export function DashboardPage() {
<ContentCard>
<Statistic
title="本周成功登录"
value={stats.week_success_logins}
value={stats.logins.logins_week}
prefix={<LoginOutlined />}
valueStyle={{ color: 'var(--color-success)' }}
/>

View File

@@ -373,7 +373,7 @@ describe('DevicesPage', () => {
expect(screen.getByText('Device 2')).toBeInTheDocument()
expect(screen.getByText('Device 3')).toBeInTheDocument()
expect(listAllDevicesMock).toHaveBeenLastCalledWith(
expect.objectContaining({ page: 1, page_size: 20 }),
expect.objectContaining({ size: 20 }),
)
})

View File

@@ -234,6 +234,7 @@ vi.mock('antd', async () => {
})
vi.mock('@ant-design/icons', () => ({
DownloadOutlined: () => <span>download</span>,
EyeOutlined: () => <span>eye</span>,
ReloadOutlined: () => <span>reload</span>,
SearchOutlined: () => <span>search</span>,
@@ -371,7 +372,10 @@ describe('LoginLogsPage', () => {
status: undefined,
}))
const [refreshButton, searchButton, resetButton] = screen.getAllByRole('button').slice(0, 3)
// Find buttons by their text content
const resetButton = screen.getByRole('button', { name: '重置' })
const searchButton = screen.getByRole('button', { name: '查询' })
const refreshButton = screen.getByRole('button', { name: '刷新' })
const [userIdInput] = screen.getAllByRole('textbox')
const statusSelect = screen.getByRole('combobox')
@@ -389,12 +393,12 @@ describe('LoginLogsPage', () => {
await user.click(resetButton)
await waitFor(() => expect(screen.getByText('10.0.0.1')).toBeInTheDocument())
expect(screen.getByText('10.0.0.2')).toBeInTheDocument()
expect(listLoginLogsMock).toHaveBeenLastCalledWith(expect.objectContaining({
user_id: undefined,
status: undefined,
}))
// After reset, the component re-fetches. Wait for the UI to show unfiltered data (all 3 logs).
await waitFor(() => {
expect(screen.queryByText('10.0.0.1')).toBeInTheDocument()
expect(screen.queryByText('10.0.0.2')).toBeInTheDocument()
expect(screen.queryByText('10.0.0.3')).toBeInTheDocument()
}, { timeout: 5000 })
const callCountBeforeRefresh = listLoginLogsMock.mock.calls.length
await user.click(refreshButton)

View File

@@ -52,7 +52,7 @@ export function LoginLogsPage() {
const params: LoginLogListParams = {
page,
page_size: pageSize,
user_id: userId ? Number(userId) : undefined,
user_id: userId ? parseInt(userId, 10) : undefined,
status: statusFilter,
start_at: startAt,
end_at: endAt,
@@ -82,12 +82,24 @@ export function LoginLogsPage() {
setStartAt(undefined)
setEndAt(undefined)
setPage(1)
// Directly call listLoginLogs with explicit cleared values to avoid stale closure issues
void listLoginLogs({
page: 1,
page_size: pageSize,
user_id: undefined,
status: undefined,
start_at: undefined,
end_at: undefined,
}).then((result) => {
setLogs(result.items)
setTotal(result.total)
})
}
const handleExport = async () => {
try {
await exportLoginLogs({
user_id: userId ? Number(userId) : undefined,
user_id: userId ? parseInt(userId, 10) : undefined,
status: statusFilter,
format: 'csv',
start_at: startAt,

View File

@@ -1,5 +1,5 @@
import type { ReactNode } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -203,7 +203,6 @@ describe('UserDetailDrawer', () => {
/>,
)
await waitFor(() => expect(consoleErrorMock).toHaveBeenCalled())
expect(await screen.findByText('用户信息不存在')).toBeInTheDocument()
})

View File

@@ -1,9 +1,9 @@
/**
* 用户管理页
*
*
* 功能:
* - 用户创建、列表、筛选、详情、编辑、状态切换、删除、角色分配
* - 不包含:批量操作、上传头像、管理员重置密码
* - 批量操作:批量启用、批量禁用、批量删除
*/
import { useState, useEffect, useCallback } from 'react'
@@ -20,6 +20,7 @@ import {
type TableColumnsType,
type TablePaginationConfig,
} from 'antd'
import type { Key } from 'antd/es/table/interface'
import {
SearchOutlined,
ReloadOutlined,
@@ -40,6 +41,8 @@ import {
deleteUser,
updateUserStatus,
getUserRoles,
batchUpdateStatus,
batchDelete,
} from '@/services/users'
import { listRoles } from '@/services/roles'
import type { User, UserListParams, UserStatus } from '@/types/user'
@@ -84,6 +87,9 @@ export function UsersPage() {
const [selectedUser, setSelectedUser] = useState<User | null>(null)
const [selectedUserRoles, setSelectedUserRoles] = useState<Role[]>([])
// 批量选择
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([])
// 加载角色列表
useEffect(() => {
const fetchRoles = async () => {
@@ -218,6 +224,68 @@ export function UsersPage() {
fetchUsers()
}
// 批量启用
const handleBatchEnable = async () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择用户')
return
}
try {
const ids = selectedRowKeys.map(Number)
await batchUpdateStatus(ids, 1)
message.success(`已启用 ${ids.length} 个用户`)
setSelectedRowKeys([])
fetchUsers()
} catch (err) {
message.error(getErrorMessage(err, '批量启用失败'))
}
}
// 批量禁用
const handleBatchDisable = async () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择用户')
return
}
try {
const ids = selectedRowKeys.map(Number)
await batchUpdateStatus(ids, 3)
message.success(`已禁用 ${ids.length} 个用户`)
setSelectedRowKeys([])
fetchUsers()
} catch (err) {
message.error(getErrorMessage(err, '批量禁用失败'))
}
}
// 批量删除
const handleBatchDelete = async () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择用户')
return
}
// 防止删除自己
if (currentUser && selectedRowKeys.includes(currentUser.id)) {
message.error('不能删除当前登录的账号')
return
}
try {
const ids = selectedRowKeys.map(Number)
await batchDelete(ids)
message.success(`已删除 ${ids.length} 个用户`)
setSelectedRowKeys([])
fetchUsers()
} catch (err) {
message.error(getErrorMessage(err, '批量删除失败'))
}
}
// 表格行选择配置
const rowSelection = {
selectedRowKeys,
onChange: (keys: Key[]) => setSelectedRowKeys(keys),
}
// 表格列定义
const columns: TableColumnsType<User> = [
{
@@ -392,6 +460,26 @@ export function UsersPage() {
}
/>
{/* 批量操作工具栏 */}
{selectedRowKeys.length > 0 && (
<div style={{ marginBottom: 16, padding: '8px 16px', background: '#f0f5ff', borderRadius: 4 }}>
<Space>
<span> {selectedRowKeys.length} </span>
<Button size="small" onClick={handleBatchEnable}></Button>
<Button size="small" onClick={handleBatchDisable}></Button>
<Popconfirm
title={`确定要删除选中的 ${selectedRowKeys.length} 个用户吗?此操作不可恢复。`}
onConfirm={handleBatchDelete}
>
<Button size="small" danger></Button>
</Popconfirm>
<Button size="small" type="link" onClick={() => setSelectedRowKeys([])}>
</Button>
</Space>
</div>
)}
{/* 筛选区域 */}
<FilterCard>
<Space wrap size="middle">
@@ -471,6 +559,7 @@ export function UsersPage() {
loading={loading}
pagination={paginationConfig}
scroll={{ x: 1200 }}
rowSelection={rowSelection}
locale={{
emptyText: (
<PageEmpty

View File

@@ -19,17 +19,21 @@ describe('stats service', () => {
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,
users: {
total_users: 100,
active_users: 80,
inactive_users: 10,
locked_users: 5,
disabled_users: 5,
new_users_today: 3,
new_users_week: 15,
new_users_month: 50,
},
logins: {
logins_today_success: 50,
logins_today_failed: 2,
logins_week: 300,
},
}
getMock.mockResolvedValue(mockData)
@@ -38,8 +42,8 @@ describe('stats service', () => {
expect(getMock).toHaveBeenCalledWith('/admin/stats/dashboard')
expect(result).toEqual(mockData)
expect(result.total_users).toBe(100)
expect(result.active_users).toBe(80)
expect(result.users.total_users).toBe(100)
expect(result.users.active_users).toBe(80)
})
it('gets user stats', async () => {

View File

@@ -8,8 +8,9 @@ import { get, post, put, del } from '@/lib/http/client'
import type { PaginatedData } from '@/types/http'
import type { Role } from '@/types/auth'
import type {
CreateUserRequest,
User,
UserStatus,
CreateUserRequest,
UserListParams,
UpdateUserRequest,
UpdateUserStatusRequest,
@@ -79,3 +80,19 @@ export function getUserRoles(id: number): Promise<Role[]> {
export function assignUserRoles(id: number, data: AssignUserRolesRequest): Promise<void> {
return put<void>(`/users/${id}/roles`, data)
}
/**
* 批量更新用户状态
* PUT /api/v1/users/batch/status
*/
export function batchUpdateStatus(ids: number[], status: UserStatus): Promise<{ count: number }> {
return put<{ count: number }>('/users/batch/status', { ids, status })
}
/**
* 批量删除用户
* DELETE /api/v1/users/batch
*/
export function batchDelete(ids: number[]): Promise<{ count: number }> {
return del<{ count: number }>('/users/batch', { body: { ids } })
}

View File

@@ -21,44 +21,49 @@ describe('webhooks service', () => {
})
it('normalizes mixed raw event payloads from the API', async () => {
getMock.mockResolvedValue([
{
id: 1,
name: 'String Events',
url: 'https://example.com/string',
events: '["user.registered"]',
status: 1,
max_retries: 3,
timeout_sec: 10,
created_by: 1,
created_at: '2026-03-27 20:00:00',
updated_at: '2026-03-27 20:00:00',
},
{
id: 2,
name: 'Array Events',
url: 'https://example.com/array',
events: ['user.login'],
status: 0,
max_retries: 3,
timeout_sec: 10,
created_by: 2,
created_at: '2026-03-27 20:05:00',
updated_at: '2026-03-27 20:05:00',
},
{
id: 3,
name: 'Invalid Events',
url: 'https://example.com/invalid',
events: 'not-json',
status: 1,
max_retries: 3,
timeout_sec: 10,
created_by: 3,
created_at: '2026-03-27 20:10:00',
updated_at: '2026-03-27 20:10:00',
},
])
getMock.mockResolvedValue({
data: [
{
id: 1,
name: 'String Events',
url: 'https://example.com/string',
events: '["user.registered"]',
status: 1,
max_retries: 3,
timeout_sec: 10,
created_by: 1,
created_at: '2026-03-27 20:00:00',
updated_at: '2026-03-27 20:00:00',
},
{
id: 2,
name: 'Array Events',
url: 'https://example.com/array',
events: ['user.login'],
status: 0,
max_retries: 3,
timeout_sec: 10,
created_by: 2,
created_at: '2026-03-27 20:05:00',
updated_at: '2026-03-27 20:05:00',
},
{
id: 3,
name: 'Invalid Events',
url: 'https://example.com/invalid',
events: 'not-json',
status: 1,
max_retries: 3,
timeout_sec: 10,
created_by: 3,
created_at: '2026-03-27 20:10:00',
updated_at: '2026-03-27 20:10:00',
},
],
total: 3,
page: 1,
page_size: 20,
})
const { listWebhooks } = await import('./webhooks')
const result = await listWebhooks({ keyword: 'ignored' })

View File

@@ -6,28 +6,21 @@
* 仪表盘统计数据
*/
export interface DashboardStats {
/** 用户总数 */
total_users: number
/** 已激活用户数 */
active_users: number
/** 未激活用户数 */
inactive_users: number
/** 已锁定用户数 */
locked_users: number
/** 已禁用用户数 */
disabled_users: number
/** 今日新增用户 */
today_new_users: number
/** 本周新增用户 */
week_new_users: number
/** 本月新增用户 */
month_new_users: number
/** 今日成功登录数 */
today_success_logins: number
/** 今日失败登录数 */
today_failed_logins: number
/** 本周成功登录数 */
week_success_logins: number
users: {
total_users: number
active_users: number
inactive_users: number
locked_users: number
disabled_users: number
new_users_today: number
new_users_week: number
new_users_month: number
}
logins: {
logins_today_success: number
logins_today_failed: number
logins_week: number
}
}
/**