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

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