feat: admin frontend - React + Vite, auth pages, user management, roles, permissions, webhooks, devices, logs

This commit is contained in:
2026-04-02 11:20:20 +08:00
parent dcc1f186f8
commit 4718980ab5
235 changed files with 35682 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { NotFoundPage } from './NotFoundPage'
const navigateMock = vi.fn()
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom')
return {
...actual,
useNavigate: () => navigateMock,
}
})
describe('NotFoundPage', () => {
it('renders the 404 state and routes users back to the dashboard', async () => {
const user = userEvent.setup()
render(<NotFoundPage />)
expect(screen.getByText('404')).toBeInTheDocument()
expect(screen.getByText('抱歉,您访问的页面不存在')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '返回首页' }))
expect(navigateMock).toHaveBeenCalledWith('/dashboard')
})
})

View File

@@ -0,0 +1,31 @@
/**
* 404 页面
*/
import { Result, Button } from 'antd'
import { useNavigate } from 'react-router-dom'
export function NotFoundPage() {
const navigate = useNavigate()
return (
<div style={{
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--color-canvas)',
}}>
<Result
status="404"
title="404"
subTitle="抱歉,您访问的页面不存在"
extra={
<Button type="primary" onClick={() => navigate('/dashboard')}>
</Button>
}
/>
</div>
)
}

View File

@@ -0,0 +1 @@
export { NotFoundPage } from './NotFoundPage'

View File

@@ -0,0 +1,47 @@
/**
* Dashboard 页面样式
*/
.section {
margin-bottom: 32px;
}
.sectionTitle {
display: block;
margin-bottom: 16px;
font-size: 14px;
font-weight: 500;
}
.statCard {
border-radius: var(--radius-md);
border: 1px solid var(--color-border-soft);
transition: box-shadow var(--motion-fast);
}
.statCard:hover {
box-shadow: var(--shadow-sm);
}
.statCard :global(.ant-statistic-title) {
color: var(--color-text-muted);
font-size: 13px;
}
.statCard :global(.ant-statistic-content) {
font-size: 28px;
}
.infoCard {
margin-top: 24px;
border-radius: var(--radius-md);
background: var(--color-surface-muted);
border: none;
}
/* 响应式 */
@media (max-width: 768px) {
.statCard :global(.ant-statistic-content) {
font-size: 24px;
}
}

View File

@@ -0,0 +1,218 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DashboardStats } from '@/types/stats'
import { DashboardPage } from './DashboardPage'
const getDashboardStatsMock = vi.fn<() => Promise<DashboardStats>>()
const getErrorMessageMock = vi.fn<(error: unknown, fallback: string) => string>()
function flattenChildren(children: ReactNode): string {
if (children === null || children === undefined || typeof children === 'boolean') {
return ''
}
if (typeof children === 'string' || typeof children === 'number') {
return String(children)
}
if (Array.isArray(children)) {
return children.map(flattenChildren).join(' ').trim()
}
if (typeof children === 'object' && 'props' in children) {
return flattenChildren((children as { props?: { children?: ReactNode } }).props?.children)
}
return ''
}
function createDeferred<T>() {
let resolve!: (value: T) => void
let reject!: (error?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
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,
}
vi.mock('antd', () => ({
Col: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
Row: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
Statistic: ({
title,
value,
}: {
title?: ReactNode
value?: ReactNode
}) => (
<div data-testid="statistic">
<span>{flattenChildren(title)}</span>
<span>{String(value)}</span>
</div>
),
Tooltip: ({
children,
title,
}: {
children?: ReactNode
title?: ReactNode
}) => <span data-tooltip={flattenChildren(title)}>{children}</span>,
Typography: {
Text: ({
children,
className,
type,
}: {
children?: ReactNode
className?: string
type?: string
}) => {
void className
void type
return <span>{children}</span>
},
},
}))
vi.mock('@ant-design/icons', () => ({
CloseCircleOutlined: () => <span>close-icon</span>,
InfoCircleOutlined: () => <span>info-icon</span>,
LockOutlined: () => <span>lock-icon</span>,
LoginOutlined: () => <span>login-icon</span>,
StopOutlined: () => <span>stop-icon</span>,
TeamOutlined: () => <span>team-icon</span>,
UserAddOutlined: () => <span>user-add-icon</span>,
UserOutlined: () => <span>user-icon</span>,
}))
vi.mock('@/components/common', () => ({
PageHeader: ({
title,
description,
}: {
title: ReactNode
description?: ReactNode
}) => (
<section data-testid="page-header">
<h1>{flattenChildren(title)}</h1>
<p>{flattenChildren(description)}</p>
</section>
),
}))
vi.mock('@/components/feedback', () => ({
PageLoading: ({ tip }: { tip?: ReactNode }) => (
<div data-testid="page-loading">{flattenChildren(tip)}</div>
),
PageError: ({
description,
onRetry,
}: {
description?: ReactNode
onRetry?: () => void
}) => (
<div data-testid="page-error" data-has-retry={String(Boolean(onRetry))}>
<span>{flattenChildren(description)}</span>
{onRetry ? (
<button type="button" onClick={onRetry}>
retry
</button>
) : null}
</div>
),
}))
vi.mock('@/components/layout', () => ({
ContentCard: ({ children }: { children?: ReactNode }) => (
<div data-testid="content-card">{children}</div>
),
PageLayout: ({ children }: { children?: ReactNode }) => (
<div data-testid="page-layout">{children}</div>
),
}))
vi.mock('@/lib/errors', () => ({
getErrorMessage: (error: unknown, fallback: string) => getErrorMessageMock(error, fallback),
}))
vi.mock('@/services/stats', () => ({
getDashboardStats: () => getDashboardStatsMock(),
}))
describe('DashboardPage', () => {
beforeEach(() => {
getDashboardStatsMock.mockReset()
getErrorMessageMock.mockReset()
getErrorMessageMock.mockImplementation((_, fallback) => fallback)
})
it('shows loading first and then renders the dashboard cards after stats load', async () => {
const deferred = createDeferred<DashboardStats>()
getDashboardStatsMock.mockReturnValueOnce(deferred.promise)
render(<DashboardPage />)
expect(screen.getByTestId('page-loading')).toBeInTheDocument()
expect(getDashboardStatsMock).toHaveBeenCalledTimes(1)
deferred.resolve(sampleStats)
expect(await screen.findByTestId('page-layout')).toBeInTheDocument()
expect(screen.getByTestId('page-header')).toBeInTheDocument()
expect(screen.getAllByTestId('content-card')).toHaveLength(12)
for (const value of Object.values(sampleStats)) {
expect(screen.getByText(String(value))).toBeInTheDocument()
}
})
it('renders a retriable error state and reloads data successfully on retry', async () => {
const user = userEvent.setup()
getDashboardStatsMock
.mockRejectedValueOnce(new Error('network down'))
.mockResolvedValueOnce(sampleStats)
getErrorMessageMock.mockReturnValue('dashboard load failed')
render(<DashboardPage />)
expect(await screen.findByTestId('page-error')).toHaveAttribute('data-has-retry', 'true')
expect(screen.getByText('dashboard load failed')).toBeInTheDocument()
expect(getErrorMessageMock).toHaveBeenCalledWith(expect.any(Error), expect.any(String))
await user.click(screen.getByRole('button', { name: 'retry' }))
expect(await screen.findByTestId('page-layout')).toBeInTheDocument()
expect(getDashboardStatsMock).toHaveBeenCalledTimes(2)
})
it('renders a non-retriable empty-state error when the stats payload is missing', async () => {
getDashboardStatsMock.mockResolvedValueOnce(null as unknown as DashboardStats)
render(<DashboardPage />)
expect(await screen.findByTestId('page-error')).toHaveAttribute('data-has-retry', 'false')
expect(screen.queryByRole('button', { name: 'retry' })).not.toBeInTheDocument()
expect(screen.queryByTestId('page-layout')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,224 @@
/**
* Dashboard 页面
*
* 展示系统统计信息:
* - 用户统计卡片
* - 登录统计卡片
*/
import { useState, useEffect, useCallback } from 'react'
import { Row, Col, Statistic, Typography, Tooltip } from 'antd'
import {
UserOutlined,
TeamOutlined,
LockOutlined,
StopOutlined,
UserAddOutlined,
LoginOutlined,
CloseCircleOutlined,
InfoCircleOutlined,
} from '@ant-design/icons'
import { PageHeader } from '@/components/common'
import { PageLoading, PageError } from '@/components/feedback'
import { PageLayout, ContentCard } from '@/components/layout'
import { getErrorMessage } from '@/lib/errors'
import { getDashboardStats } from '@/services/stats'
import type { DashboardStats } from '@/types/stats'
import styles from './DashboardPage.module.css'
const { Text } = Typography
export function DashboardPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [stats, setStats] = useState<DashboardStats | null>(null)
const fetchStats = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await getDashboardStats()
setStats(data)
} catch (err) {
setError(getErrorMessage(err, '获取统计数据失败'))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchStats()
}, [fetchStats])
if (loading) {
return <PageLoading tip="加载统计数据..." />
}
if (error) {
return <PageError description={error} onRetry={fetchStats} />
}
if (!stats) {
return <PageError description="暂无统计数据" />
}
return (
<PageLayout>
<PageHeader
title="总览"
description="系统运行状态与用户统计"
/>
{/* 用户状态统计 */}
<div className={styles.section}>
<Text type="secondary" className={styles.sectionTitle}>
</Text>
<Row gutter={[16, 16]}>
<Col xs={12} sm={8} lg={6}>
<ContentCard>
<Statistic
title="用户总数"
value={stats.total_users}
prefix={<TeamOutlined />}
valueStyle={{ color: 'var(--color-text-strong)' }}
/>
</ContentCard>
</Col>
<Col xs={12} sm={8} lg={6}>
<ContentCard>
<Statistic
title="已激活"
value={stats.active_users}
prefix={<UserOutlined />}
valueStyle={{ color: 'var(--color-success)' }}
/>
</ContentCard>
</Col>
<Col xs={12} sm={8} lg={6}>
<ContentCard>
<Statistic
title="未激活"
value={stats.inactive_users}
prefix={<UserOutlined />}
valueStyle={{ color: 'var(--color-text-muted)' }}
/>
</ContentCard>
</Col>
<Col xs={12} sm={8} lg={6}>
<ContentCard>
<Statistic
title="已锁定"
value={stats.locked_users}
prefix={<LockOutlined />}
valueStyle={{ color: 'var(--color-warning)' }}
/>
</ContentCard>
</Col>
<Col xs={12} sm={8} lg={6}>
<ContentCard>
<Statistic
title="已禁用"
value={stats.disabled_users}
prefix={<StopOutlined />}
valueStyle={{ color: 'var(--color-danger)' }}
/>
</ContentCard>
</Col>
</Row>
</div>
{/* 新增用户统计 */}
<div className={styles.section}>
<Text type="secondary" className={styles.sectionTitle}>
</Text>
<Row gutter={[16, 16]}>
<Col xs={12} sm={8} lg={6}>
<ContentCard>
<Statistic
title="今日新增"
value={stats.today_new_users}
prefix={<UserAddOutlined />}
valueStyle={{ color: 'var(--color-primary)' }}
/>
</ContentCard>
</Col>
<Col xs={12} sm={8} lg={6}>
<ContentCard>
<Statistic
title="本周新增"
value={stats.week_new_users}
prefix={<UserAddOutlined />}
valueStyle={{ color: 'var(--color-primary)' }}
/>
</ContentCard>
</Col>
<Col xs={12} sm={8} lg={6}>
<ContentCard>
<Statistic
title="本月新增"
value={stats.month_new_users}
prefix={<UserAddOutlined />}
valueStyle={{ color: 'var(--color-primary)' }}
/>
</ContentCard>
</Col>
</Row>
</div>
{/* 登录统计 */}
<div className={styles.section}>
<Text type="secondary" className={styles.sectionTitle}>
</Text>
<Row gutter={[16, 16]}>
<Col xs={12} sm={8} lg={6}>
<ContentCard>
<Statistic
title="今日成功登录"
value={stats.today_success_logins}
prefix={<LoginOutlined />}
valueStyle={{ color: 'var(--color-success)' }}
/>
</ContentCard>
</Col>
<Col xs={12} sm={8} lg={6}>
<ContentCard>
<Statistic
title={
<span>
<Tooltip title="包含密码错误、验证码错误、账号锁定等失败原因">
<InfoCircleOutlined style={{ marginLeft: 4, color: 'var(--color-text-muted)' }} />
</Tooltip>
</span>
}
value={stats.today_failed_logins}
prefix={<CloseCircleOutlined />}
valueStyle={{ color: 'var(--color-danger)' }}
/>
</ContentCard>
</Col>
<Col xs={12} sm={8} lg={6}>
<ContentCard>
<Statistic
title="本周成功登录"
value={stats.week_success_logins}
prefix={<LoginOutlined />}
valueStyle={{ color: 'var(--color-success)' }}
/>
</ContentCard>
</Col>
</Row>
</div>
{/* 说明卡片 */}
<ContentCard>
<Text type="secondary">
线
</Text>
</ContentCard>
</PageLayout>
)
}

View File

@@ -0,0 +1 @@
export { DashboardPage } from './DashboardPage'

View File

@@ -0,0 +1,352 @@
/**
* 设备管理页
*
* 功能:
* - 全局设备列表、筛选(用户、状态、信任状态)、分页
* - 设备详情、信任/取消信任、删除
*/
import { useState, useEffect, useCallback } from 'react'
import {
Table,
Button,
Space,
Tag,
Input,
Select,
Popconfirm,
message,
type TableColumnsType,
type TablePaginationConfig,
} from 'antd'
import {
SearchOutlined,
ReloadOutlined,
DeleteOutlined,
} from '@ant-design/icons'
import dayjs from 'dayjs'
import { PageHeader } from '@/components/common'
import { PageEmpty, PageError } from '@/components/feedback'
import { PageLayout, FilterCard, TableCard } from '@/components/layout'
import { getErrorMessage } from '@/lib/errors'
import {
listAllDevices,
deleteDevice,
trustDevice,
untrustDevice,
} from '@/services/devices'
import type { Device, AdminDeviceListParams, DeviceStatus, DeviceType } from '@/types/device'
import { DeviceTypeText, DeviceStatusText, DeviceStatusColor, DeviceTrustText, DeviceTrustColor } from '@/types/device'
export function DevicesPage() {
// 列表数据
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [devices, setDevices] = useState<Device[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
// 筛选条件
const [keyword, setKeyword] = useState('')
const [userIdFilter, setUserIdFilter] = useState<number | undefined>()
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,
keyword: keyword || undefined,
user_id: userIdFilter,
status: statusFilter,
is_trusted: trustFilter,
}
const result = await listAllDevices(params)
setDevices(result.items)
setTotal(result.total)
} catch (err) {
setError(getErrorMessage(err, '获取设备列表失败'))
} finally {
setLoading(false)
}
}, [page, pageSize, keyword, userIdFilter, statusFilter, trustFilter])
useEffect(() => {
void fetchDevices()
}, [fetchDevices])
// 筛选条件变化时重置到第一页
useEffect(() => {
setPage(1)
}, [keyword, userIdFilter, statusFilter, trustFilter])
// 重置筛选
const handleReset = () => {
setKeyword('')
setUserIdFilter(undefined)
setStatusFilter(undefined)
setTrustFilter(undefined)
setPage(1)
}
// 删除设备
const handleDelete = async (device: Device) => {
try {
await deleteDevice(device.id)
message.success(`设备 ${device.device_name} 已删除`)
void fetchDevices()
} catch (err) {
message.error(getErrorMessage(err, '删除失败'))
}
}
// 信任设备
const handleTrust = async (device: Device) => {
try {
await trustDevice(device.id, '30d')
message.success(`设备 ${device.device_name} 已设为信任`)
void fetchDevices()
} catch (err) {
message.error(getErrorMessage(err, '操作失败'))
}
}
// 取消信任
const handleUntrust = async (device: Device) => {
try {
await untrustDevice(device.id)
message.success(`设备 ${device.device_name} 已取消信任`)
void fetchDevices()
} catch (err) {
message.error(getErrorMessage(err, '操作失败'))
}
}
// 表格列定义
const columns: TableColumnsType<Device> = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
},
{
title: '用户ID',
dataIndex: 'user_id',
key: 'user_id',
width: 80,
},
{
title: '设备名称',
dataIndex: 'device_name',
key: 'device_name',
width: 120,
render: (text) => text || '-',
},
{
title: '设备类型',
dataIndex: 'device_type',
key: 'device_type',
width: 80,
render: (type: DeviceType) => DeviceTypeText[type] || '未知',
},
{
title: '操作系统',
dataIndex: 'device_os',
key: 'device_os',
width: 80,
render: (text) => text || '-',
},
{
title: '浏览器',
dataIndex: 'device_browser',
key: 'device_browser',
width: 80,
render: (text) => text || '-',
},
{
title: 'IP地址',
dataIndex: 'ip',
key: 'ip',
width: 120,
},
{
title: '位置',
dataIndex: 'location',
key: 'location',
width: 120,
render: (text) => text || '-',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 80,
render: (status: DeviceStatus) => (
<Tag color={DeviceStatusColor[status]}>{DeviceStatusText[status]}</Tag>
),
},
{
title: '信任状态',
dataIndex: 'is_trusted',
key: 'is_trusted',
width: 80,
render: (isTrusted: boolean) => (
<Tag color={DeviceTrustColor[String(isTrusted)]}>{DeviceTrustText[String(isTrusted)]}</Tag>
),
},
{
title: '最后活跃',
dataIndex: 'last_active_time',
key: 'last_active_time',
width: 160,
render: (text) => text ? dayjs(text).format('YYYY-MM-DD HH:mm') : '-',
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 160,
render: (text) => dayjs(text).format('YYYY-MM-DD HH:mm'),
},
{
title: '操作',
key: 'action',
width: 180,
fixed: 'right',
render: (_, record) => (
<Space size="small">
{record.is_trusted ? (
<Button type="link" size="small" onClick={() => void handleUntrust(record)}>
</Button>
) : (
<Button type="link" size="small" onClick={() => void handleTrust(record)}>
</Button>
)}
<Popconfirm
title={`确定要删除设备「${record.device_name}」吗?此操作不可恢复。`}
onConfirm={() => void handleDelete(record)}
>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
>
</Button>
</Popconfirm>
</Space>
),
},
]
// 分页配置
const paginationConfig: TablePaginationConfig = {
current: page,
pageSize,
total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
onChange: (p, ps) => {
setPage(p)
setPageSize(ps)
},
}
if (error) {
return <PageError description={error} onRetry={() => void fetchDevices()} />
}
return (
<PageLayout>
<PageHeader
title="设备管理"
description="管理系统所有设备,支持查看、信任状态管理和删除"
actions={
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void fetchDevices()}>
</Button>
</Space>
}
/>
{/* 筛选区域 */}
<FilterCard>
<Space wrap size="middle">
<Input
placeholder="设备名/IP/位置"
prefix={<SearchOutlined />}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onPressEnter={() => void fetchDevices()}
style={{ width: 200 }}
allowClear
/>
<Input
placeholder="用户ID"
type="number"
value={userIdFilter}
onChange={(e) => setUserIdFilter(e.target.value ? Number(e.target.value) : undefined)}
style={{ width: 100 }}
allowClear
/>
<Select
placeholder="设备状态"
value={statusFilter}
onChange={setStatusFilter}
allowClear
style={{ width: 100 }}
options={[
{ value: 0, label: '离线' },
{ value: 1, label: '活跃' },
]}
/>
<Select
placeholder="信任状态"
value={trustFilter}
onChange={setTrustFilter}
allowClear
style={{ width: 100 }}
options={[
{ value: true, label: '已信任' },
{ value: false, label: '未信任' },
]}
/>
<Button type="primary" icon={<SearchOutlined />} onClick={() => void fetchDevices()}>
</Button>
<Button onClick={handleReset}></Button>
</Space>
</FilterCard>
{/* 设备列表 */}
<TableCard>
<Table
columns={columns}
dataSource={devices}
rowKey="id"
loading={loading}
pagination={paginationConfig}
scroll={{ x: 1400 }}
locale={{
emptyText: (
<PageEmpty
description="暂无设备数据"
/>
),
}}
/>
</TableCard>
</PageLayout>
)
}

View File

@@ -0,0 +1 @@
export { DevicesPage } from './DevicesPage'

View File

@@ -0,0 +1,194 @@
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { message } from 'antd'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ImportUsersResult } from '@/types/import-export'
import { ImportExportPage } from './ImportExportPage'
const downloadImportTemplateMock = vi.fn<(format: 'csv' | 'xlsx') => Promise<void>>()
const exportUsersMock = vi.fn<(payload: unknown) => Promise<void>>()
const importUsersMock = vi.fn<(file: File) => Promise<ImportUsersResult>>()
const realGetComputedStyle = window.getComputedStyle.bind(window)
vi.mock('@/services/import-export', () => ({
downloadImportTemplate: (format: 'csv' | 'xlsx') => downloadImportTemplateMock(format),
exportUsers: (payload: unknown) => exportUsersMock(payload),
importUsers: (file: File) => importUsersMock(file),
}))
function getUploadInput() {
const uploadInput = document.querySelector('input[type="file"]')
expect(uploadInput).not.toBeNull()
return uploadInput as HTMLInputElement
}
describe('ImportExportPage', () => {
beforeEach(() => {
downloadImportTemplateMock.mockReset()
exportUsersMock.mockReset()
importUsersMock.mockReset()
vi.spyOn(message, 'success').mockImplementation(() => undefined as never)
vi.spyOn(message, 'error').mockImplementation(() => undefined as never)
vi.spyOn(window, 'getComputedStyle').mockImplementation((element) => realGetComputedStyle(element))
})
afterEach(() => {
vi.restoreAllMocks()
})
it('downloads the default import template format', async () => {
const user = userEvent.setup()
downloadImportTemplateMock.mockResolvedValue(undefined)
render(<ImportExportPage />)
const importPanel = screen.getByRole('tabpanel', { name: /导入用户/ })
await user.click(within(importPanel).getByRole('button', { name: /下载模板/ }))
await waitFor(() => expect(downloadImportTemplateMock).toHaveBeenCalledWith('csv'))
expect(message.success).toHaveBeenCalledTimes(1)
})
it('switches the import format and surfaces template download failures', async () => {
const user = userEvent.setup()
downloadImportTemplateMock.mockRejectedValue(new Error('template failed'))
render(<ImportExportPage />)
const importPanel = screen.getByRole('tabpanel', { name: /导入用户/ })
await user.click(within(importPanel).getByRole('radio', { name: 'Excel (.xlsx)' }))
await user.click(within(importPanel).getByRole('button', { name: /下载模板/ }))
await waitFor(() => expect(downloadImportTemplateMock).toHaveBeenCalledWith('xlsx'))
await waitFor(() => expect(message.error).toHaveBeenCalledWith('template failed'))
})
it('rejects unsupported and oversized import files before hitting the service', async () => {
const user = userEvent.setup({ applyAccept: false })
render(<ImportExportPage />)
await user.upload(getUploadInput(), new File(['hello'], 'users.txt', { type: 'text/plain' }))
await waitFor(() => expect(message.error).toHaveBeenCalledTimes(1))
const oversizedFile = new File([new Uint8Array(11 * 1024 * 1024)], 'users.csv', { type: 'text/csv' })
await user.upload(getUploadInput(), oversizedFile)
await waitFor(() => expect(message.error).toHaveBeenCalledTimes(2))
expect(importUsersMock).not.toHaveBeenCalled()
})
it('surfaces backend import failures for supported files', async () => {
const user = userEvent.setup()
importUsersMock.mockRejectedValue(new Error('import failed'))
render(<ImportExportPage />)
const csvFile = new File(['username,email'], 'users.csv', { type: 'text/csv' })
await user.upload(getUploadInput(), csvFile)
await waitFor(() => expect(importUsersMock).toHaveBeenCalledTimes(1))
await waitFor(() => expect(message.error).toHaveBeenCalledWith('import failed'))
})
it('submits a csv import and renders a success result summary', async () => {
const user = userEvent.setup()
importUsersMock.mockResolvedValue({
success_count: 2,
fail_count: 0,
errors: [],
message: '导入完成',
})
render(<ImportExportPage />)
const csvFile = new File(['username,email'], 'users.csv', { type: 'text/csv' })
await user.upload(getUploadInput(), csvFile)
await waitFor(() => expect(importUsersMock).toHaveBeenCalledTimes(1))
expect(await screen.findByText('成功 2 条,失败 0 条')).toBeInTheDocument()
expect(document.querySelector('.ant-alert-success')).not.toBeNull()
})
it('submits an xlsx import, renders warning details, and resets the import flow', async () => {
const user = userEvent.setup()
importUsersMock.mockResolvedValue({
success_count: 1,
fail_count: 2,
errors: ['row 2 invalid email', 'row 3 duplicate username'],
message: '',
})
render(<ImportExportPage />)
const xlsxFile = new File(['binary'], 'users.xlsx', {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
})
await user.upload(getUploadInput(), xlsxFile)
await waitFor(() => expect(importUsersMock).toHaveBeenCalledTimes(1))
expect(await screen.findByText('row 2 invalid email')).toBeInTheDocument()
expect(screen.getByText('row 3 duplicate username')).toBeInTheDocument()
expect(document.querySelector('.ant-alert-warning')).not.toBeNull()
expect(message.success).toHaveBeenCalledTimes(1)
await user.click(screen.getByRole('button', { name: '继续导入' }))
expect(screen.queryByText('row 2 invalid email')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /下载模板/ })).toBeInTheDocument()
})
it('requires at least one export field and submits the selected export payload', async () => {
const user = userEvent.setup()
exportUsersMock.mockResolvedValue(undefined)
render(<ImportExportPage />)
await user.click(screen.getByRole('tab', { name: /导出用户/ }))
const exportPanel = screen.getByRole('tabpanel', { name: /导出用户/ })
await user.click(within(exportPanel).getByRole('button', { name: '清空' }))
await user.click(within(exportPanel).getByRole('button', { name: /导出用户数据/ }))
expect(exportUsersMock).not.toHaveBeenCalled()
expect(message.error).toHaveBeenCalledTimes(1)
await user.click(within(exportPanel).getByRole('button', { name: '全选' }))
await user.type(within(exportPanel).getByRole('textbox'), 'alice')
await user.click(within(exportPanel).getByRole('radio', { name: /CSV \(\.csv\)/ }))
await user.click(within(exportPanel).getByRole('button', { name: /导出用户数据/ }))
await waitFor(() => expect(exportUsersMock).toHaveBeenCalledWith({
format: 'csv',
fields: ['id', 'username', 'email', 'phone', 'nickname', 'status', 'totp_enabled', 'last_login_time', 'created_at'],
keyword: 'alice',
status: undefined,
}))
expect(message.success).toHaveBeenCalledTimes(1)
})
it('updates export field selection and surfaces export failures with an empty keyword', async () => {
const user = userEvent.setup()
exportUsersMock.mockRejectedValue(new Error('export failed'))
render(<ImportExportPage />)
await user.click(screen.getByRole('tab', { name: /导出用户/ }))
const exportPanel = screen.getByRole('tabpanel', { name: /导出用户/ })
await user.click(within(exportPanel).getByRole('checkbox', { name: /ID/ }))
await user.click(within(exportPanel).getByRole('button', { name: /导出用户数据/ }))
await waitFor(() => expect(exportUsersMock).toHaveBeenCalledWith({
format: 'xlsx',
fields: ['username', 'email', 'phone', 'status', 'created_at'],
keyword: undefined,
status: undefined,
}))
await waitFor(() => expect(message.error).toHaveBeenCalledWith('export failed'))
})
})

View File

@@ -0,0 +1,361 @@
import { useState, type ChangeEvent } from 'react'
import {
Alert,
Button,
Card,
Checkbox,
Col,
Input,
Radio,
Row,
Select,
Space,
Steps,
Table,
Tabs,
Typography,
Upload,
message,
} from 'antd'
import {
DownloadOutlined,
FileExcelOutlined,
InboxOutlined,
UploadOutlined,
} from '@ant-design/icons'
import type { RcFile } from 'antd/es/upload'
import { getErrorMessage } from '@/lib/errors'
import { PageLayout, ContentCard } from '@/components/layout'
import { PageHeader } from '@/components/common'
import {
downloadImportTemplate,
exportUsers,
importUsers,
} from '@/services/import-export'
import type { ImportExportFormat, ImportUsersResult } from '@/types/import-export'
const { Text } = Typography
const { Dragger } = Upload
const exportableFields = [
{ key: 'id', label: '用户 ID' },
{ key: 'username', label: '用户名' },
{ key: 'email', label: '邮箱' },
{ key: 'phone', label: '手机号' },
{ key: 'nickname', label: '昵称' },
{ key: 'status', label: '状态' },
{ key: 'totp_enabled', label: 'TOTP 状态' },
{ key: 'last_login_time', label: '最后登录时间' },
{ key: 'created_at', label: '注册时间' },
]
export function ImportExportPage() {
const [activeTab, setActiveTab] = useState('import')
const [importLoading, setImportLoading] = useState(false)
const [importStep, setImportStep] = useState(0)
const [importFormat, setImportFormat] = useState<ImportExportFormat>('csv')
const [importResult, setImportResult] = useState<ImportUsersResult | null>(null)
const [exportLoading, setExportLoading] = useState(false)
const [exportFormat, setExportFormat] = useState<ImportExportFormat>('xlsx')
const [exportFields, setExportFields] = useState<string[]>([
'id',
'username',
'email',
'phone',
'status',
'created_at',
])
const [exportKeyword, setExportKeyword] = useState('')
const [exportStatus, setExportStatus] = useState<number | undefined>()
const handleImport = async (file: RcFile) => {
const lowerName = file.name.toLowerCase()
const isSupported = lowerName.endsWith('.csv') || lowerName.endsWith('.xlsx')
if (!isSupported) {
message.error('只能上传 CSV 或 XLSX 文件')
return false
}
const isLt10M = file.size / 1024 / 1024 < 10
if (!isLt10M) {
message.error('文件大小不能超过 10MB')
return false
}
try {
setImportLoading(true)
setImportStep(1)
const result = await importUsers(file)
setImportResult(result)
setImportStep(2)
message.success(result.message || '导入完成')
} catch (error) {
setImportStep(0)
message.error(getErrorMessage(error, '导入失败'))
} finally {
setImportLoading(false)
}
return false
}
const handleDownloadTemplate = async () => {
try {
await downloadImportTemplate(importFormat)
message.success('模板下载已开始')
} catch (error) {
message.error(getErrorMessage(error, '下载模板失败'))
}
}
const handleExport = async () => {
if (exportFields.length === 0) {
message.error('请至少选择一个导出字段')
return
}
try {
setExportLoading(true)
await exportUsers({
format: exportFormat,
fields: exportFields,
keyword: exportKeyword || undefined,
status: exportStatus,
})
message.success('导出任务已开始')
} catch (error) {
message.error(getErrorMessage(error, '导出失败'))
} finally {
setExportLoading(false)
}
}
const handleResetImport = () => {
setImportStep(0)
setImportResult(null)
}
const importErrorRows = (importResult?.errors || []).map((error, index) => ({
key: `${index}`,
index: index + 1,
message: error,
}))
const tabItems = [
{
key: 'import',
label: (
<span>
<UploadOutlined />
</span>
),
children: (
<div>
<Steps
current={importStep}
style={{ marginBottom: 24 }}
items={[
{ title: '上传文件' },
{ title: '服务端处理' },
{ title: '完成' },
]}
/>
{importStep !== 2 && (
<>
<Alert
type="info"
showIcon
message="当前真实导入能力"
description={(
<div>
<p>1. `.csv` `.xlsx`</p>
<p>2. 10MB</p>
<p>3. </p>
</div>
)}
style={{ marginBottom: 16 }}
/>
<Card title="模板格式" size="small" style={{ marginBottom: 16 }}>
<Space direction="vertical" style={{ width: '100%' }}>
<Radio.Group value={importFormat} onChange={(event) => setImportFormat(event.target.value)}>
<Radio value="csv">CSV (.csv)</Radio>
<Radio value="xlsx">Excel (.xlsx)</Radio>
</Radio.Group>
<Button icon={<DownloadOutlined />} onClick={() => void handleDownloadTemplate()}>
</Button>
</Space>
</Card>
<Dragger
accept=".csv,.xlsx"
showUploadList={false}
beforeUpload={(file) => {
void handleImport(file)
return false
}}
disabled={importLoading}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint">
CSV / XLSX 10MB
</p>
</Dragger>
</>
)}
{importStep === 2 && importResult && (
<Card>
<Space direction="vertical" style={{ width: '100%' }} size="large">
<Alert
type={importResult.fail_count > 0 ? 'warning' : 'success'}
showIcon
message={importResult.message}
description={`成功 ${importResult.success_count} 条,失败 ${importResult.fail_count}`}
/>
{importErrorRows.length > 0 && (
<Table
rowKey="key"
dataSource={importErrorRows}
pagination={false}
size="small"
columns={[
{ title: '#', dataIndex: 'index', key: 'index', width: 80 },
{ title: '错误信息', dataIndex: 'message', key: 'message' },
]}
/>
)}
<Button onClick={handleResetImport}></Button>
</Space>
</Card>
)}
</div>
),
},
{
key: 'export',
label: (
<span>
<DownloadOutlined />
</span>
),
children: (
<div>
<Alert
type="info"
showIcon
message="当前真实导出能力"
description="后端支持 format、fields、keyword、status 四类参数;不再走前端假流程。"
style={{ marginBottom: 16 }}
/>
<Card title="导出筛选" size="small" style={{ marginBottom: 16 }}>
<Row gutter={[16, 16]}>
<Col xs={24} md={12}>
<Input
placeholder="关键字"
value={exportKeyword}
onChange={(event: ChangeEvent<HTMLInputElement>) => setExportKeyword(event.target.value)}
/>
</Col>
<Col xs={24} md={12}>
<Select
placeholder="用户状态"
value={exportStatus}
onChange={setExportStatus}
allowClear
style={{ width: '100%' }}
options={[
{ value: 0, label: '未激活' },
{ value: 1, label: '已激活' },
{ value: 2, label: '已锁定' },
{ value: 3, label: '已禁用' },
]}
/>
</Col>
</Row>
</Card>
<Card title="导出字段" size="small" style={{ marginBottom: 16 }}>
<Checkbox.Group
value={exportFields}
onChange={(values) => setExportFields(values as string[])}
style={{ width: '100%' }}
>
<Row gutter={[8, 8]}>
{exportableFields.map((field) => (
<Col xs={24} sm={12} md={8} key={field.key}>
<Checkbox value={field.key}>{field.label}</Checkbox>
</Col>
))}
</Row>
</Checkbox.Group>
<div style={{ marginTop: 8 }}>
<Button type="link" size="small" onClick={() => setExportFields(exportableFields.map((field) => field.key))}>
</Button>
<Button type="link" size="small" onClick={() => setExportFields([])}>
</Button>
</div>
</Card>
<Card title="导出格式" size="small" style={{ marginBottom: 16 }}>
<Radio.Group value={exportFormat} onChange={(event) => setExportFormat(event.target.value)}>
<Radio value="xlsx">
<Space>
<FileExcelOutlined />
Excel (.xlsx)
</Space>
</Radio>
<Radio value="csv">
<Space>
<FileExcelOutlined />
CSV (.csv)
</Space>
</Radio>
</Radio.Group>
</Card>
<div style={{ textAlign: 'center' }}>
<Button
type="primary"
size="large"
icon={<DownloadOutlined />}
onClick={() => void handleExport()}
loading={exportLoading}
>
</Button>
</div>
<div style={{ marginTop: 16 }}>
<Text type="secondary">
{exportFields.length} .{exportFormat}
</Text>
</div>
</div>
),
},
]
return (
<PageLayout>
<PageHeader
title="导入导出"
description="对齐真实后端 `/admin/users/import|export|import/template`"
/>
<ContentCard>
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
</ContentCard>
</PageLayout>
)
}

View File

@@ -0,0 +1 @@
export { ImportExportPage } from './ImportExportPage'

View File

@@ -0,0 +1,38 @@
import { Descriptions, Drawer, Tag } from 'antd'
import dayjs from 'dayjs'
import type { LoginLog } from '@/types/login-log'
import { LoginStatusColor, LoginStatusText, LoginTypeText } from '@/types/login-log'
interface LoginLogDetailDrawerProps {
open: boolean
log: LoginLog | null
onClose: () => void
}
export function LoginLogDetailDrawer({ open, log, onClose }: LoginLogDetailDrawerProps) {
if (!log) {
return null
}
return (
<Drawer title="登录日志详情" placement="right" width={640} open={open} onClose={onClose}>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="日志 ID">{log.id}</Descriptions.Item>
<Descriptions.Item label="用户 ID">{log.user_id ?? '-'}</Descriptions.Item>
<Descriptions.Item label="登录类型">
{LoginTypeText[log.login_type] || log.login_type}
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={LoginStatusColor[log.status]}>{LoginStatusText[log.status]}</Tag>
</Descriptions.Item>
<Descriptions.Item label="设备 ID">{log.device_id || '-'}</Descriptions.Item>
<Descriptions.Item label="登录 IP">{log.ip}</Descriptions.Item>
<Descriptions.Item label="地理位置">{log.location || '-'}</Descriptions.Item>
<Descriptions.Item label="失败原因">{log.fail_reason || '-'}</Descriptions.Item>
<Descriptions.Item label="登录时间">
{dayjs(log.created_at).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
</Descriptions>
</Drawer>
)
}

View File

@@ -0,0 +1,21 @@
.filterCard {
margin-bottom: 16px;
}
.tableCard {
margin-bottom: 16px;
}
.detailItem {
margin-bottom: 12px;
}
.detailLabel {
font-weight: 500;
color: var(--color-text);
margin-bottom: 4px;
}
.detailValue {
color: var(--color-text-secondary);
}

View File

@@ -0,0 +1,438 @@
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 { LoginLog, LoginLogListParams, LoginLogListResponse } from '@/types/login-log'
import { LoginLogsPage } from './LoginLogsPage'
const listLoginLogsMock = vi.fn<(params?: LoginLogListParams) => Promise<LoginLogListResponse>>()
vi.mock('antd', async () => {
const React = await import('react')
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 ''
}
const RangePicker = ({
placeholder = ['start', 'end'],
onChange,
}: {
placeholder?: [string, string]
onChange?: (dates: null, dateStrings: [string, string]) => void
}) => {
const [start, setStart] = React.useState('')
const [end, setEnd] = React.useState('')
return (
<div>
<input
aria-label={placeholder[0]}
value={start}
onChange={(event) => {
const nextStart = event.target.value
setStart(nextStart)
onChange?.(null, [nextStart, end])
}}
/>
<input
aria-label={placeholder[1]}
value={end}
onChange={(event) => {
const nextEnd = event.target.value
setEnd(nextEnd)
onChange?.(null, [start, nextEnd])
}}
/>
</div>
)
}
return {
Button: ({
children,
onClick,
htmlType,
type: buttonType,
icon,
...props
}: {
children?: ReactNode
onClick?: () => void
htmlType?: 'button' | 'submit' | 'reset'
[key: string]: unknown
}) => {
void buttonType
void icon
return (
<button type={htmlType ?? 'button'} onClick={onClick} {...props}>
{children}
</button>
)
},
DatePicker: { RangePicker },
Input: ({
onPressEnter,
prefix,
allowClear,
...props
}: {
onPressEnter?: () => void
prefix?: ReactNode
allowClear?: boolean
[key: string]: unknown
}) => {
void prefix
void allowClear
return (
<input
{...props}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onPressEnter?.()
}
}}
/>
)
},
Select: ({
value,
onChange,
options = [],
placeholder,
allowClear,
...props
}: {
value?: string | number
onChange?: (value: unknown) => void
options?: Array<{ value: string | number, label: ReactNode }>
placeholder?: string
allowClear?: boolean
[key: string]: unknown
}) => {
void allowClear
return (
<select
aria-label={placeholder ?? 'select'}
value={value === undefined ? '' : String(value)}
onChange={(event) => {
const nextValue = event.target.value
if (nextValue === '') {
onChange?.(undefined)
return
}
const matchedOption = options.find((option) => String(option.value) === nextValue)
onChange?.(matchedOption?.value ?? nextValue)
}}
{...props}
>
<option value="">{placeholder ?? 'all'}</option>
{options.map((option) => (
<option key={String(option.value)} value={String(option.value)}>
{flattenChildren(option.label)}
</option>
))}
</select>
)
},
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', () => ({
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>
{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/login-logs', () => ({
listLoginLogs: (params?: LoginLogListParams) => listLoginLogsMock(params),
}))
vi.mock('./LoginLogDetailDrawer', () => ({
LoginLogDetailDrawer: ({
open,
log,
onClose,
}: {
open: boolean
log: LoginLog | null
onClose: () => void
}) => (
open ? (
<div data-testid="login-log-detail-drawer">
<span>{`detail:${log?.id ?? 'none'}`}</span>
<button type="button" onClick={onClose}>close detail</button>
</div>
) : null
),
}))
function buildLog(id: number, userId: number, status: 0 | 1): LoginLog {
return {
id,
user_id: userId,
login_type: 1,
device_id: `device-${id}`,
ip: `10.0.0.${id}`,
location: 'Shanghai',
status,
fail_reason: status === 0 ? 'bad password' : undefined,
created_at: `2026-03-27 0${id}:00:00`,
}
}
describe('LoginLogsPage', () => {
let currentLogs: LoginLog[]
beforeEach(() => {
currentLogs = [
buildLog(1, 7, 1),
buildLog(2, 8, 0),
buildLog(3, 7, 0),
]
listLoginLogsMock.mockReset()
listLoginLogsMock.mockImplementation(async (params?: LoginLogListParams) => {
const page = params?.page ?? 1
const pageSize = params?.page_size ?? 20
let items = currentLogs
if (params?.user_id !== undefined) {
items = items.filter((log) => log.user_id === params.user_id)
}
if (params?.status !== undefined) {
items = items.filter((log) => log.status === params.status)
}
const start = (page - 1) * pageSize
const pagedItems = items.slice(start, start + pageSize)
return {
items: pagedItems.map((log) => ({ ...log })),
total: items.length,
page,
page_size: pageSize,
}
})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('loads, filters, resets, paginates, refreshes, and opens detail drawer', async () => {
const user = userEvent.setup()
render(<LoginLogsPage />)
expect(await screen.findByText('10.0.0.1')).toBeInTheDocument()
expect(screen.getByText('10.0.0.2')).toBeInTheDocument()
expect(listLoginLogsMock).toHaveBeenLastCalledWith(expect.objectContaining({
page: 1,
page_size: 20,
user_id: undefined,
status: undefined,
}))
const [refreshButton, searchButton, resetButton] = screen.getAllByRole('button').slice(0, 3)
const [userIdInput] = screen.getAllByRole('textbox')
const statusSelect = screen.getByRole('combobox')
await user.clear(userIdInput)
await user.type(userIdInput, '7abc')
await user.selectOptions(statusSelect, '0')
await user.click(searchButton)
await waitFor(() => expect(screen.queryByText('10.0.0.1')).not.toBeInTheDocument())
expect(screen.getByText('10.0.0.3')).toBeInTheDocument()
expect(listLoginLogsMock).toHaveBeenLastCalledWith(expect.objectContaining({
user_id: 7,
status: 0,
}))
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,
}))
const callCountBeforeRefresh = listLoginLogsMock.mock.calls.length
await user.click(refreshButton)
await waitFor(() => expect(listLoginLogsMock.mock.calls.length).toBeGreaterThan(callCountBeforeRefresh))
await user.click(screen.getByRole('button', { name: 'paginate' }))
await waitFor(() => expect(listLoginLogsMock).toHaveBeenLastCalledWith(expect.objectContaining({
page: 1,
page_size: 50,
})))
const firstRow = screen.getByTestId('table-row-1')
await user.click(within(firstRow).getByRole('button'))
expect(screen.getByTestId('login-log-detail-drawer')).toHaveTextContent('detail:1')
await user.click(screen.getByRole('button', { name: 'close detail' }))
await waitFor(() => expect(screen.queryByTestId('login-log-detail-drawer')).not.toBeInTheDocument())
})
it('renders the page error state and retries fetching', async () => {
const user = userEvent.setup()
listLoginLogsMock.mockReset()
listLoginLogsMock.mockRejectedValueOnce(new Error('login logs failed'))
listLoginLogsMock.mockResolvedValue({
items: currentLogs.map((log) => ({ ...log })),
total: currentLogs.length,
page: 1,
page_size: 20,
})
render(<LoginLogsPage />)
expect(await screen.findByText('login logs failed')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'retry' }))
await waitFor(() => expect(screen.getByText('10.0.0.1')).toBeInTheDocument())
expect(listLoginLogsMock).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,272 @@
import { useCallback, useEffect, useState } from 'react'
import {
Button,
DatePicker,
Input,
message,
Select,
Space,
Table,
Tag,
type TableColumnsType,
type TablePaginationConfig,
} from 'antd'
import { DownloadOutlined, EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'
import dayjs from 'dayjs'
import { PageHeader } from '@/components/common'
import { PageEmpty, PageError } from '@/components/feedback'
import { PageLayout, FilterCard, TableCard } from '@/components/layout'
import { getErrorMessage } from '@/lib/errors'
import { exportLoginLogs, listLoginLogs } from '@/services/login-logs'
import {
LoginStatusColor,
LoginStatusText,
LoginTypeText,
type LoginLog,
type LoginLogListParams,
type LoginStatus,
} from '@/types/login-log'
import { LoginLogDetailDrawer } from './LoginLogDetailDrawer'
const { RangePicker } = DatePicker
export function LoginLogsPage() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [logs, setLogs] = useState<LoginLog[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [userId, setUserId] = useState('')
const [statusFilter, setStatusFilter] = useState<LoginStatus | undefined>()
const [startAt, setStartAt] = useState<string | undefined>()
const [endAt, setEndAt] = useState<string | undefined>()
const [detailVisible, setDetailVisible] = useState(false)
const [selectedLog, setSelectedLog] = useState<LoginLog | null>(null)
const fetchLogs = useCallback(async () => {
setLoading(true)
setError(null)
try {
const params: LoginLogListParams = {
page,
page_size: pageSize,
user_id: userId ? Number(userId) : undefined,
status: statusFilter,
start_at: startAt,
end_at: endAt,
}
const result = await listLoginLogs(params)
setLogs(result.items)
setTotal(result.total)
} catch (err) {
setError(getErrorMessage(err, '获取登录日志失败'))
} finally {
setLoading(false)
}
}, [endAt, page, pageSize, startAt, statusFilter, userId])
useEffect(() => {
void fetchLogs()
}, [fetchLogs])
// 筛选条件变化时重置到第一页
useEffect(() => {
setPage(1)
}, [userId, statusFilter, startAt, endAt])
const handleReset = () => {
setUserId('')
setStatusFilter(undefined)
setStartAt(undefined)
setEndAt(undefined)
setPage(1)
}
const handleExport = async () => {
try {
await exportLoginLogs({
user_id: userId ? Number(userId) : undefined,
status: statusFilter,
format: 'csv',
start_at: startAt,
end_at: endAt,
})
message.success('导出成功')
} catch (err) {
message.error(getErrorMessage(err, '导出失败'))
}
}
const columns: TableColumnsType<LoginLog> = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
},
{
title: '用户 ID',
dataIndex: 'user_id',
key: 'user_id',
width: 100,
render: (value) => value ?? '-',
},
{
title: '登录类型',
dataIndex: 'login_type',
key: 'login_type',
width: 140,
render: (value: LoginLog['login_type']) => LoginTypeText[value] || value,
},
{
title: '设备 ID',
dataIndex: 'device_id',
key: 'device_id',
width: 180,
render: (value) => value || '-',
},
{
title: 'IP',
dataIndex: 'ip',
key: 'ip',
width: 140,
},
{
title: '位置',
dataIndex: 'location',
key: 'location',
width: 180,
render: (value) => value || '-',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: LoginLog['status']) => (
<Tag color={LoginStatusColor[status]}>{LoginStatusText[status]}</Tag>
),
},
{
title: '失败原因',
dataIndex: 'fail_reason',
key: 'fail_reason',
width: 220,
render: (value) => value || '-',
},
{
title: '登录时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180,
render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: '操作',
key: 'action',
width: 100,
fixed: 'right',
render: (_, record) => (
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => {
setSelectedLog(record)
setDetailVisible(true)
}}>
</Button>
),
},
]
const paginationConfig: TablePaginationConfig = {
current: page,
pageSize,
total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (value) => `${value}`,
onChange: (current, size) => {
setPage(current)
setPageSize(size)
},
}
if (error) {
return <PageError description={error} onRetry={fetchLogs} />
}
return (
<PageLayout>
<PageHeader
title="登录日志"
description="对齐后端 `/logs/login` 的真实查询模型,只保留稳定支持的筛选项"
actions={(
<Space>
<Button icon={<DownloadOutlined />} onClick={() => void handleExport()}>
</Button>
<Button icon={<ReloadOutlined />} onClick={() => void fetchLogs()}>
</Button>
</Space>
)}
/>
<FilterCard>
<Space wrap size="middle">
<Input
placeholder="用户 ID"
prefix={<SearchOutlined />}
value={userId}
onChange={(event) => setUserId(event.target.value.replace(/[^\d]/g, ''))}
onPressEnter={() => void fetchLogs()}
style={{ width: 160 }}
allowClear
/>
<Select
placeholder="登录状态"
value={statusFilter}
onChange={setStatusFilter}
allowClear
style={{ width: 120 }}
options={[
{ value: 1, label: '成功' },
{ value: 0, label: '失败' },
]}
/>
<RangePicker
placeholder={['开始时间', '结束时间']}
showTime
onChange={(_, dateStrings) => {
setStartAt(dateStrings[0] || undefined)
setEndAt(dateStrings[1] || undefined)
}}
/>
<Button type="primary" icon={<SearchOutlined />} onClick={() => void fetchLogs()}>
</Button>
<Button onClick={handleReset}></Button>
</Space>
</FilterCard>
<TableCard>
<Table
columns={columns}
dataSource={logs}
rowKey="id"
loading={loading}
pagination={paginationConfig}
scroll={{ x: 1380 }}
locale={{ emptyText: <PageEmpty description="暂无登录日志" /> }}
/>
</TableCard>
<LoginLogDetailDrawer
open={detailVisible}
log={selectedLog}
onClose={() => setDetailVisible(false)}
/>
</PageLayout>
)
}

View File

@@ -0,0 +1 @@
export { LoginLogsPage } from './LoginLogsPage'

View File

@@ -0,0 +1,60 @@
import { Descriptions, Drawer, Tag, Typography } from 'antd'
import dayjs from 'dayjs'
import type { OperationLog } from '@/types/operation-log'
import styles from './OperationLogsPage.module.css'
const { Paragraph, Text } = Typography
interface OperationLogDetailDrawerProps {
open: boolean
log: OperationLog | null
onClose: () => void
}
// Strip HTML tags to prevent XSS when rendering user-controlled fields
function stripHtmlTags(text: string | undefined | null): string {
if (!text) return '-'
return text.replace(/<[^>]*>/g, '')
}
export function OperationLogDetailDrawer({ open, log, onClose }: OperationLogDetailDrawerProps) {
if (!log) {
return null
}
return (
<Drawer title="操作日志详情" placement="right" width={720} open={open} onClose={onClose}>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="日志 ID">{log.id}</Descriptions.Item>
<Descriptions.Item label="用户 ID">{log.user_id ?? '-'}</Descriptions.Item>
<Descriptions.Item label="操作类型">{log.operation_type || '-'}</Descriptions.Item>
<Descriptions.Item label="操作名称">{log.operation_name || '-'}</Descriptions.Item>
<Descriptions.Item label="请求方法">
<Tag>{log.request_method}</Tag>
</Descriptions.Item>
<Descriptions.Item label="请求路径">
<Paragraph copyable style={{ marginBottom: 0 }}>
<Text>{stripHtmlTags(log.request_path)}</Text>
</Paragraph>
</Descriptions.Item>
<Descriptions.Item label="请求参数">
<pre className={styles.codeBlock}><Text>{stripHtmlTags(log.request_params)}</Text></pre>
</Descriptions.Item>
<Descriptions.Item label="状态码">
<Tag color={log.response_status >= 200 && log.response_status < 300 ? 'success' : 'error'}>
{log.response_status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="IP 地址">{log.ip}</Descriptions.Item>
<Descriptions.Item label="User Agent">
<div style={{ wordBreak: 'break-all', fontSize: 12 }}>
<Text>{stripHtmlTags(log.user_agent)}</Text>
</div>
</Descriptions.Item>
<Descriptions.Item label="操作时间">
{dayjs(log.created_at).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
</Descriptions>
</Drawer>
)
}

View File

@@ -0,0 +1,17 @@
.filterCard {
margin-bottom: 16px;
}
.tableCard {
margin-bottom: 16px;
}
.codeBlock {
background: #f5f5f5;
padding: 8px 12px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 12px;
max-height: 200px;
overflow: auto;
}

View File

@@ -0,0 +1,451 @@
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 { OperationLog, OperationLogListParams, OperationLogListResponse } from '@/types/operation-log'
import { OperationLogsPage } from './OperationLogsPage'
const listOperationLogsMock = vi.fn<(params?: OperationLogListParams) => Promise<OperationLogListResponse>>()
vi.mock('antd', async () => {
const React = await import('react')
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 ''
}
const RangePicker = ({
placeholder = ['start', 'end'],
onChange,
}: {
placeholder?: [string, string]
onChange?: (dates: null, dateStrings: [string, string]) => void
}) => {
const [start, setStart] = React.useState('')
const [end, setEnd] = React.useState('')
return (
<div>
<input
aria-label={placeholder[0]}
value={start}
onChange={(event) => {
const nextStart = event.target.value
setStart(nextStart)
onChange?.(null, [nextStart, end])
}}
/>
<input
aria-label={placeholder[1]}
value={end}
onChange={(event) => {
const nextEnd = event.target.value
setEnd(nextEnd)
onChange?.(null, [start, nextEnd])
}}
/>
</div>
)
}
return {
Button: ({
children,
onClick,
htmlType,
type: buttonType,
icon,
...props
}: {
children?: ReactNode
onClick?: () => void
htmlType?: 'button' | 'submit' | 'reset'
[key: string]: unknown
}) => {
void buttonType
void icon
return (
<button type={htmlType ?? 'button'} onClick={onClick} {...props}>
{children}
</button>
)
},
DatePicker: { RangePicker },
Input: ({
onPressEnter,
prefix,
allowClear,
...props
}: {
onPressEnter?: () => void
prefix?: ReactNode
allowClear?: boolean
[key: string]: unknown
}) => {
void prefix
void allowClear
return (
<input
{...props}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onPressEnter?.()
}
}}
/>
)
},
Select: ({
value,
onChange,
options = [],
placeholder,
allowClear,
...props
}: {
value?: string | number
onChange?: (value: unknown) => void
options?: Array<{ value: string | number, label: ReactNode }>
placeholder?: string
allowClear?: boolean
[key: string]: unknown
}) => {
void allowClear
return (
<select
aria-label={placeholder ?? 'select'}
value={value === undefined ? '' : String(value)}
onChange={(event) => {
const nextValue = event.target.value
if (nextValue === '') {
onChange?.(undefined)
return
}
const matchedOption = options.find((option) => String(option.value) === nextValue)
onChange?.(matchedOption?.value ?? nextValue)
}}
{...props}
>
<option value="">{placeholder ?? 'all'}</option>
{options.map((option) => (
<option key={String(option.value)} value={String(option.value)}>
{flattenChildren(option.label)}
</option>
))}
</select>
)
},
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', () => ({
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>
{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/operation-logs', () => ({
listOperationLogs: (params?: OperationLogListParams) => listOperationLogsMock(params),
}))
vi.mock('./OperationLogDetailDrawer', () => ({
OperationLogDetailDrawer: ({
open,
log,
onClose,
}: {
open: boolean
log: OperationLog | null
onClose: () => void
}) => (
open ? (
<div data-testid="operation-log-detail-drawer">
<span>{`detail:${log?.id ?? 'none'}`}</span>
<button type="button" onClick={onClose}>close detail</button>
</div>
) : null
),
}))
function buildLog(id: number, userId: number, requestMethod: string, operationName: string): OperationLog {
return {
id,
user_id: userId,
operation_type: 'user',
operation_name: operationName,
request_method: requestMethod,
request_path: `/api/v1/logs/${id}`,
request_params: `{"id":${id}}`,
response_status: requestMethod === 'GET' ? 200 : 500,
ip: `10.0.1.${id}`,
user_agent: 'Chrome',
created_at: `2026-03-27 1${id}:00:00`,
}
}
describe('OperationLogsPage', () => {
let currentLogs: OperationLog[]
beforeEach(() => {
currentLogs = [
buildLog(1, 9, 'GET', 'fetch users'),
buildLog(2, 9, 'POST', 'create user'),
buildLog(3, 12, 'DELETE', 'delete user'),
]
listOperationLogsMock.mockReset()
listOperationLogsMock.mockImplementation(async (params?: OperationLogListParams) => {
const page = params?.page ?? 1
const pageSize = params?.page_size ?? 20
let items = currentLogs
if (params?.user_id !== undefined) {
items = items.filter((log) => log.user_id === params.user_id)
}
if (params?.method) {
items = items.filter((log) => log.request_method === params.method)
}
if (params?.keyword) {
const keyword = params.keyword.toLowerCase()
items = items.filter((log) => (
log.operation_name.toLowerCase().includes(keyword) ||
log.request_path.toLowerCase().includes(keyword)
))
}
const start = (page - 1) * pageSize
const pagedItems = items.slice(start, start + pageSize)
return {
items: pagedItems.map((log) => ({ ...log })),
total: items.length,
page,
page_size: pageSize,
}
})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('loads, filters, resets, paginates, refreshes, and opens detail drawer', async () => {
const user = userEvent.setup()
render(<OperationLogsPage />)
expect(await screen.findByText('fetch users')).toBeInTheDocument()
expect(screen.getByText('create user')).toBeInTheDocument()
expect(listOperationLogsMock).toHaveBeenLastCalledWith(expect.objectContaining({
page: 1,
page_size: 20,
user_id: undefined,
method: undefined,
keyword: undefined,
}))
const [refreshButton, searchButton, resetButton] = screen.getAllByRole('button').slice(0, 3)
const [userIdInput, keywordInput] = screen.getAllByRole('textbox')
const methodSelect = screen.getByRole('combobox')
await user.type(userIdInput, '9')
await user.selectOptions(methodSelect, 'POST')
await user.type(keywordInput, 'create')
await user.click(searchButton)
await waitFor(() => expect(screen.queryByText('fetch users')).not.toBeInTheDocument())
expect(screen.getByText('create user')).toBeInTheDocument()
expect(listOperationLogsMock).toHaveBeenLastCalledWith(expect.objectContaining({
user_id: 9,
method: 'POST',
keyword: 'create',
}))
await user.click(resetButton)
await waitFor(() => expect(screen.getByText('fetch users')).toBeInTheDocument())
expect(screen.getByText('delete user')).toBeInTheDocument()
expect(listOperationLogsMock).toHaveBeenLastCalledWith(expect.objectContaining({
user_id: undefined,
method: undefined,
keyword: undefined,
}))
const callCountBeforeRefresh = listOperationLogsMock.mock.calls.length
await user.click(refreshButton)
await waitFor(() => expect(listOperationLogsMock.mock.calls.length).toBeGreaterThan(callCountBeforeRefresh))
await user.click(screen.getByRole('button', { name: 'paginate' }))
await waitFor(() => expect(listOperationLogsMock).toHaveBeenLastCalledWith(expect.objectContaining({
page: 1,
page_size: 50,
})))
const firstRow = screen.getByTestId('table-row-1')
await user.click(within(firstRow).getByRole('button'))
expect(screen.getByTestId('operation-log-detail-drawer')).toHaveTextContent('detail:1')
await user.click(screen.getByRole('button', { name: 'close detail' }))
await waitFor(() => expect(screen.queryByTestId('operation-log-detail-drawer')).not.toBeInTheDocument())
})
it('renders the page error state and retries fetching', async () => {
const user = userEvent.setup()
listOperationLogsMock.mockReset()
listOperationLogsMock.mockRejectedValueOnce(new Error('operation logs failed'))
listOperationLogsMock.mockResolvedValue({
items: currentLogs.map((log) => ({ ...log })),
total: currentLogs.length,
page: 1,
page_size: 20,
})
render(<OperationLogsPage />)
expect(await screen.findByText('operation logs failed')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'retry' }))
await waitFor(() => expect(screen.getByText('fetch users')).toBeInTheDocument())
expect(listOperationLogsMock).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,256 @@
import { useCallback, useEffect, useState } from 'react'
import {
Button,
DatePicker,
Input,
Select,
Space,
Table,
Tag,
type TableColumnsType,
type TablePaginationConfig,
} from 'antd'
import { EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'
import dayjs from 'dayjs'
import { PageHeader } from '@/components/common'
import { PageEmpty, PageError } from '@/components/feedback'
import { PageLayout, FilterCard, TableCard } from '@/components/layout'
import { getErrorMessage } from '@/lib/errors'
import { listOperationLogs } from '@/services/operation-logs'
import type { OperationLog, OperationLogListParams } from '@/types/operation-log'
import { OperationLogDetailDrawer } from './OperationLogDetailDrawer'
const { RangePicker } = DatePicker
export function OperationLogsPage() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [logs, setLogs] = useState<OperationLog[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [userId, setUserId] = useState('')
const [method, setMethod] = useState<string | undefined>()
const [keyword, setKeyword] = useState('')
const [startAt, setStartAt] = useState<string | undefined>()
const [endAt, setEndAt] = useState<string | undefined>()
const [detailVisible, setDetailVisible] = useState(false)
const [selectedLog, setSelectedLog] = useState<OperationLog | null>(null)
const fetchLogs = useCallback(async () => {
setLoading(true)
setError(null)
try {
const params: OperationLogListParams = {
page,
page_size: pageSize,
user_id: userId ? Number(userId) : undefined,
method,
keyword: keyword || undefined,
start_at: startAt,
end_at: endAt,
}
const result = await listOperationLogs(params)
setLogs(result.items)
setTotal(result.total)
} catch (err) {
setError(getErrorMessage(err, '获取操作日志失败'))
} finally {
setLoading(false)
}
}, [endAt, keyword, method, page, pageSize, startAt, userId])
useEffect(() => {
void fetchLogs()
}, [fetchLogs])
// 筛选条件变化时重置到第一页
useEffect(() => {
setPage(1)
}, [userId, method, keyword, startAt, endAt])
const handleReset = () => {
setUserId('')
setMethod(undefined)
setKeyword('')
setStartAt(undefined)
setEndAt(undefined)
setPage(1)
}
const columns: TableColumnsType<OperationLog> = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
},
{
title: '用户 ID',
dataIndex: 'user_id',
key: 'user_id',
width: 100,
render: (value) => value ?? '-',
},
{
title: '操作类型',
dataIndex: 'operation_type',
key: 'operation_type',
width: 140,
},
{
title: '操作名称',
dataIndex: 'operation_name',
key: 'operation_name',
width: 180,
},
{
title: '请求方法',
dataIndex: 'request_method',
key: 'request_method',
width: 100,
render: (value) => <Tag>{value}</Tag>,
},
{
title: '请求路径',
dataIndex: 'request_path',
key: 'request_path',
width: 260,
ellipsis: true,
},
{
title: '状态码',
dataIndex: 'response_status',
key: 'response_status',
width: 100,
render: (value) => (
<Tag color={value >= 200 && value < 300 ? 'success' : 'error'}>{value}</Tag>
),
},
{
title: 'IP',
dataIndex: 'ip',
key: 'ip',
width: 140,
},
{
title: '操作时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180,
render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: '操作',
key: 'action',
width: 100,
fixed: 'right',
render: (_, record) => (
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => {
setSelectedLog(record)
setDetailVisible(true)
}}>
</Button>
),
},
]
const paginationConfig: TablePaginationConfig = {
current: page,
pageSize,
total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (value) => `${value}`,
onChange: (current, size) => {
setPage(current)
setPageSize(size)
},
}
if (error) {
return <PageError description={error} onRetry={fetchLogs} />
}
return (
<PageLayout>
<PageHeader
title="操作日志"
description="对齐后端 `/logs/operation` 的真实筛选参数和字段模型"
actions={(
<Button icon={<ReloadOutlined />} onClick={() => void fetchLogs()}>
</Button>
)}
/>
<FilterCard>
<Space wrap size="middle">
<Input
placeholder="用户 ID"
prefix={<SearchOutlined />}
value={userId}
onChange={(event) => setUserId(event.target.value.replace(/[^\d]/g, ''))}
onPressEnter={() => void fetchLogs()}
style={{ width: 160 }}
allowClear
/>
<Select
placeholder="请求方法"
value={method}
onChange={setMethod}
allowClear
style={{ width: 120 }}
options={[
{ value: 'GET', label: 'GET' },
{ value: 'POST', label: 'POST' },
{ value: 'PUT', label: 'PUT' },
{ value: 'DELETE', label: 'DELETE' },
{ value: 'PATCH', label: 'PATCH' },
]}
/>
<Input
placeholder="关键字"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
onPressEnter={() => void fetchLogs()}
style={{ width: 200 }}
allowClear
/>
<RangePicker
placeholder={['开始时间', '结束时间']}
showTime
onChange={(_, dateStrings) => {
setStartAt(dateStrings[0] || undefined)
setEndAt(dateStrings[1] || undefined)
}}
/>
<Button type="primary" icon={<SearchOutlined />} onClick={() => void fetchLogs()}>
</Button>
<Button onClick={handleReset}></Button>
</Space>
</FilterCard>
<TableCard>
<Table
columns={columns}
dataSource={logs}
rowKey="id"
loading={loading}
pagination={paginationConfig}
scroll={{ x: 1460 }}
locale={{ emptyText: <PageEmpty description="暂无操作日志" /> }}
/>
</TableCard>
<OperationLogDetailDrawer
open={detailVisible}
log={selectedLog}
onClose={() => setDetailVisible(false)}
/>
</PageLayout>
)
}

View File

@@ -0,0 +1 @@
export { OperationLogsPage } from './OperationLogsPage'

View File

@@ -0,0 +1,295 @@
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 { Permission } from '@/types/permission'
import { PermissionFormModal } from './PermissionFormModal'
const createPermissionMock = vi.fn()
const updatePermissionMock = vi.fn()
const messageSuccessMock = vi.fn()
const messageErrorMock = vi.fn()
const formApi = {
validateFields: vi.fn(),
setFieldsValue: vi.fn(),
resetFields: vi.fn(),
}
vi.mock('antd', () => {
const Form = ({
children,
}: {
children?: ReactNode
}) => <form>{children}</form>
const Select = Object.assign(({
options = [],
children,
}: {
options?: Array<{ value: number | string, label: ReactNode }>
children?: ReactNode
}) => (
<select>
{options.map((option) => (
<option key={String(option.value)} value={String(option.value)}>
{option.label}
</option>
))}
{children}
</select>
), {
Option: ({
value,
children,
}: {
value: string
children?: ReactNode
}) => <option value={value}>{children}</option>,
})
return {
Modal: ({
open,
title,
onOk,
onCancel,
children,
}: {
open: boolean
title: ReactNode
onOk?: () => void
onCancel?: () => void
children?: ReactNode
}) => (
open ? (
<div data-testid="permission-form-modal-root">
<h1>{title}</h1>
{children}
<button type="button" onClick={onOk}>modal ok</button>
<button type="button" onClick={onCancel}>modal cancel</button>
</div>
) : null
),
Form: Object.assign(Form, {
useForm: () => [formApi],
Item: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}),
Input: ({
placeholder,
disabled,
}: {
placeholder?: string
disabled?: boolean
}) => <input placeholder={placeholder} disabled={disabled} />,
InputNumber: () => <input type="number" />,
Select,
message: {
success: (content: string) => messageSuccessMock(content),
error: (content: string) => messageErrorMock(content),
},
}
})
vi.mock('@/services/permissions', () => ({
createPermission: (payload: unknown) => createPermissionMock(payload),
updatePermission: (id: number, payload: unknown) => updatePermissionMock(id, payload),
}))
function buildPermission(
id: number,
name: string,
code: string,
parentId: number | null = null,
children?: Permission[],
): Permission {
return {
id,
name,
code,
type: 'menu',
parent_id: parentId,
path: `/admin/${code}`,
icon: `${code}-icon`,
sort: id,
status: 1,
children,
}
}
describe('PermissionFormModal', () => {
beforeEach(() => {
createPermissionMock.mockReset()
updatePermissionMock.mockReset()
messageSuccessMock.mockReset()
messageErrorMock.mockReset()
formApi.validateFields.mockReset()
formApi.setFieldsValue.mockReset()
formApi.resetFields.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('creates permissions, applies parent defaults, and resets on cancel', async () => {
const user = userEvent.setup()
const parentPermission = buildPermission(1, 'Dashboard', 'dashboard')
const permissions = [
parentPermission,
buildPermission(2, 'Users', 'user:manage', 1),
]
const onSuccess = vi.fn()
const onClose = vi.fn()
formApi.validateFields.mockResolvedValue({
name: 'Audit API',
code: 'audit:read',
type: 'api',
parent_id: 1,
path: '/admin/audit',
icon: 'AuditOutlined',
sort: undefined,
})
createPermissionMock.mockResolvedValue(undefined)
render(
<PermissionFormModal
open
permission={null}
parentPermission={parentPermission}
permissions={permissions}
onSuccess={onSuccess}
onClose={onClose}
/>,
)
expect(formApi.resetFields).toHaveBeenCalled()
expect(formApi.setFieldsValue).toHaveBeenCalledWith({ parent_id: 1 })
expect(screen.getByText('创建子权限 - Dashboard')).toBeInTheDocument()
expect(screen.getByText('Dashboard')).toBeInTheDocument()
expect(screen.getByText('Users')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'modal ok' }))
await waitFor(() => expect(createPermissionMock).toHaveBeenCalledWith({
name: 'Audit API',
code: 'audit:read',
type: 'api',
parent_id: 1,
path: '/admin/audit',
icon: 'AuditOutlined',
sort: 0,
}))
expect(messageSuccessMock).toHaveBeenCalledWith('权限已创建')
expect(onSuccess).toHaveBeenCalledTimes(1)
await user.click(screen.getByRole('button', { name: 'modal cancel' }))
expect(formApi.resetFields).toHaveBeenCalledTimes(2)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('prefills edit mode, excludes self from parent options, and updates editable fields only', async () => {
const user = userEvent.setup()
const permission = buildPermission(2, 'Users', 'user:manage', 1)
const permissions = [
buildPermission(1, 'Dashboard', 'dashboard', null, [permission]),
buildPermission(3, 'Reports', 'reports:view'),
]
formApi.validateFields.mockResolvedValue({
name: 'Users Updated',
code: 'ignored-code',
type: 'menu',
parent_id: 3,
path: '/admin/users',
icon: 'UserOutlined',
sort: 20,
})
updatePermissionMock.mockResolvedValue(undefined)
render(
<PermissionFormModal
open
permission={permission}
parentPermission={null}
permissions={permissions}
onSuccess={vi.fn()}
onClose={vi.fn()}
/>,
)
expect(formApi.setFieldsValue).toHaveBeenCalledWith({
name: 'Users',
code: 'user:manage',
type: 'menu',
parent_id: 1,
path: '/admin/user:manage',
icon: 'user:manage-icon',
sort: 2,
})
expect(screen.getByText('编辑权限')).toBeInTheDocument()
expect(screen.getByText('Dashboard')).toBeInTheDocument()
expect(screen.getByText('Reports')).toBeInTheDocument()
expect(screen.queryByText('Users')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'modal ok' }))
await waitFor(() => expect(updatePermissionMock).toHaveBeenCalledWith(2, {
name: 'Users Updated',
path: '/admin/users',
icon: 'UserOutlined',
sort: 20,
}))
expect(messageSuccessMock).toHaveBeenCalledWith('权限已更新')
})
it('swallows validation errors and surfaces service failures', async () => {
const user = userEvent.setup()
formApi.validateFields.mockRejectedValueOnce({ errorFields: [{ name: ['name'] }] })
const { rerender } = render(
<PermissionFormModal
open
permission={null}
parentPermission={null}
permissions={[]}
onSuccess={vi.fn()}
onClose={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'modal ok' }))
await waitFor(() => expect(createPermissionMock).not.toHaveBeenCalled())
expect(updatePermissionMock).not.toHaveBeenCalled()
expect(messageErrorMock).not.toHaveBeenCalled()
formApi.validateFields.mockResolvedValueOnce({
name: 'Broken Permission',
code: 'broken:permission',
type: 'api',
parent_id: null,
path: '',
icon: '',
sort: 0,
})
createPermissionMock.mockRejectedValueOnce(new Error('save failed'))
rerender(
<PermissionFormModal
open
permission={null}
parentPermission={null}
permissions={[]}
onSuccess={vi.fn()}
onClose={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'modal ok' }))
await waitFor(() => expect(messageErrorMock).toHaveBeenCalledWith('save failed'))
})
})

View File

@@ -0,0 +1,227 @@
/**
* 权限创建/编辑弹窗
*/
import { useState, useEffect } from 'react'
import {
Modal,
Form,
Input,
Select,
InputNumber,
message,
} from 'antd'
import { getErrorMessage, isFormValidationError } from '@/lib/errors'
import type { Permission, PermissionType } from '@/types/permission'
import { PermissionTypeText } from '@/types/permission'
import { createPermission, updatePermission } from '@/services/permissions'
interface PermissionFormModalProps {
open: boolean
permission: Permission | null
parentPermission: Permission | null
permissions: Permission[]
onSuccess: () => void
onClose: () => void
}
export function PermissionFormModal({
open,
permission,
parentPermission,
permissions,
onSuccess,
onClose,
}: PermissionFormModalProps) {
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const isEdit = !!permission
// 初始化表单
useEffect(() => {
if (open) {
if (permission) {
form.setFieldsValue({
name: permission.name,
code: permission.code,
type: permission.type,
parent_id: permission.parent_id,
path: permission.path,
icon: permission.icon,
sort: permission.sort,
})
} else {
form.resetFields()
// 如果有父权限,设置 parent_id
if (parentPermission) {
form.setFieldsValue({
parent_id: parentPermission.id,
})
}
}
}
}, [open, permission, parentPermission, form])
// 提交表单
const handleSubmit = async () => {
try {
const values = await form.validateFields()
setLoading(true)
if (isEdit && permission) {
await updatePermission(permission.id, {
name: values.name,
path: values.path,
icon: values.icon,
sort: values.sort,
})
message.success('权限已更新')
} else {
await createPermission({
name: values.name,
code: values.code,
type: values.type,
parent_id: values.parent_id || null,
path: values.path,
icon: values.icon,
sort: values.sort || 0,
})
message.success('权限已创建')
}
onSuccess()
} catch (err) {
if (isFormValidationError(err)) {
return
}
message.error(getErrorMessage(err, '操作失败'))
} finally {
setLoading(false)
}
}
// 关闭时重置
const handleClose = () => {
form.resetFields()
onClose()
}
// 构建父权限选项(树形结构)
const buildParentOptions = (perms: Permission[], level = 0): Array<{ value: number; label: string }> => {
const options: Array<{ value: number; label: string }> = []
perms.forEach((perm) => {
// 排除当前权限及其子权限(编辑时不能选择自己或子权限作为父级)
if (isEdit && permission && perm.id === permission.id) {
return
}
options.push({
value: perm.id,
label: `${' '.repeat(level)}${perm.name}`,
})
if (perm.children && perm.children.length > 0) {
options.push(...buildParentOptions(perm.children, level + 1))
}
})
return options
}
return (
<Modal
title={isEdit ? '编辑权限' : parentPermission ? `创建子权限 - ${parentPermission.name}` : '创建权限'}
open={open}
onOk={handleSubmit}
onCancel={handleClose}
confirmLoading={loading}
width={520}
>
<Form
form={form}
layout="vertical"
initialValues={{
type: 'menu' as PermissionType,
sort: 0,
}}
>
<Form.Item
name="name"
label="权限名称"
rules={[
{ required: true, message: '请输入权限名称' },
{ max: 50, message: '权限名称不能超过50个字符' },
]}
>
<Input placeholder="如:用户管理" />
</Form.Item>
<Form.Item
name="code"
label="权限代码"
rules={[
{ required: true, message: '请输入权限代码' },
{ pattern: /^[a-zA-Z][a-zA-Z0-9_:]*$/, message: '权限代码必须以字母开头,只能包含字母、数字、下划线和冒号' },
{ max: 100, message: '权限代码不能超过100个字符' },
]}
extra="权限代码用于权限判断,创建后不可修改"
>
<Input placeholder="如user:manage" disabled={isEdit} />
</Form.Item>
<Form.Item
name="type"
label="权限类型"
rules={[{ required: true, message: '请选择权限类型' }]}
extra="创建后不可修改"
>
<Select disabled={isEdit}>
{Object.entries(PermissionTypeText).map(([value, label]) => (
<Select.Option key={value} value={value}>
{label}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
name="parent_id"
label="父级权限"
extra="不选择则为顶级权限"
>
<Select
placeholder="选择父级权限"
allowClear
showSearch
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
options={buildParentOptions(permissions)}
disabled={isEdit}
/>
</Form.Item>
<Form.Item
name="path"
label="路由路径"
extra="菜单类型权限需要填写路由路径"
>
<Input placeholder="如:/admin/users" />
</Form.Item>
<Form.Item
name="icon"
label="图标"
extra="菜单类型权限可填写图标名称"
>
<Input placeholder="如UserOutlined" />
</Form.Item>
<Form.Item
name="sort"
label="排序"
extra="数值越小越靠前"
>
<InputNumber min={0} max={999} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
)
}

View File

@@ -0,0 +1,31 @@
.treeCard {
margin-bottom: 16px;
}
.treeNode {
display: flex;
align-items: center;
gap: 8px;
}
.nodeInfo {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
}
.nodeName {
font-weight: 500;
}
.nodeCode {
color: var(--color-text-muted);
font-family: monospace;
font-size: 12px;
}
.nodeActions {
display: flex;
gap: 4px;
}

View File

@@ -0,0 +1,403 @@
import type { ReactElement, ReactNode } from 'react'
import { cloneElement, isValidElement } 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 { Permission, PermissionStatus } from '@/types/permission'
import { PermissionsPage } from './PermissionsPage'
const getPermissionTreeMock = vi.fn<() => Promise<Permission[]>>()
const listPermissionsMock = vi.fn<() => Promise<Permission[]>>()
const deletePermissionMock = vi.fn<(id: number) => Promise<void>>()
const updatePermissionStatusMock = vi.fn<(id: number, status: PermissionStatus) => Promise<void>>()
const messageSuccessMock = vi.fn()
const messageErrorMock = vi.fn()
vi.mock('antd', async () => {
type MockTreeNode = {
key: string
title: ReactNode
children?: MockTreeNode[]
}
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 renderTreeNodes(nodes: MockTreeNode[]): ReactNode {
return nodes.map((node) => (
<div key={node.key} data-testid={`tree-node-${node.key}`}>
{node.title}
{node.children ? renderTreeNodes(node.children) : null}
</div>
))
}
return {
Button: ({
children,
onClick,
htmlType,
type: buttonType,
icon,
danger,
...props
}: {
children?: ReactNode
onClick?: () => void
htmlType?: 'button' | 'submit' | 'reset'
[key: string]: unknown
}) => {
void buttonType
void icon
void danger
return (
<button type={htmlType ?? 'button'} onClick={onClick} {...props}>
{children}
</button>
)
},
Popconfirm: ({
children,
onConfirm,
}: {
children: ReactElement
onConfirm?: () => void
}) => {
if (!isValidElement(children)) {
return children
}
const child = children as ReactElement<{ onClick?: () => void }>
return cloneElement(child, {
onClick: () => onConfirm?.(),
})
},
Segmented: ({
options,
onChange,
}: {
options: Array<{ label: ReactNode, value: string }>
onChange?: (value: string) => void
}) => (
<div>
{options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => onChange?.(option.value)}
>
{option.label}
</button>
))}
</div>
),
Space: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
Spin: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
Table: ({
columns,
dataSource,
rowKey,
locale,
}: {
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 }
}) => {
const rows = dataSource ?? []
if (rows.length === 0) {
return <div>{locale?.emptyText ?? null}</div>
}
return (
<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>
)
},
Tag: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
Tree: ({
treeData = [],
}: {
treeData?: MockTreeNode[]
}) => <div>{renderTreeNodes(treeData)}</div>,
message: {
success: (content: string) => messageSuccessMock(content),
error: (content: string) => messageErrorMock(content),
},
}
})
vi.mock('@ant-design/icons', () => ({
DeleteOutlined: () => <span>delete</span>,
EditOutlined: () => <span>edit</span>,
PlusOutlined: () => <span>plus</span>,
ReloadOutlined: () => <span>reload</span>,
}))
vi.mock('@/components/common', () => ({
PageHeader: ({
title,
description,
actions,
}: {
title: ReactNode
description?: ReactNode
actions?: ReactNode
}) => (
<section>
<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>,
TreeCard: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/services/permissions', () => ({
getPermissionTree: () => getPermissionTreeMock(),
listPermissions: () => listPermissionsMock(),
deletePermission: (id: number) => deletePermissionMock(id),
updatePermissionStatus: (id: number, status: PermissionStatus) => updatePermissionStatusMock(id, status),
}))
vi.mock('./PermissionFormModal', () => ({
PermissionFormModal: ({
open,
permission,
parentPermission,
onSuccess,
onClose,
}: {
open: boolean
permission: Permission | null
parentPermission: Permission | null
onSuccess: () => void
onClose: () => void
}) => (
open ? (
<div data-testid="permission-form-modal">
<span>{`permission-form:${permission?.code ?? 'create'}:${parentPermission?.code ?? 'root'}`}</span>
<button type="button" onClick={onSuccess}>permission form success</button>
<button type="button" onClick={onClose}>permission form close</button>
</div>
) : null
),
}))
function buildPermission(
id: number,
name: string,
code: string,
status: PermissionStatus,
parentId: number | null = null,
children?: Permission[],
): Permission {
return {
id,
name,
code,
type: 'menu',
parent_id: parentId,
path: `/admin/${code}`,
icon: `${code}-icon`,
sort: id,
status,
children,
}
}
function flattenPermissions(permissions: Permission[]): Permission[] {
return permissions.flatMap((permission) => [
{ ...permission, children: permission.children?.map((child) => ({ ...child })) },
...(permission.children ? flattenPermissions(permission.children) : []),
])
}
function togglePermissionStatus(permissions: Permission[], id: number, status: PermissionStatus): Permission[] {
return permissions.map((permission) => (
permission.id === id
? { ...permission, status }
: {
...permission,
children: permission.children ? togglePermissionStatus(permission.children, id, status) : undefined,
}
))
}
function deletePermissionFromTree(permissions: Permission[], id: number): Permission[] {
return permissions
.filter((permission) => permission.id !== id)
.map((permission) => ({
...permission,
children: permission.children ? deletePermissionFromTree(permission.children, id) : undefined,
}))
}
describe('PermissionsPage', () => {
let currentTreePermissions: Permission[]
beforeEach(() => {
currentTreePermissions = [
buildPermission(1, 'Dashboard', 'dashboard', 1, null, [
buildPermission(2, 'User Manage', 'user:manage', 1, 1),
]),
buildPermission(3, 'Audit API', 'audit:read', 0),
]
getPermissionTreeMock.mockReset()
listPermissionsMock.mockReset()
deletePermissionMock.mockReset()
updatePermissionStatusMock.mockReset()
messageSuccessMock.mockReset()
messageErrorMock.mockReset()
getPermissionTreeMock.mockImplementation(async () => currentTreePermissions.map((permission) => ({ ...permission })))
listPermissionsMock.mockImplementation(async () => flattenPermissions(currentTreePermissions))
deletePermissionMock.mockImplementation(async (id: number) => {
currentTreePermissions = deletePermissionFromTree(currentTreePermissions, id)
})
updatePermissionStatusMock.mockImplementation(async (id: number, status: PermissionStatus) => {
currentTreePermissions = togglePermissionStatus(currentTreePermissions, id, status)
})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('loads tree/list views and handles create/edit/create-child/refresh/toggle/delete flows', async () => {
const user = userEvent.setup()
render(<PermissionsPage />)
expect(await screen.findByText('Dashboard')).toBeInTheDocument()
expect(screen.getByText('User Manage')).toBeInTheDocument()
expect(getPermissionTreeMock).toHaveBeenCalledTimes(1)
expect(listPermissionsMock).toHaveBeenCalledTimes(1)
const initialFetchCount = getPermissionTreeMock.mock.calls.length
await user.click(screen.getByRole('button', { name: '创建权限' }))
expect(screen.getByTestId('permission-form-modal')).toHaveTextContent('permission-form:create:root')
await user.click(screen.getByRole('button', { name: 'permission form success' }))
await waitFor(() => expect(getPermissionTreeMock.mock.calls.length).toBeGreaterThan(initialFetchCount))
await waitFor(() => expect(screen.queryByTestId('permission-form-modal')).not.toBeInTheDocument())
const dashboardNode = screen.getByTestId('tree-node-1')
await user.click(within(dashboardNode).getAllByRole('button', { name: '添加子权限' })[0])
expect(screen.getByTestId('permission-form-modal')).toHaveTextContent('permission-form:create:dashboard')
await user.click(screen.getByRole('button', { name: 'permission form close' }))
await waitFor(() => expect(screen.queryByTestId('permission-form-modal')).not.toBeInTheDocument())
const childNode = screen.getByTestId('tree-node-2')
await user.click(within(childNode).getByRole('button', { name: '编辑' }))
expect(screen.getByTestId('permission-form-modal')).toHaveTextContent('permission-form:user:manage:root')
await user.click(screen.getByRole('button', { name: 'permission form success' }))
await waitFor(() => expect(screen.queryByTestId('permission-form-modal')).not.toBeInTheDocument())
await user.click(within(childNode).getByRole('button', { name: '禁用' }))
await waitFor(() => expect(updatePermissionStatusMock).toHaveBeenCalledWith(2, 0))
expect(messageSuccessMock).toHaveBeenCalledWith('状态已更新')
await waitFor(() => expect(within(screen.getByTestId('tree-node-2')).getByRole('button', { name: '启用' })).toBeInTheDocument())
const refreshCallCount = getPermissionTreeMock.mock.calls.length
await user.click(screen.getByRole('button', { name: '刷新' }))
await waitFor(() => expect(getPermissionTreeMock.mock.calls.length).toBeGreaterThan(refreshCallCount))
await user.click(screen.getByRole('button', { name: '列表' }))
expect(await screen.findByTestId('table-row-3')).toBeInTheDocument()
await user.click(within(screen.getByTestId('table-row-3')).getByRole('button', { name: '删除' }))
await waitFor(() => expect(deletePermissionMock).toHaveBeenCalledWith(3))
expect(messageSuccessMock).toHaveBeenCalledWith('权限已删除')
await waitFor(() => expect(screen.queryByTestId('table-row-3')).not.toBeInTheDocument())
})
it('renders the page error state and retries fetching', async () => {
const user = userEvent.setup()
getPermissionTreeMock.mockReset()
listPermissionsMock.mockReset()
getPermissionTreeMock.mockRejectedValueOnce(new Error('permissions failed'))
listPermissionsMock.mockResolvedValue([])
getPermissionTreeMock.mockResolvedValue(currentTreePermissions)
listPermissionsMock.mockResolvedValue(flattenPermissions(currentTreePermissions))
render(<PermissionsPage />)
expect(await screen.findByText('permissions failed')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'retry' }))
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
expect(getPermissionTreeMock).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,299 @@
import { useCallback, useEffect, useState } from 'react'
import {
Button,
Popconfirm,
Segmented,
Space,
Spin,
Table,
Tag,
Tree,
message,
type TableColumnsType,
type TreeProps,
} from 'antd'
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'
import { PageHeader } from '@/components/common'
import { PageEmpty, PageError } from '@/components/feedback'
import { PageLayout, TreeCard } from '@/components/layout'
import { getErrorMessage } from '@/lib/errors'
import {
deletePermission,
getPermissionTree,
listPermissions,
updatePermissionStatus,
} from '@/services/permissions'
import type { Permission, PermissionStatus } from '@/types/permission'
import {
PermissionStatusColor,
PermissionStatusText,
PermissionTypeColor,
PermissionTypeText,
} from '@/types/permission'
import { PermissionFormModal } from './PermissionFormModal'
import styles from './PermissionsPage.module.css'
type ViewMode = 'tree' | 'list'
export function PermissionsPage() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [treePermissions, setTreePermissions] = useState<Permission[]>([])
const [flatPermissions, setFlatPermissions] = useState<Permission[]>([])
const [viewMode, setViewMode] = useState<ViewMode>('tree')
const [formVisible, setFormVisible] = useState(false)
const [selectedPermission, setSelectedPermission] = useState<Permission | null>(null)
const [parentPermission, setParentPermission] = useState<Permission | null>(null)
const fetchPermissions = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [treeResult, listResult] = await Promise.all([
getPermissionTree(),
listPermissions(),
])
setTreePermissions(treeResult)
setFlatPermissions(listResult)
} catch (err) {
setError(getErrorMessage(err, '获取权限数据失败'))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void fetchPermissions()
}, [fetchPermissions])
const handleCreate = () => {
setSelectedPermission(null)
setParentPermission(null)
setFormVisible(true)
}
const handleCreateChild = (permission: Permission) => {
setSelectedPermission(null)
setParentPermission(permission)
setFormVisible(true)
}
const handleEdit = (permission: Permission) => {
setSelectedPermission(permission)
setParentPermission(null)
setFormVisible(true)
}
const handleDelete = async (id: number) => {
try {
await deletePermission(id)
message.success('权限已删除')
await fetchPermissions()
} catch (err) {
message.error(getErrorMessage(err, '删除失败'))
}
}
const handleToggleStatus = async (permission: Permission) => {
const nextStatus: PermissionStatus = permission.status === 1 ? 0 : 1
try {
await updatePermissionStatus(permission.id, nextStatus)
message.success('状态已更新')
await fetchPermissions()
} catch (err) {
message.error(getErrorMessage(err, '状态更新失败'))
}
}
const buildTreeData = (permissions: Permission[]): TreeProps['treeData'] =>
permissions.map((permission) => ({
key: String(permission.id),
title: (
<div className={styles.treeNode}>
<div className={styles.nodeInfo}>
<span className={styles.nodeName}>{permission.name}</span>
<Tag color={PermissionTypeColor[permission.type]}>
{PermissionTypeText[permission.type]}
</Tag>
<span className={styles.nodeCode}>{permission.code}</span>
<Tag color={PermissionStatusColor[permission.status]}>
{PermissionStatusText[permission.status]}
</Tag>
{permission.path && (
<span style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>
{permission.path}
</span>
)}
</div>
<div className={styles.nodeActions} onClick={(event) => event.stopPropagation()}>
<Button type="link" size="small" icon={<PlusOutlined />} onClick={() => handleCreateChild(permission)}>
</Button>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleEdit(permission)}>
</Button>
<Popconfirm
title={permission.status === 1 ? '确定禁用该权限吗?' : '确定启用该权限吗?'}
onConfirm={() => void handleToggleStatus(permission)}
>
<Button type="link" size="small">
{permission.status === 1 ? '禁用' : '启用'}
</Button>
</Popconfirm>
<Popconfirm
title="确定删除该权限吗?子权限也会被删除。"
onConfirm={() => void handleDelete(permission.id)}
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</div>
</div>
),
children: permission.children ? buildTreeData(permission.children) : undefined,
}))
const listColumns: TableColumnsType<Permission> = [
{
title: '名称',
dataIndex: 'name',
key: 'name',
width: 180,
},
{
title: '代码',
dataIndex: 'code',
key: 'code',
width: 220,
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
width: 120,
render: (value: Permission['type']) => (
<Tag color={PermissionTypeColor[value]}>{PermissionTypeText[value]}</Tag>
),
},
{
title: '路径',
dataIndex: 'path',
key: 'path',
width: 220,
render: (value) => value || '-',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (value: PermissionStatus) => (
<Tag color={PermissionStatusColor[value]}>{PermissionStatusText[value]}</Tag>
),
},
{
title: '操作',
key: 'action',
width: 240,
fixed: 'right',
render: (_, record) => (
<Space size="small">
<Button type="link" size="small" icon={<PlusOutlined />} onClick={() => handleCreateChild(record)}>
</Button>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>
</Button>
<Popconfirm
title={record.status === 1 ? '确定禁用该权限吗?' : '确定启用该权限吗?'}
onConfirm={() => void handleToggleStatus(record)}
>
<Button type="link" size="small">
{record.status === 1 ? '禁用' : '启用'}
</Button>
</Popconfirm>
<Popconfirm
title="确定删除该权限吗?"
onConfirm={() => void handleDelete(record.id)}
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
]
if (error) {
return <PageError description={error} onRetry={fetchPermissions} />
}
return (
<PageLayout>
<PageHeader
title="权限管理"
description="支持树形和列表两种视图,统一使用真实权限接口"
actions={(
<Space>
<Segmented<ViewMode>
value={viewMode}
onChange={(value) => setViewMode(value)}
options={[
{ label: '树形', value: 'tree' },
{ label: '列表', value: 'list' },
]}
/>
<Button icon={<ReloadOutlined />} onClick={() => void fetchPermissions()}>
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
</Button>
</Space>
)}
/>
<TreeCard>
<Spin spinning={loading}>
{viewMode === 'tree' ? (
treePermissions.length > 0 ? (
<Tree
showLine
defaultExpandAll
treeData={buildTreeData(treePermissions)}
style={{ fontSize: 14 }}
/>
) : (
<PageEmpty description="暂无权限数据" />
)
) : (
<Table
rowKey="id"
dataSource={flatPermissions}
columns={listColumns}
pagination={{ pageSize: 20, showSizeChanger: true }}
scroll={{ x: 1200 }}
locale={{ emptyText: <PageEmpty description="暂无权限数据" /> }}
/>
)}
</Spin>
</TreeCard>
<PermissionFormModal
open={formVisible}
permission={selectedPermission}
parentPermission={parentPermission}
permissions={treePermissions}
onSuccess={() => {
setFormVisible(false)
void fetchPermissions()
}}
onClose={() => setFormVisible(false)}
/>
</PageLayout>
)
}

View File

@@ -0,0 +1 @@
export { PermissionsPage } from './PermissionsPage'

View File

@@ -0,0 +1,330 @@
import type { ReactNode } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import dayjs from 'dayjs'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Role } from '@/types/auth'
import type { CurrentUserProfile } from '@/services/profile'
import { ProfilePage } from './ProfilePage'
const useAuthMock = vi.fn()
const getCurrentProfileMock = vi.fn()
const updateProfileMock = vi.fn()
const messageSuccessMock = vi.fn()
const messageErrorMock = vi.fn()
const formApi = {
setFieldsValue: vi.fn(),
}
let submittedFormValues: Record<string, unknown> = {}
vi.mock('antd', () => {
const Form = ({
children,
onFinish,
}: {
children?: ReactNode
onFinish?: (values: Record<string, unknown>) => void
}) => (
<form
onSubmit={(event) => {
event.preventDefault()
onFinish?.(submittedFormValues)
}}
>
{children}
</form>
)
return {
Alert: ({
message,
description,
}: {
message?: ReactNode
description?: ReactNode
}) => (
<div>
<div>{message}</div>
<div>{description}</div>
</div>
),
Button: ({
children,
htmlType,
onClick,
icon,
type: buttonType,
loading,
...props
}: {
children?: ReactNode
htmlType?: 'button' | 'submit' | 'reset'
onClick?: () => void
[key: string]: unknown
}) => {
void icon
void buttonType
void loading
return (
<button type={htmlType ?? 'button'} onClick={onClick} {...props}>
{children}
</button>
)
},
Col: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DatePicker: ({ placeholder }: { placeholder?: string }) => <input placeholder={placeholder} />,
Form: Object.assign(Form, {
useForm: () => [formApi],
Item: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}),
Input: Object.assign(({
placeholder,
}: {
placeholder?: string
}) => <input placeholder={placeholder} />, {
TextArea: ({ placeholder }: { placeholder?: string }) => <textarea placeholder={placeholder} />,
}),
Row: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
Select: ({
options = [],
placeholder,
}: {
options?: Array<{ value: number | string, label: ReactNode }>
placeholder?: string
}) => (
<select aria-label={placeholder ?? 'select'}>
{options.map((option) => (
<option key={String(option.value)} value={String(option.value)}>
{option.label}
</option>
))}
</select>
),
Space: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
Spin: () => <div>loading</div>,
Tag: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
Typography: {
Text: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
},
message: {
success: (content: string) => messageSuccessMock(content),
error: (content: string) => messageErrorMock(content),
},
}
})
vi.mock('@ant-design/icons', () => ({
SaveOutlined: () => <span>save</span>,
}))
vi.mock('react-router-dom', () => ({
Link: ({
to,
children,
}: {
to: string
children?: ReactNode
}) => <a href={to}>{children}</a>,
}))
vi.mock('@/app/providers/auth-context', () => ({
useAuth: () => useAuthMock(),
}))
vi.mock('@/services/profile', () => ({
getCurrentProfile: (userId: number) => getCurrentProfileMock(userId),
updateProfile: (userId: number, payload: unknown) => updateProfileMock(userId, payload),
}))
vi.mock('@/components/layout', () => ({
PageLayout: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
ContentCard: ({
title,
children,
}: {
title?: ReactNode
children?: ReactNode
}) => (
<section>
<h2>{title}</h2>
{children}
</section>
),
}))
vi.mock('@/components/common', () => ({
PageHeader: ({
title,
description,
}: {
title: ReactNode
description?: ReactNode
}) => (
<section>
<h1>{title}</h1>
<p>{description}</p>
</section>
),
}))
function buildProfile(): CurrentUserProfile {
const roles: Role[] = [
{
id: 1,
name: 'Admin',
code: 'admin',
description: 'system admin',
is_system: true,
is_default: false,
status: 1,
},
{
id: 2,
name: 'Auditor',
code: 'auditor',
description: 'audit role',
is_system: false,
is_default: false,
status: 1,
},
]
return {
user: {
id: 8,
username: 'alice',
email: 'alice@example.com',
phone: '13800000008',
nickname: 'Alice',
avatar: '',
gender: 2,
birthday: '2026-03-01',
region: 'Shanghai',
bio: 'Hello world',
status: 1,
last_login_at: '2026-03-27 10:30:00',
last_login_ip: '127.0.0.1',
created_at: '2026-03-20 09:00:00',
updated_at: '2026-03-27 09:00:00',
},
roles,
}
}
describe('ProfilePage', () => {
let refreshUserMock: ReturnType<typeof vi.fn>
beforeEach(() => {
refreshUserMock = vi.fn().mockResolvedValue(undefined)
submittedFormValues = {}
useAuthMock.mockReturnValue({
user: {
id: 8,
username: 'alice',
email: 'alice@example.com',
phone: '13800000008',
nickname: 'Alice',
avatar: '',
status: 1,
},
refreshUser: refreshUserMock,
roles: [],
isAdmin: false,
isAuthenticated: true,
isLoading: false,
onLoginSuccess: vi.fn(),
logout: vi.fn(),
})
getCurrentProfileMock.mockReset()
updateProfileMock.mockReset()
messageSuccessMock.mockReset()
messageErrorMock.mockReset()
formApi.setFieldsValue.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('loads profile data, hydrates the form, and submits updates successfully', async () => {
const user = userEvent.setup()
const profile = buildProfile()
getCurrentProfileMock.mockResolvedValue(profile)
updateProfileMock.mockResolvedValue(profile.user)
submittedFormValues = {
nickname: 'Alice Updated',
gender: 1,
birthday: dayjs('2026-03-15'),
region: 'Beijing',
bio: 'Updated bio',
}
render(<ProfilePage />)
expect(await screen.findByText('alice')).toBeInTheDocument()
expect(screen.getByText('alice@example.com')).toBeInTheDocument()
expect(screen.getByText('Admin')).toBeInTheDocument()
expect(screen.getByText('Auditor')).toBeInTheDocument()
expect(formApi.setFieldsValue).toHaveBeenCalledWith(expect.objectContaining({
nickname: 'Alice',
gender: 2,
region: 'Shanghai',
bio: 'Hello world',
}))
await user.click(screen.getByRole('button', { name: '保存修改' }))
await waitFor(() => expect(updateProfileMock).toHaveBeenCalledWith(8, {
nickname: 'Alice Updated',
gender: 1,
birthday: '2026-03-15',
region: 'Beijing',
bio: 'Updated bio',
}))
await waitFor(() => expect(getCurrentProfileMock).toHaveBeenCalledTimes(2))
expect(refreshUserMock).toHaveBeenCalledTimes(1)
expect(messageSuccessMock).toHaveBeenCalledWith('资料更新成功')
})
it('surfaces profile fetch failures and still renders the page shell', async () => {
getCurrentProfileMock.mockRejectedValueOnce(new Error('profile failed'))
render(<ProfilePage />)
await waitFor(() => expect(messageErrorMock).toHaveBeenCalledWith('profile failed'))
expect(await screen.findByText('个人资料')).toBeInTheDocument()
expect(screen.getByText('暂无角色')).toBeInTheDocument()
})
it('surfaces update failures without refreshing the user session', async () => {
const user = userEvent.setup()
const profile = buildProfile()
getCurrentProfileMock.mockResolvedValue(profile)
updateProfileMock.mockRejectedValueOnce(new Error('update failed'))
submittedFormValues = {
nickname: 'Broken Update',
gender: 0,
birthday: dayjs('2026-03-16'),
region: 'Hangzhou',
bio: 'Broken bio',
}
render(<ProfilePage />)
expect(await screen.findByText('alice')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '保存修改' }))
await waitFor(() => expect(messageErrorMock).toHaveBeenCalledWith('update failed'))
expect(refreshUserMock).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,265 @@
import { useCallback, useEffect, useState } from 'react'
import {
Alert,
Button,
Col,
DatePicker,
Form,
Input,
Row,
Select,
Space,
Spin,
Tag,
Typography,
message,
} from 'antd'
import { SaveOutlined } from '@ant-design/icons'
import dayjs from 'dayjs'
import type { Dayjs } from 'dayjs'
import { Link } from 'react-router-dom'
import { useAuth } from '@/app/providers/auth-context'
import { getErrorMessage } from '@/lib/errors'
import { getCurrentProfile, updateProfile } from '@/services/profile'
import type { CurrentUserProfile } from '@/services/profile'
import type { UpdateUserRequest } from '@/types/user'
import { PageLayout, ContentCard } from '@/components/layout'
import { PageHeader } from '@/components/common'
const { Text } = Typography
const genderOptions = [
{ value: 0, label: '保密' },
{ value: 1, label: '男' },
{ value: 2, label: '女' },
]
interface ProfileFormValues {
nickname?: string
gender?: 0 | 1 | 2
birthday?: Dayjs | null
region?: string
bio?: string
}
export function ProfilePage() {
const { user, refreshUser } = useAuth()
const [form] = Form.useForm<ProfileFormValues>()
const [loading, setLoading] = useState(false)
const [fetching, setFetching] = useState(true)
const [profileData, setProfileData] = useState<CurrentUserProfile | null>(null)
const fetchProfile = useCallback(async () => {
if (!user) {
return
}
try {
setFetching(true)
const data = await getCurrentProfile(user.id)
setProfileData(data)
form.setFieldsValue({
nickname: data.user.nickname || undefined,
gender: data.user.gender,
birthday: data.user.birthday ? dayjs(data.user.birthday) : null,
region: data.user.region || undefined,
bio: data.user.bio || undefined,
})
} catch (error) {
message.error(getErrorMessage(error, '获取个人资料失败'))
} finally {
setFetching(false)
}
}, [form, user])
useEffect(() => {
void fetchProfile()
}, [fetchProfile])
const handleSubmit = async (values: ProfileFormValues) => {
if (!user) {
return
}
const payload: UpdateUserRequest = {
nickname: values.nickname,
gender: values.gender,
birthday: values.birthday?.format('YYYY-MM-DD'),
region: values.region,
bio: values.bio,
}
try {
setLoading(true)
await updateProfile(user.id, payload)
await Promise.all([fetchProfile(), refreshUser()])
message.success('资料更新成功')
} catch (error) {
message.error(getErrorMessage(error, '资料更新失败'))
} finally {
setLoading(false)
}
}
if (fetching) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 100 }}>
<Spin size="large" />
</div>
)
}
return (
<PageLayout>
<PageHeader
title="个人资料"
description="维护当前账号的基础资料信息"
/>
<Alert
type="info"
showIcon
message="头像上传已迁移到安全设置"
description="当前后端仅提供头像上传接口,头像相关操作统一放到「安全设置」页处理。"
style={{ marginBottom: 24 }}
/>
<Row gutter={24}>
<Col xs={24} lg={16}>
<ContentCard title="基本信息">
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Alert
type="info"
showIcon
message="邮箱和手机号已迁移到安全设置管理"
description={<Link to="/profile/security"></Link>}
style={{ marginBottom: 16 }}
/>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item
name="nickname"
label="昵称"
rules={[{ max: 50, message: '昵称不能超过 50 个字符' }]}
>
<Input placeholder="请输入昵称" />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="gender" label="性别">
<Select options={genderOptions} placeholder="请选择性别" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item name="birthday" label="生日">
<DatePicker
style={{ width: '100%' }}
placeholder="请选择生日"
disabledDate={(current) => current && current > dayjs().endOf('day')}
/>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item
name="region"
label="地区"
rules={[{ max: 100, message: '地区不能超过 100 个字符' }]}
>
<Input placeholder="请输入地区" />
</Form.Item>
</Col>
</Row>
<Form.Item
name="bio"
label="个人简介"
rules={[{ max: 500, message: '简介不能超过 500 个字符' }]}
>
<Input.TextArea rows={4} maxLength={500} showCount placeholder="介绍一下自己..." />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button
type="primary"
htmlType="submit"
icon={<SaveOutlined />}
loading={loading}
>
</Button>
</Form.Item>
</Form>
</ContentCard>
</Col>
<Col xs={24} lg={8}>
<ContentCard title="账号信息">
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<div>
<Text type="secondary"> ID</Text>
<div>
<Text copyable>{profileData?.user.id}</Text>
</div>
</div>
<div>
<Text type="secondary"></Text>
<div>
<Text strong>{profileData?.user.username || '-'}</Text>
</div>
</div>
<div>
<Text type="secondary"></Text>
<div>
<Text>
{profileData?.user.created_at
? dayjs(profileData.user.created_at).format('YYYY-MM-DD HH:mm')
: '-'}
</Text>
</div>
</div>
<div>
<Text type="secondary"></Text>
<div>
<Text>
{profileData?.user.last_login_at
? dayjs(profileData.user.last_login_at).format('YYYY-MM-DD HH:mm')
: '-'}
</Text>
</div>
</div>
<div>
<Text type="secondary"></Text>
<div>
<Text>{profileData?.user.email || '未绑定'}</Text>
</div>
</div>
<div>
<Text type="secondary"></Text>
<div>
<Text>{profileData?.user.phone || '未绑定'}</Text>
</div>
</div>
</Space>
</ContentCard>
<ContentCard title="当前角色" style={{ marginTop: 16 }}>
<Space wrap>
{profileData?.roles.map((role) => (
<Tag key={role.id} color={role.code === 'admin' ? 'processing' : 'default'}>
{role.name}
</Tag>
))}
</Space>
{(!profileData?.roles || profileData.roles.length === 0) && (
<Text type="secondary"></Text>
)}
</ContentCard>
</Col>
</Row>
</PageLayout>
)
}

View File

@@ -0,0 +1 @@
export { ProfilePage } from './ProfilePage'

View File

@@ -0,0 +1,126 @@
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 { ContactBindingsSection } from './ContactBindingsSection'
const getCurrentProfileMock = vi.fn()
const sendEmailBindCodeMock = vi.fn()
const bindEmailMock = vi.fn()
const unbindPhoneMock = vi.fn()
const originalGetComputedStyle = window.getComputedStyle
vi.mock('@/services/profile', () => ({
getCurrentProfile: (...args: unknown[]) => getCurrentProfileMock(...args),
}))
vi.mock('@/services/account-bindings', () => ({
sendEmailBindCode: (...args: unknown[]) => sendEmailBindCodeMock(...args),
bindEmail: (...args: unknown[]) => bindEmailMock(...args),
bindPhone: vi.fn(),
sendPhoneBindCode: vi.fn(),
unbindEmail: vi.fn(),
unbindPhone: (...args: unknown[]) => unbindPhoneMock(...args),
}))
describe('ContactBindingsSection', () => {
afterEach(() => {
vi.restoreAllMocks()
})
beforeEach(() => {
getCurrentProfileMock.mockReset()
sendEmailBindCodeMock.mockReset()
bindEmailMock.mockReset()
unbindPhoneMock.mockReset()
getCurrentProfileMock.mockResolvedValue({
user: {
id: 1,
username: 'admin',
email: '',
phone: '13812345678',
nickname: 'Admin',
avatar: '',
gender: 0,
birthday: '',
region: '',
bio: '',
status: 1,
last_login_at: '',
last_login_ip: '',
created_at: '',
updated_at: '',
},
roles: [],
})
sendEmailBindCodeMock.mockResolvedValue(undefined)
bindEmailMock.mockResolvedValue(undefined)
unbindPhoneMock.mockResolvedValue(undefined)
vi.spyOn(window, 'getComputedStyle').mockImplementation((element) => {
return originalGetComputedStyle.call(window, element)
})
})
it('sends a bind code and binds email after verification', async () => {
const user = userEvent.setup()
const refreshSessionUser = vi.fn().mockResolvedValue(undefined)
render(
<ContactBindingsSection
userId={1}
emailBindingEnabled
phoneBindingEnabled
refreshSessionUser={refreshSessionUser}
/>,
)
await waitFor(() => expect(screen.getByRole('button', { name: '绑定邮箱' })).toBeInTheDocument())
await user.click(screen.getByRole('button', { name: '绑定邮箱' }))
await user.type(screen.getByPlaceholderText('请输入新邮箱'), 'bind@example.com')
await user.click(screen.getByRole('button', { name: '发送验证码' }))
await waitFor(() => expect(sendEmailBindCodeMock).toHaveBeenCalledWith({ email: 'bind@example.com' }))
await user.type(screen.getByPlaceholderText('请输入验证码'), '123456')
await user.type(screen.getByPlaceholderText('建议填写当前密码'), 'SecurePass123')
await user.click(screen.getByRole('button', { name: '确认绑定' }))
await waitFor(() =>
expect(bindEmailMock).toHaveBeenCalledWith({
email: 'bind@example.com',
code: '123456',
current_password: 'SecurePass123',
totp_code: undefined,
}),
)
expect(refreshSessionUser).toHaveBeenCalled()
})
it('unbinds phone with the provided verification payload', async () => {
const user = userEvent.setup()
render(
<ContactBindingsSection
userId={1}
emailBindingEnabled
phoneBindingEnabled
refreshSessionUser={vi.fn().mockResolvedValue(undefined)}
/>,
)
await waitFor(() => expect(screen.getByRole('button', { name: '解绑手机号' })).toBeInTheDocument())
await user.click(screen.getByRole('button', { name: '解绑手机号' }))
await user.type(screen.getByPlaceholderText('填写当前密码'), 'SecurePass123')
await user.click(screen.getByRole('button', { name: '确认解绑' }))
await waitFor(() =>
expect(unbindPhoneMock).toHaveBeenCalledWith({
current_password: 'SecurePass123',
totp_code: undefined,
}),
)
})
})

View File

@@ -0,0 +1,395 @@
import { useCallback, useEffect, useState } from 'react'
import {
Alert,
Button,
Col,
Form,
Input,
Modal,
Row,
Space,
Tag,
Typography,
message,
} from 'antd'
import { MailOutlined, PhoneOutlined } from '@ant-design/icons'
import { ContentCard } from '@/components/layout'
import { getErrorMessage } from '@/lib/errors'
import {
bindEmail,
bindPhone,
sendEmailBindCode,
sendPhoneBindCode,
unbindEmail,
unbindPhone,
} from '@/services/account-bindings'
import { getCurrentProfile, type CurrentUserProfile } from '@/services/profile'
const { Text } = Typography
type BindingChannel = 'email' | 'phone'
interface BindFormValues {
email?: string
phone?: string
code?: string
current_password?: string
totp_code?: string
}
interface UnbindFormValues {
current_password?: string
totp_code?: string
}
interface ContactBindingsSectionProps {
userId: number
emailBindingEnabled: boolean
phoneBindingEnabled: boolean
refreshSessionUser: () => Promise<void>
}
function getChannelLabel(channel: BindingChannel): string {
return channel === 'email' ? '邮箱' : '手机号'
}
function maskEmail(value: string): string {
const [localPart, domain] = value.split('@')
if (!localPart || !domain) {
return value
}
if (localPart.length <= 2) {
return `${localPart[0] ?? ''}***@${domain}`
}
return `${localPart.slice(0, 2)}***@${domain}`
}
function maskPhone(value: string): string {
if (value.length <= 7) {
return value
}
return `${value.slice(0, 3)}****${value.slice(-4)}`
}
export function ContactBindingsSection({
userId,
emailBindingEnabled,
phoneBindingEnabled,
refreshSessionUser,
}: ContactBindingsSectionProps) {
const [bindForm] = Form.useForm<BindFormValues>()
const [unbindForm] = Form.useForm<UnbindFormValues>()
const [profile, setProfile] = useState<CurrentUserProfile | null>(null)
const [loading, setLoading] = useState(true)
const [bindVisible, setBindVisible] = useState(false)
const [unbindVisible, setUnbindVisible] = useState(false)
const [bindSubmitting, setBindSubmitting] = useState(false)
const [unbindSubmitting, setUnbindSubmitting] = useState(false)
const [sendCodeLoading, setSendCodeLoading] = useState(false)
const [activeChannel, setActiveChannel] = useState<BindingChannel | null>(null)
const fetchProfile = useCallback(async () => {
try {
setLoading(true)
const nextProfile = await getCurrentProfile(userId)
setProfile(nextProfile)
} catch (error) {
message.error(getErrorMessage(error, '获取账号绑定状态失败'))
} finally {
setLoading(false)
}
}, [userId])
useEffect(() => {
void fetchProfile()
}, [fetchProfile])
const refreshAll = useCallback(async () => {
await Promise.all([fetchProfile(), refreshSessionUser()])
}, [fetchProfile, refreshSessionUser])
const openBindModal = (channel: BindingChannel) => {
setActiveChannel(channel)
bindForm.resetFields()
setBindVisible(true)
}
const openUnbindModal = (channel: BindingChannel) => {
setActiveChannel(channel)
unbindForm.resetFields()
setUnbindVisible(true)
}
const closeBindModal = () => {
setBindVisible(false)
setActiveChannel(null)
bindForm.resetFields()
}
const closeUnbindModal = () => {
setUnbindVisible(false)
setActiveChannel(null)
unbindForm.resetFields()
}
const handleSendCode = async () => {
if (!activeChannel) {
return
}
try {
setSendCodeLoading(true)
if (activeChannel === 'email') {
const values = await bindForm.validateFields(['email'])
await sendEmailBindCode({ email: String(values.email).trim() })
} else {
const values = await bindForm.validateFields(['phone'])
await sendPhoneBindCode({ phone: String(values.phone).trim() })
}
message.success(`${getChannelLabel(activeChannel)}验证码已发送`)
} catch (error) {
message.error(getErrorMessage(error, `发送${getChannelLabel(activeChannel)}验证码失败`))
} finally {
setSendCodeLoading(false)
}
}
const handleBind = async (values: BindFormValues) => {
if (!activeChannel) {
return
}
try {
setBindSubmitting(true)
const verification = {
current_password: values.current_password?.trim() || undefined,
totp_code: values.totp_code?.trim() || undefined,
}
if (activeChannel === 'email') {
await bindEmail({
email: values.email?.trim() || '',
code: values.code?.trim() || '',
...verification,
})
} else {
await bindPhone({
phone: values.phone?.trim() || '',
code: values.code?.trim() || '',
...verification,
})
}
message.success(`${getChannelLabel(activeChannel)}已更新`)
closeBindModal()
await refreshAll()
} catch (error) {
message.error(getErrorMessage(error, `${getChannelLabel(activeChannel)}绑定失败`))
} finally {
setBindSubmitting(false)
}
}
const handleUnbind = async (values: UnbindFormValues) => {
if (!activeChannel) {
return
}
try {
setUnbindSubmitting(true)
const verification = {
current_password: values.current_password?.trim() || undefined,
totp_code: values.totp_code?.trim() || undefined,
}
if (activeChannel === 'email') {
await unbindEmail(verification)
} else {
await unbindPhone(verification)
}
message.success(`${getChannelLabel(activeChannel)}已解绑`)
closeUnbindModal()
await refreshAll()
} catch (error) {
message.error(getErrorMessage(error, `${getChannelLabel(activeChannel)}解绑失败`))
} finally {
setUnbindSubmitting(false)
}
}
const renderBindingCard = (channel: BindingChannel) => {
const isEmail = channel === 'email'
const label = getChannelLabel(channel)
const value = isEmail ? profile?.user.email || '' : profile?.user.phone || ''
const isBound = value !== ''
const bindingEnabled = isEmail ? emailBindingEnabled : phoneBindingEnabled
const displayValue = isEmail ? maskEmail(value) : maskPhone(value)
return (
<div
style={{
border: '1px solid #f0f0f0',
borderRadius: 12,
padding: 16,
height: '100%',
background: '#fff',
}}
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Space wrap>
{isEmail ? <MailOutlined /> : <PhoneOutlined />}
<Text strong>{label}</Text>
<Tag color={isBound ? 'success' : 'default'}>{isBound ? '已绑定' : '未绑定'}</Tag>
{!bindingEnabled && <Tag color="warning"></Tag>}
</Space>
<div>
<Text type="secondary">{label}</Text>
<div style={{ marginTop: 4 }}>
<Text>{isBound ? displayValue : '未绑定'}</Text>
</div>
</div>
<Text type="secondary">
{bindingEnabled
? `绑定或更换${label}时,系统会校验新${label}验证码以及当前账号身份。`
: `当前环境未启用${label}验证码通道,暂不可发起绑定或更换。`}
</Text>
<Space wrap>
<Button type="primary" disabled={!bindingEnabled} onClick={() => openBindModal(channel)}>
{isBound ? `更换${label}` : `绑定${label}`}
</Button>
{isBound && (
<Button danger onClick={() => openUnbindModal(channel)}>
{`解绑${label}`}
</Button>
)}
</Space>
</Space>
</div>
)
}
return (
<>
<ContentCard title="邮箱 / 手机号绑定" style={{ marginTop: 24 }}>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="邮箱和手机号属于账号登录方式管理。绑定或更换需要验证码和当前账号验证,解绑会校验当前账号身份并保留至少一种登录方式。"
/>
{loading ? (
<Text type="secondary">...</Text>
) : (
<Row gutter={[16, 16]}>
<Col xs={24} xl={12}>
{renderBindingCard('email')}
</Col>
<Col xs={24} xl={12}>
{renderBindingCard('phone')}
</Col>
</Row>
)}
</Space>
</ContentCard>
<Modal
title={activeChannel ? `确认${getChannelLabel(activeChannel)}绑定` : '确认绑定'}
open={bindVisible}
onCancel={closeBindModal}
onOk={() => bindForm.submit()}
confirmLoading={bindSubmitting}
okText="确认绑定"
>
<Space direction="vertical" style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message={activeChannel ? `请先发送${getChannelLabel(activeChannel)}验证码,再输入当前账号密码或 TOTP 完成验证。` : ''}
/>
<Form form={bindForm} layout="vertical" onFinish={handleBind}>
{activeChannel === 'email' ? (
<Form.Item
name="email"
label="新邮箱"
rules={[
{ required: true, message: '请输入新邮箱' },
{ type: 'email', message: '请输入有效的邮箱地址' },
]}
>
<Input placeholder="请输入新邮箱" />
</Form.Item>
) : (
<Form.Item
name="phone"
label="新手机号"
rules={[
{ required: true, message: '请输入新手机号' },
{
pattern: /^\+?[1-9]\d{6,14}$/,
message: '请输入符合 E.164 的手机号',
},
]}
>
<Input placeholder="例如:+8613800138000" />
</Form.Item>
)}
<Form.Item
name="code"
label="验证码"
rules={[{ required: true, message: '请输入验证码' }]}
>
<Input
placeholder="请输入验证码"
addonAfter={
<Button type="link" size="small" loading={sendCodeLoading} onClick={handleSendCode}>
</Button>
}
/>
</Form.Item>
<Form.Item name="current_password" label="当前密码">
<Input.Password placeholder="建议填写当前密码" />
</Form.Item>
<Form.Item name="totp_code" label="TOTP / 恢复码">
<Input placeholder="已开启 TOTP 时可填写验证码或恢复码" />
</Form.Item>
</Form>
</Space>
</Modal>
<Modal
title={activeChannel ? `确认解绑${getChannelLabel(activeChannel)}` : '确认解绑'}
open={unbindVisible}
onCancel={closeUnbindModal}
onOk={() => unbindForm.submit()}
confirmLoading={unbindSubmitting}
okText="确认解绑"
okButtonProps={{ danger: true }}
>
<Space direction="vertical" style={{ width: '100%' }}>
<Alert
type="warning"
showIcon
message={activeChannel ? `解绑${getChannelLabel(activeChannel)}前,需要验证当前账号身份;如果解绑后没有其他登录方式,系统会拒绝本次操作。` : ''}
/>
<Form form={unbindForm} layout="vertical" onFinish={handleUnbind}>
<Form.Item name="current_password" label="当前密码">
<Input.Password placeholder="填写当前密码" />
</Form.Item>
<Form.Item name="totp_code" label="TOTP / 恢复码">
<Input placeholder="或填写 TOTP 验证码 / 恢复码" />
</Form.Item>
</Form>
</Space>
</Modal>
</>
)
}

View File

@@ -0,0 +1,560 @@
import type { ReactElement, ReactNode } from 'react'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { message } from 'antd'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { OAuthProviderInfo } from '@/types'
import type { Device } from '@/types/device'
import type { LoginLog } from '@/types/login-log'
import type { OperationLog } from '@/types/operation-log'
import { ProfileSecurityPage } from './ProfileSecurityPage'
const originalGetComputedStyle = window.getComputedStyle
const useAuthMock = vi.fn()
const parseOAuthCallbackHashMock = vi.fn()
const getAuthCapabilitiesMock = vi.fn<() => Promise<{
password: boolean
email_activation: boolean
email_code: boolean
sms_code: boolean
password_reset: boolean
admin_bootstrap_required: boolean
oauth_providers: OAuthProviderInfo[]
}>>()
const listSocialAccountsMock = vi.fn()
const startSocialBindingMock = vi.fn()
const unbindSocialAccountMock = vi.fn()
const getTOTPStatusMock = vi.fn<() => Promise<{ totp_enabled: boolean }>>()
const getTOTPSetupMock = vi.fn()
const enableTOTPMock = vi.fn<(code: string) => Promise<void>>()
const disableTOTPMock = vi.fn<(code: string) => Promise<void>>()
const updatePasswordMock = vi.fn()
const uploadAvatarMock = vi.fn<(userId: number, file: File) => Promise<void>>()
const listDevicesMock = vi.fn<() => Promise<{ items: Device[] }>>()
const updateDeviceStatusMock = vi.fn<(id: number, status: 0 | 1) => Promise<void>>()
const deleteDeviceMock = vi.fn<(id: number) => Promise<void>>()
const listMyLoginLogsMock = vi.fn<() => Promise<{ items: LoginLog[] }>>()
const listMyOperationLogsMock = vi.fn<() => Promise<{ items: OperationLog[] }>>()
vi.mock('antd', async () => {
const actual = await vi.importActual<typeof import('antd')>('antd')
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)
}
return {
...actual,
Popconfirm: ({
children,
title,
onConfirm,
}: {
children: ReactElement
title?: ReactNode
onConfirm?: () => void
}) => (
<div>
{children}
<button type="button" onClick={() => onConfirm?.()}>
{typeof title === 'string' ? title : 'confirm action'}
</button>
</div>
),
Table: ({
columns,
dataSource,
rowKey,
locale,
}: {
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 }
}) => {
const rows = dataSource ?? []
if (rows.length === 0) {
return <div>{locale?.emptyText ?? null}</div>
}
return (
<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>
)
},
Upload: ({
children,
beforeUpload,
accept,
}: {
children: ReactNode
beforeUpload?: (file: File) => boolean | Promise<boolean>
accept?: string
}) => (
<label>
{children}
<input
data-testid="upload-input"
type="file"
accept={accept}
onChange={(event) => {
const files = Array.from(event.currentTarget.files ?? [])
files.forEach((file) => {
void beforeUpload?.(file)
})
}}
/>
</label>
),
}
})
vi.mock('@/app/providers/auth-context', () => ({
useAuth: () => useAuthMock(),
}))
vi.mock('@/lib/auth/oauth', () => ({
parseOAuthCallbackHash: (hash: string) => parseOAuthCallbackHashMock(hash),
}))
vi.mock('@/services/auth', () => ({
getAuthCapabilities: () => getAuthCapabilitiesMock(),
}))
vi.mock('@/services/social-accounts', () => ({
listSocialAccounts: () => listSocialAccountsMock(),
startSocialBinding: (payload: unknown) => startSocialBindingMock(payload),
unbindSocialAccount: (provider: string, payload: unknown) => unbindSocialAccountMock(provider, payload),
}))
vi.mock('@/services/profile', () => ({
disableTOTP: (code: string) => disableTOTPMock(code),
enableTOTP: (code: string) => enableTOTPMock(code),
getTOTPSetup: () => getTOTPSetupMock(),
getTOTPStatus: () => getTOTPStatusMock(),
updatePassword: (userId: number, payload: unknown) => updatePasswordMock(userId, payload),
uploadAvatar: (userId: number, file: File) => uploadAvatarMock(userId, file),
}))
vi.mock('@/services/devices', () => ({
deleteDevice: (id: number) => deleteDeviceMock(id),
listDevices: () => listDevicesMock(),
updateDeviceStatus: (id: number, status: 0 | 1) => updateDeviceStatusMock(id, status),
}))
vi.mock('@/services/login-logs', () => ({
listMyLoginLogs: () => listMyLoginLogsMock(),
}))
vi.mock('@/services/operation-logs', () => ({
listMyOperationLogs: () => listMyOperationLogsMock(),
}))
vi.mock('./ContactBindingsSection', () => ({
ContactBindingsSection: () => <div data-testid="contact-bindings-section" />,
}))
function buildDevice(id: number, name: string, status: 0 | 1, isTrusted = false): Device {
return {
id,
user_id: 1,
device_id: `device-${id}`,
device_name: name,
device_type: 3,
device_os: 'Windows',
device_browser: 'Chrome',
ip: `10.0.0.${id}`,
location: 'Shanghai',
status,
is_trusted: isTrusted,
trust_expires_at: isTrusted ? '2026-04-30 23:59:59' : null,
last_active_time: `2026-03-27 1${id}:00:00`,
created_at: '2026-03-27 10:00:00',
updated_at: '2026-03-27 10:30:00',
}
}
describe('ProfileSecurityPage behavior', () => {
let currentDevices: Device[]
let refreshUserMock: ReturnType<typeof vi.fn>
beforeEach(() => {
currentDevices = [
buildDevice(101, 'Primary Device', 1),
buildDevice(102, 'Backup Device', 0),
]
refreshUserMock = vi.fn().mockResolvedValue(undefined)
useAuthMock.mockReturnValue({
user: { id: 1, avatar: '', username: 'admin' },
refreshUser: refreshUserMock,
roles: [],
isAdmin: false,
isAuthenticated: true,
isLoading: false,
onLoginSuccess: vi.fn(),
logout: vi.fn(),
})
getAuthCapabilitiesMock.mockReset()
parseOAuthCallbackHashMock.mockReset()
listSocialAccountsMock.mockReset()
startSocialBindingMock.mockReset()
unbindSocialAccountMock.mockReset()
getTOTPStatusMock.mockReset()
getTOTPSetupMock.mockReset()
enableTOTPMock.mockReset()
disableTOTPMock.mockReset()
updatePasswordMock.mockReset()
uploadAvatarMock.mockReset()
listDevicesMock.mockReset()
updateDeviceStatusMock.mockReset()
deleteDeviceMock.mockReset()
listMyLoginLogsMock.mockReset()
listMyOperationLogsMock.mockReset()
getAuthCapabilitiesMock.mockResolvedValue({
password: true,
email_activation: false,
email_code: true,
sms_code: true,
password_reset: true,
admin_bootstrap_required: false,
oauth_providers: [{ provider: 'github', name: 'GitHub' }],
})
parseOAuthCallbackHashMock.mockReturnValue({})
listSocialAccountsMock.mockResolvedValue([])
startSocialBindingMock.mockResolvedValue({
auth_url: 'https://oauth.example.com/github',
state: 'state-demo',
})
unbindSocialAccountMock.mockResolvedValue(undefined)
getTOTPStatusMock.mockResolvedValue({ totp_enabled: false })
getTOTPSetupMock.mockResolvedValue({
secret: 'totp-secret',
qr_code_base64: 'abc123',
recovery_codes: ['rc-1', 'rc-2'],
})
enableTOTPMock.mockResolvedValue(undefined)
disableTOTPMock.mockResolvedValue(undefined)
updatePasswordMock.mockResolvedValue(undefined)
uploadAvatarMock.mockResolvedValue(undefined)
listDevicesMock.mockImplementation(async () => ({
items: currentDevices.map((device) => ({ ...device })),
}))
updateDeviceStatusMock.mockImplementation(async (id: number, status: 0 | 1) => {
currentDevices = currentDevices.map((device) => (
device.id === id ? { ...device, status } : device
))
})
deleteDeviceMock.mockImplementation(async (id: number) => {
currentDevices = currentDevices.filter((device) => device.id !== id)
})
listMyLoginLogsMock.mockResolvedValue({
items: [{
id: 201,
login_type: 1,
device_id: 'device-101',
ip: '10.0.0.1',
location: 'Shanghai',
status: 1,
created_at: '2026-03-27 09:00:00',
}],
})
listMyOperationLogsMock.mockResolvedValue({
items: [{
id: 301,
operation_type: 'user',
operation_name: 'update profile',
request_method: 'PUT',
request_path: '/api/v1/users/me',
request_params: '{}',
response_status: 200,
ip: '10.0.0.1',
user_agent: 'Chrome',
created_at: '2026-03-27 09:10:00',
}],
})
vi.spyOn(window, 'getComputedStyle').mockImplementation((element) => {
return originalGetComputedStyle.call(window, element)
})
vi.spyOn(message, 'success').mockImplementation(() => undefined as never)
vi.spyOn(message, 'error').mockImplementation(() => undefined as never)
window.history.replaceState({}, '', '/profile/security')
})
afterEach(() => {
vi.restoreAllMocks()
})
it('opens the TOTP setup flow, validates the code, and enables TOTP', async () => {
const user = userEvent.setup()
render(<ProfileSecurityPage />)
await waitFor(() => expect(screen.getByRole('button', { name: '启用 TOTP' })).toBeInTheDocument())
await user.click(screen.getByRole('button', { name: '启用 TOTP' }))
await waitFor(() => expect(getTOTPSetupMock).toHaveBeenCalledTimes(1))
expect(await screen.findByText('密钥totp-secret')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '确认启用' }))
expect(message.error).toHaveBeenCalledWith('请输入 6 位验证码')
expect(enableTOTPMock).not.toHaveBeenCalled()
await user.type(screen.getByPlaceholderText('请输入 6 位验证码'), '123456')
await user.click(screen.getByRole('button', { name: '确认启用' }))
await waitFor(() => expect(enableTOTPMock).toHaveBeenCalledWith('123456'))
expect(message.success).toHaveBeenCalledWith('TOTP 已启用')
expect(screen.getByRole('button', { name: '禁用 TOTP' })).toBeInTheDocument()
})
it('shows an error when the initial security data fetch fails', async () => {
listDevicesMock.mockRejectedValueOnce(new Error('load failed'))
render(<ProfileSecurityPage />)
await waitFor(() => expect(message.error).toHaveBeenCalledWith('load failed'))
expect(await screen.findByText('暂无设备数据')).toBeInTheDocument()
})
it('handles a successful social callback, clears the hash, and refetches security data', async () => {
parseOAuthCallbackHashMock.mockReturnValueOnce({
status: 'success',
provider: 'github',
message: 'bind ok',
})
window.history.replaceState({}, '', '/profile/security#social-callback')
const replaceStateSpy = vi.spyOn(window.history, 'replaceState')
render(<ProfileSecurityPage />)
await waitFor(() => expect(message.success).toHaveBeenCalledWith('bind ok'))
await waitFor(() => expect(listDevicesMock).toHaveBeenCalledTimes(2))
expect(replaceStateSpy.mock.calls.at(-1)?.[2]).toBe('/profile/security')
})
it('shows callback errors without refetching security data', async () => {
parseOAuthCallbackHashMock.mockReturnValueOnce({
status: 'error',
provider: 'github',
message: 'bind failed',
})
window.history.replaceState({}, '', '/profile/security#social-callback')
render(<ProfileSecurityPage />)
await waitFor(() => expect(message.error).toHaveBeenCalledWith('bind failed'))
expect(listDevicesMock).toHaveBeenCalledTimes(1)
})
it('validates disable input and disables TOTP when a code is provided', async () => {
const user = userEvent.setup()
getTOTPStatusMock.mockResolvedValueOnce({ totp_enabled: true })
render(<ProfileSecurityPage />)
await waitFor(() => expect(screen.getByRole('button', { name: '禁用 TOTP' })).toBeInTheDocument())
await user.click(screen.getByRole('button', { name: '禁用 TOTP' }))
await user.click(screen.getByRole('button', { name: '确认禁用' }))
expect(message.error).toHaveBeenCalledWith('请输入验证码或恢复码')
expect(disableTOTPMock).not.toHaveBeenCalled()
await user.type(screen.getByPlaceholderText('验证码或恢复码'), '654321')
await user.click(screen.getByRole('button', { name: '确认禁用' }))
await waitFor(() => expect(disableTOTPMock).toHaveBeenCalledWith('654321'))
expect(message.success).toHaveBeenCalledWith('TOTP 已禁用')
})
it('rejects invalid avatar files and uploads valid images', async () => {
render(<ProfileSecurityPage />)
const uploadInput = await screen.findByTestId('upload-input') as HTMLInputElement
const invalidFile = new File(['plain-text'], 'avatar.txt', { type: 'text/plain' })
fireEvent.change(uploadInput, { target: { files: [invalidFile] } })
expect(message.error).toHaveBeenCalledWith('只能上传图片文件')
expect(uploadAvatarMock).not.toHaveBeenCalled()
const oversizedFile = new File([new Uint8Array(2 * 1024 * 1024)], 'avatar-large.png', {
type: 'image/png',
})
fireEvent.change(uploadInput, { target: { files: [oversizedFile] } })
expect(message.error).toHaveBeenCalledWith('图片大小不能超过 2MB')
expect(uploadAvatarMock).not.toHaveBeenCalled()
const validFile = new File([new Uint8Array(1024)], 'avatar.png', { type: 'image/png' })
fireEvent.change(uploadInput, { target: { files: [validFile] } })
await waitFor(() => expect(uploadAvatarMock).toHaveBeenCalledWith(1, expect.any(File)))
expect(refreshUserMock).toHaveBeenCalledTimes(1)
expect(message.success).toHaveBeenCalledWith('头像上传成功')
})
it('submits password changes for the current user', async () => {
const user = userEvent.setup()
const { container } = render(<ProfileSecurityPage />)
await waitFor(() => expect(container.querySelectorAll('input[type="password"]')).toHaveLength(3))
const passwordInputs = Array.from(
container.querySelectorAll('input[type="password"]'),
) as HTMLInputElement[]
await user.type(passwordInputs[0], 'CurrentPass123')
await user.type(passwordInputs[1], 'NewPass123!')
await user.type(passwordInputs[2], 'NewPass123!')
await user.click(container.querySelector('button[type="submit"]') as HTMLButtonElement)
await waitFor(() =>
expect(updatePasswordMock).toHaveBeenCalledWith(1, {
current_password: 'CurrentPass123',
new_password: 'NewPass123!',
confirm_password: 'NewPass123!',
}),
)
expect(message.success).toHaveBeenCalledWith('密码修改成功')
})
it('toggles device status, refetches the list, and deletes devices', async () => {
const user = userEvent.setup()
render(<ProfileSecurityPage />)
expect(await screen.findByText('Primary Device')).toBeInTheDocument()
expect(screen.getByText('Backup Device')).toBeInTheDocument()
await user.click(within(screen.getByTestId('table-row-101')).getByRole('button', { name: '禁用' }))
await waitFor(() => expect(updateDeviceStatusMock).toHaveBeenCalledWith(101, 0))
expect(message.success).toHaveBeenCalledWith('设备已禁用')
await waitFor(() => expect(within(screen.getByTestId('table-row-101')).getByRole('button', { name: '启用' })).toBeInTheDocument())
await user.click(within(screen.getByTestId('table-row-102')).getByRole('button', { name: '启用' }))
await waitFor(() => expect(updateDeviceStatusMock).toHaveBeenCalledWith(102, 1))
expect(message.success).toHaveBeenCalledWith('设备已重新激活')
await waitFor(() => expect(within(screen.getByTestId('table-row-102')).getByRole('button', { name: '禁用' })).toBeInTheDocument())
await user.click(within(screen.getByTestId('table-row-101')).getByRole('button', { name: '确定删除此设备吗?' }))
await waitFor(() => expect(deleteDeviceMock).toHaveBeenCalledWith(101))
expect(message.success).toHaveBeenCalledWith('设备已删除')
await waitFor(() => expect(screen.queryByTestId('table-row-101')).not.toBeInTheDocument())
})
it('unbinds a social account and refreshes the security data', async () => {
const user = userEvent.setup()
listSocialAccountsMock.mockResolvedValue([
{
id: 11,
provider: 'github',
nickname: 'octocat',
avatar: '',
status: 1,
created_at: '2026-03-27 09:20:00',
},
])
render(<ProfileSecurityPage />)
const row = await screen.findByTestId('table-row-11')
await user.click(within(row).getByRole('button', { name: '解绑' }))
const dialog = await screen.findByRole('dialog')
const inputs = Array.from(dialog.querySelectorAll('input')) as HTMLInputElement[]
await user.type(inputs[0], 'SecurePass123')
await user.type(inputs[1], '654321')
await user.click(within(dialog).getByRole('button', { name: '确认解绑' }))
await waitFor(() =>
expect(unbindSocialAccountMock).toHaveBeenCalledWith('github', {
current_password: 'SecurePass123',
totp_code: '654321',
}),
)
await waitFor(() => expect(listSocialAccountsMock).toHaveBeenCalledTimes(2))
expect(message.success).toHaveBeenCalledWith(expect.stringContaining('GitHub'))
})
it('skips protected actions when no authenticated user is available', async () => {
useAuthMock.mockReturnValue({
user: null,
refreshUser: refreshUserMock,
roles: [],
isAdmin: false,
isAuthenticated: false,
isLoading: false,
onLoginSuccess: vi.fn(),
logout: vi.fn(),
})
const user = userEvent.setup()
const { container } = render(<ProfileSecurityPage />)
await waitFor(() => expect(listDevicesMock).not.toHaveBeenCalled())
expect(screen.queryByTestId('contact-bindings-section')).not.toBeInTheDocument()
const validFile = new File([new Uint8Array(1024)], 'avatar.png', { type: 'image/png' })
fireEvent.change(await screen.findByTestId('upload-input'), { target: { files: [validFile] } })
expect(uploadAvatarMock).not.toHaveBeenCalled()
const passwordInputs = Array.from(
container.querySelectorAll('input[type="password"]'),
) as HTMLInputElement[]
await user.type(passwordInputs[0], 'CurrentPass123')
await user.type(passwordInputs[1], 'NewPass123!')
await user.type(passwordInputs[2], 'NewPass123!')
await user.click(container.querySelector('button[type="submit"]') as HTMLButtonElement)
expect(updatePasswordMock).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,124 @@
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 { ProfileSecurityPage } from './ProfileSecurityPage'
const useAuthMock = vi.fn()
const getAuthCapabilitiesMock = vi.fn()
const listSocialAccountsMock = vi.fn()
const startSocialBindingMock = vi.fn()
const unbindSocialAccountMock = vi.fn()
const getTOTPStatusMock = vi.fn()
const listDevicesMock = vi.fn()
const listMyLoginLogsMock = vi.fn()
const listMyOperationLogsMock = vi.fn()
const assignMock = vi.fn()
const originalGetComputedStyle = window.getComputedStyle
vi.mock('@/app/providers/auth-context', () => ({
useAuth: () => useAuthMock(),
}))
vi.mock('@/services/auth', () => ({
getAuthCapabilities: () => getAuthCapabilitiesMock(),
}))
vi.mock('@/services/social-accounts', () => ({
listSocialAccounts: () => listSocialAccountsMock(),
startSocialBinding: (payload: unknown) => startSocialBindingMock(payload),
unbindSocialAccount: (provider: string, payload: unknown) => unbindSocialAccountMock(provider, payload),
}))
vi.mock('@/services/profile', () => ({
disableTOTP: vi.fn(),
enableTOTP: vi.fn(),
getTOTPSetup: vi.fn(),
getTOTPStatus: () => getTOTPStatusMock(),
updatePassword: vi.fn(),
uploadAvatar: vi.fn(),
}))
vi.mock('@/services/devices', () => ({
deleteDevice: vi.fn(),
listDevices: () => listDevicesMock(),
updateDeviceStatus: vi.fn(),
}))
vi.mock('@/services/login-logs', () => ({
listMyLoginLogs: () => listMyLoginLogsMock(),
}))
vi.mock('@/services/operation-logs', () => ({
listMyOperationLogs: () => listMyOperationLogsMock(),
}))
vi.mock('./ContactBindingsSection', () => ({
ContactBindingsSection: () => <div data-testid="contact-bindings-section" />,
}))
describe('ProfileSecurityPage social account actions', () => {
afterEach(() => {
vi.restoreAllMocks()
})
beforeEach(() => {
window.history.replaceState({}, '', '/profile/security')
useAuthMock.mockReturnValue({
user: { id: 1, avatar: '', username: 'admin' },
refreshUser: vi.fn(),
})
getAuthCapabilitiesMock.mockResolvedValue({
password: true,
email_activation: false,
email_code: false,
sms_code: false,
password_reset: true,
admin_bootstrap_required: false,
oauth_providers: [{ provider: 'github', name: 'GitHub' }],
})
listSocialAccountsMock.mockResolvedValue([])
startSocialBindingMock.mockResolvedValue({
auth_url: 'https://oauth.example.com/github',
state: 'state-demo',
})
unbindSocialAccountMock.mockResolvedValue(undefined)
getTOTPStatusMock.mockResolvedValue({ totp_enabled: false })
listDevicesMock.mockResolvedValue({ items: [] })
listMyLoginLogsMock.mockResolvedValue({ items: [] })
listMyOperationLogsMock.mockResolvedValue({ items: [] })
assignMock.mockReset()
vi.spyOn(window, 'getComputedStyle').mockImplementation((element) => {
return originalGetComputedStyle.call(window, element)
})
Object.defineProperty(window, 'location', {
configurable: true,
value: {
...window.location,
assign: assignMock,
},
})
})
it('starts the social binding flow from the security page', async () => {
const user = userEvent.setup()
render(<ProfileSecurityPage />)
await waitFor(() => expect(screen.getByRole('button', { name: '绑定 GitHub' })).toBeInTheDocument())
await user.click(screen.getByRole('button', { name: '绑定 GitHub' }))
await user.type(screen.getByPlaceholderText('有密码时建议填写'), 'SecurePass123')
await user.click(screen.getByRole('button', { name: '继续授权' }))
await waitFor(() =>
expect(startSocialBindingMock).toHaveBeenCalledWith({
provider: 'github',
return_to: '/profile/security',
current_password: 'SecurePass123',
totp_code: undefined,
}),
)
expect(assignMock).toHaveBeenCalledWith('https://oauth.example.com/github')
})
})

View File

@@ -0,0 +1,946 @@
import { useCallback, useEffect, useState } from 'react'
import {
Alert,
Avatar,
Button,
Checkbox,
Col,
Form,
Input,
Modal,
Popconfirm,
Row,
Space,
Table,
Tag,
Typography,
Upload,
message,
type TableColumnsType,
} from 'antd'
import {
CheckCircleOutlined,
DesktopOutlined,
LockOutlined,
SafetyOutlined,
UploadOutlined,
UserOutlined,
CheckOutlined,
} from '@ant-design/icons'
import type { RcFile } from 'antd/es/upload'
import dayjs from 'dayjs'
import { useAuth } from '@/app/providers/auth-context'
import { getErrorMessage } from '@/lib/errors'
import { parseOAuthCallbackHash } from '@/lib/auth/oauth'
import { PageLayout, ContentCard } from '@/components/layout'
import { PageHeader } from '@/components/common'
import { getAuthCapabilities } from '@/services/auth'
import { listSocialAccounts, startSocialBinding, unbindSocialAccount } from '@/services/social-accounts'
import {
disableTOTP,
enableTOTP,
getTOTPSetup,
getTOTPStatus,
updatePassword,
uploadAvatar,
} from '@/services/profile'
import { deleteDevice, listDevices, trustDevice, trustDeviceByDeviceId, untrustDevice, updateDeviceStatus } from '@/services/devices'
import { listMyLoginLogs } from '@/services/login-logs'
import { listMyOperationLogs } from '@/services/operation-logs'
import { ContactBindingsSection } from './ContactBindingsSection'
import {
DeviceStatusColor,
DeviceStatusText,
DeviceTypeText,
type Device,
} from '@/types/device'
import { LoginStatusColor, LoginStatusText, LoginTypeText, type LoginLog } from '@/types/login-log'
import type { OperationLog } from '@/types/operation-log'
import type { OAuthProviderInfo, SocialAccountInfo } from '@/types'
const { Text } = Typography
interface PasswordFormValues {
current_password: string
new_password: string
confirm_password: string
}
interface SocialVerificationFormValues {
current_password?: string
totp_code?: string
}
export function ProfileSecurityPage() {
const { user, refreshUser } = useAuth()
const [passwordForm] = Form.useForm<PasswordFormValues>()
const [bindSocialForm] = Form.useForm<SocialVerificationFormValues>()
const [unbindSocialForm] = Form.useForm<SocialVerificationFormValues>()
const [passwordLoading, setPasswordLoading] = useState(false)
const [avatarLoading, setAvatarLoading] = useState(false)
const [totpLoading, setTotpLoading] = useState(false)
const [setupVisible, setSetupVisible] = useState(false)
const [disableVisible, setDisableVisible] = useState(false)
const [totpCode, setTotpCode] = useState('')
const [disableCode, setDisableCode] = useState('')
const [totpEnabled, setTotpEnabled] = useState(false)
const [totpSetup, setTotpSetup] = useState<{
secret: string
qr_code_base64: string
recovery_codes: string[]
} | null>(null)
const [totpRememberDevice, setTotpRememberDevice] = useState(false)
const [devicesLoading, setDevicesLoading] = useState(true)
const [devices, setDevices] = useState<Device[]>([])
const [loginLogs, setLoginLogs] = useState<LoginLog[]>([])
const [operationLogs, setOperationLogs] = useState<OperationLog[]>([])
const [logsLoading, setLogsLoading] = useState(true)
const [socialLoading, setSocialLoading] = useState(true)
const [socialAccounts, setSocialAccounts] = useState<SocialAccountInfo[]>([])
const [oauthProviders, setOAuthProviders] = useState<OAuthProviderInfo[]>([])
const [emailBindingEnabled, setEmailBindingEnabled] = useState(false)
const [phoneBindingEnabled, setPhoneBindingEnabled] = useState(false)
const [bindVisible, setBindVisible] = useState(false)
const [unbindVisible, setUnbindVisible] = useState(false)
const [socialSubmitting, setSocialSubmitting] = useState(false)
const [activeProvider, setActiveProvider] = useState<string | null>(null)
const fetchSecurityData = useCallback(async () => {
if (!user) {
return
}
try {
setDevicesLoading(true)
setLogsLoading(true)
setSocialLoading(true)
const [deviceResult, totpStatus, loginLogResult, operationLogResult, socialAccountResult, authCapabilities] = await Promise.all([
listDevices({ page: 1, page_size: 20 }),
getTOTPStatus(),
listMyLoginLogs({ page: 1, page_size: 5 }),
listMyOperationLogs({ page: 1, page_size: 5 }),
listSocialAccounts(),
getAuthCapabilities(),
])
setDevices(deviceResult.items)
setTotpEnabled(totpStatus.totp_enabled)
setLoginLogs(loginLogResult.items)
setOperationLogs(operationLogResult.items)
setSocialAccounts(socialAccountResult)
setOAuthProviders(authCapabilities.oauth_providers)
setEmailBindingEnabled(authCapabilities.email_code)
setPhoneBindingEnabled(authCapabilities.sms_code)
} catch (error) {
message.error(getErrorMessage(error, '获取安全设置失败'))
} finally {
setDevicesLoading(false)
setLogsLoading(false)
setSocialLoading(false)
}
}, [user])
useEffect(() => {
void fetchSecurityData()
}, [fetchSecurityData])
const getProviderLabel = useCallback((provider: string) => {
const match = oauthProviders.find((item) => item.provider === provider)
return match?.name || provider
}, [oauthProviders])
useEffect(() => {
if (!window.location.hash) {
return
}
const callbackResult = parseOAuthCallbackHash(window.location.hash)
if (!callbackResult.status || !callbackResult.provider) {
return
}
const nextUrl = `${window.location.pathname}${window.location.search}`
window.history.replaceState(window.history.state, document.title, nextUrl)
if (callbackResult.status === 'success') {
const providerLabel = getProviderLabel(callbackResult.provider)
message.success(callbackResult.message || `${providerLabel} 绑定成功`)
void fetchSecurityData()
return
}
message.error(callbackResult.message || `${getProviderLabel(callbackResult.provider)} 绑定失败`)
}, [fetchSecurityData, getProviderLabel])
const openBindModal = (provider: string) => {
bindSocialForm.resetFields()
setActiveProvider(provider)
setBindVisible(true)
}
const openUnbindModal = (provider: string) => {
unbindSocialForm.resetFields()
setActiveProvider(provider)
setUnbindVisible(true)
}
const handleStartSocialBinding = async (values: SocialVerificationFormValues) => {
if (!activeProvider) {
return
}
try {
setSocialSubmitting(true)
const result = await startSocialBinding({
provider: activeProvider,
return_to: `${window.location.pathname}${window.location.search}`,
current_password: values.current_password?.trim() || undefined,
totp_code: values.totp_code?.trim() || undefined,
})
setBindVisible(false)
setActiveProvider(null)
bindSocialForm.resetFields()
window.location.assign(result.auth_url)
} catch (error) {
message.error(getErrorMessage(error, '发起社交账号绑定失败'))
} finally {
setSocialSubmitting(false)
}
}
const handleConfirmUnbind = async (values: SocialVerificationFormValues) => {
if (!activeProvider) {
return
}
try {
setSocialSubmitting(true)
await unbindSocialAccount(activeProvider, {
current_password: values.current_password?.trim() || undefined,
totp_code: values.totp_code?.trim() || undefined,
})
setUnbindVisible(false)
setActiveProvider(null)
unbindSocialForm.resetFields()
message.success(`${getProviderLabel(activeProvider)} 已解绑`)
await fetchSecurityData()
} catch (error) {
message.error(getErrorMessage(error, '解绑社交账号失败'))
} finally {
setSocialSubmitting(false)
}
}
const handlePasswordSubmit = async (values: PasswordFormValues) => {
if (!user) {
return
}
try {
setPasswordLoading(true)
await updatePassword(user.id, values)
passwordForm.resetFields()
message.success('密码修改成功')
} catch (error) {
message.error(getErrorMessage(error, '密码修改失败'))
} finally {
setPasswordLoading(false)
}
}
const handleAvatarUpload = async (file: RcFile) => {
if (!user) {
return false
}
const isImage = file.type.startsWith('image/')
if (!isImage) {
message.error('只能上传图片文件')
return false
}
const isLt2M = file.size / 1024 / 1024 < 2
if (!isLt2M) {
message.error('图片大小不能超过 2MB')
return false
}
try {
setAvatarLoading(true)
await uploadAvatar(user.id, file)
await refreshUser()
message.success('头像上传成功')
} catch (error) {
message.error(getErrorMessage(error, '头像上传失败'))
} finally {
setAvatarLoading(false)
}
return false
}
const handleOpenTOTPSetup = async () => {
try {
setTotpLoading(true)
const data = await getTOTPSetup()
setTotpSetup(data)
setTotpCode('')
setSetupVisible(true)
} catch (error) {
message.error(getErrorMessage(error, '获取 TOTP 设置信息失败'))
} finally {
setTotpLoading(false)
}
}
const handleEnableTOTP = async () => {
if (totpCode.length !== 6) {
message.error('请输入 6 位验证码')
return
}
try {
setTotpLoading(true)
await enableTOTP(totpCode)
// If "remember device" is checked, trust the current device
if (totpRememberDevice) {
try {
const stored = localStorage.getItem('device_fingerprint')
if (stored) {
const deviceInfo = JSON.parse(stored)
await trustDeviceByDeviceId(deviceInfo.device_id, '30d')
}
} catch {
// Non-critical: device trust failed, but TOTP was enabled
}
}
setTotpEnabled(true)
setSetupVisible(false)
setTotpRememberDevice(false)
message.success('TOTP 已启用')
} catch (error) {
message.error(getErrorMessage(error, '启用 TOTP 失败'))
} finally {
setTotpLoading(false)
}
}
const handleDisableTOTP = async () => {
if (!disableCode) {
message.error('请输入验证码或恢复码')
return
}
try {
setTotpLoading(true)
await disableTOTP(disableCode)
setTotpEnabled(false)
setDisableVisible(false)
setDisableCode('')
message.success('TOTP 已禁用')
} catch (error) {
message.error(getErrorMessage(error, '禁用 TOTP 失败'))
} finally {
setTotpLoading(false)
}
}
const handleToggleDeviceStatus = async (device: Device) => {
const nextStatus = device.status === 1 ? 0 : 1
try {
await updateDeviceStatus(device.id, nextStatus)
message.success(nextStatus === 1 ? '设备已重新激活' : '设备已禁用')
await fetchSecurityData()
} catch (error) {
message.error(getErrorMessage(error, '更新设备状态失败'))
}
}
const handleDeleteDevice = async (deviceId: number) => {
try {
await deleteDevice(deviceId)
message.success('设备已删除')
await fetchSecurityData()
} catch (error) {
message.error(getErrorMessage(error, '删除设备失败'))
}
}
const handleTrustDevice = async (device: Device) => {
try {
await trustDevice(device.id, '30d')
message.success('设备已设为信任30天内无需二次验证')
await fetchSecurityData()
} catch (error) {
message.error(getErrorMessage(error, '信任设备失败'))
}
}
const handleUntrustDevice = async (device: Device) => {
try {
await untrustDevice(device.id)
message.success('已取消设备信任')
await fetchSecurityData()
} catch (error) {
message.error(getErrorMessage(error, '取消信任失败'))
}
}
const boundProviderSet = new Set(socialAccounts.map((account) => account.provider))
const availableSocialProviders = oauthProviders.filter((provider) => !boundProviderSet.has(provider.provider))
const socialAccountColumns: TableColumnsType<SocialAccountInfo> = [
{
title: '平台',
dataIndex: 'provider',
key: 'provider',
render: (value: string) => getProviderLabel(value),
},
{
title: '账号信息',
key: 'account',
render: (_, record) => (
<Space>
<Avatar size={32} src={record.avatar || undefined} icon={<UserOutlined />} />
<Space direction="vertical" size={0}>
<Text strong>{record.nickname || getProviderLabel(record.provider)}</Text>
<Text type="secondary">{record.provider}</Text>
</Space>
</Space>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: SocialAccountInfo['status']) => (
<Tag color={status === 1 ? 'success' : status === 2 ? 'error' : 'default'}>
{status === 1 ? '已绑定' : status === 2 ? '已禁用' : '未激活'}
</Tag>
),
},
{
title: '绑定时间',
dataIndex: 'created_at',
key: 'created_at',
render: (value) => (value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-'),
},
{
title: '操作',
key: 'action',
render: (_, record) => (
<Button type="link" danger onClick={() => openUnbindModal(record.provider)}>
</Button>
),
},
]
const deviceColumns: TableColumnsType<Device> = [
{
title: '设备',
key: 'device',
render: (_, record) => (
<Space direction="vertical" size={0}>
<Space>
<Text strong>{record.device_name || record.device_id}</Text>
{record.is_trusted && (
<Tag color="success" icon={<CheckOutlined />}></Tag>
)}
</Space>
<Text type="secondary">
{DeviceTypeText[record.device_type]} / {record.device_browser || '-'} / {record.device_os || '-'}
</Text>
</Space>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: Device['status']) => (
<Tag color={DeviceStatusColor[status]}>{DeviceStatusText[status]}</Tag>
),
},
{
title: '信任状态',
key: 'trusted',
render: (_, record) => (
record.is_trusted ? (
<Text type="success">{record.trust_expires_at ? ` (至 ${dayjs(record.trust_expires_at).format('YYYY-MM-DD')})` : ''}</Text>
) : (
<Text type="secondary"></Text>
)
),
},
{
title: 'IP / 位置',
key: 'location',
render: (_, record) => (
<Space direction="vertical" size={0}>
<Text code>{record.ip}</Text>
<Text type="secondary">{record.location || '-'}</Text>
</Space>
),
},
{
title: '最后活跃',
dataIndex: 'last_active_time',
key: 'last_active_time',
render: (value) => (value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-'),
},
{
title: '操作',
key: 'action',
render: (_, record) => (
<Space>
{record.is_trusted ? (
<Button type="link" size="small" onClick={() => handleUntrustDevice(record)}>
</Button>
) : (
<Button type="link" size="small" onClick={() => handleTrustDevice(record)}>
</Button>
)}
<Button type="link" size="small" onClick={() => handleToggleDeviceStatus(record)}>
{record.status === 1 ? '禁用' : '启用'}
</Button>
<Popconfirm
title="确定删除此设备吗?"
onConfirm={() => handleDeleteDevice(record.id)}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
]
const loginLogColumns: TableColumnsType<LoginLog> = [
{
title: '登录类型',
dataIndex: 'login_type',
key: 'login_type',
render: (value: LoginLog['login_type']) => LoginTypeText[value] || value,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: LoginLog['status']) => (
<Tag color={LoginStatusColor[status]}>{LoginStatusText[status]}</Tag>
),
},
{
title: 'IP',
dataIndex: 'ip',
key: 'ip',
},
{
title: '位置',
dataIndex: 'location',
key: 'location',
render: (value) => value || '-',
},
{
title: '时间',
dataIndex: 'created_at',
key: 'created_at',
render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm:ss'),
},
]
const operationLogColumns: TableColumnsType<OperationLog> = [
{
title: '操作类型',
dataIndex: 'operation_type',
key: 'operation_type',
},
{
title: '操作名称',
dataIndex: 'operation_name',
key: 'operation_name',
},
{
title: '方法',
dataIndex: 'request_method',
key: 'request_method',
render: (value) => <Tag>{value}</Tag>,
},
{
title: '状态码',
dataIndex: 'response_status',
key: 'response_status',
render: (value) => (
<Tag color={value >= 200 && value < 300 ? 'success' : 'error'}>{value}</Tag>
),
},
{
title: '时间',
dataIndex: 'created_at',
key: 'created_at',
render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm:ss'),
},
]
return (
<PageLayout>
<PageHeader
title="安全设置"
description="维护头像、密码、TOTP、设备和当前账号最近的审计日志"
/>
<Row gutter={[24, 24]}>
<Col xs={24} lg={8}>
<ContentCard
title={
<Space>
<UserOutlined />
</Space>
}
>
<Space direction="vertical" align="center" style={{ width: '100%' }}>
<Avatar size={96} src={user?.avatar || null} icon={<UserOutlined />} />
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={handleAvatarUpload}
>
<Button icon={<UploadOutlined />} loading={avatarLoading}>
</Button>
</Upload>
<Text type="secondary"></Text>
</Space>
</ContentCard>
</Col>
<Col xs={24} lg={8}>
<ContentCard
title={
<Space>
<LockOutlined />
</Space>
}
>
<Form form={passwordForm} layout="vertical" onFinish={handlePasswordSubmit}>
<Form.Item
name="current_password"
label="当前密码"
rules={[{ required: true, message: '请输入当前密码' }]}
>
<Input.Password placeholder="请输入当前密码" />
</Form.Item>
<Form.Item
name="new_password"
label="新密码"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 8, message: '密码至少 8 位' },
]}
>
<Input.Password placeholder="请输入新密码" />
</Form.Item>
<Form.Item
name="confirm_password"
label="确认新密码"
dependencies={['new_password']}
rules={[
{ required: true, message: '请再次输入新密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('new_password') === value) {
return Promise.resolve()
}
return Promise.reject(new Error('两次输入的密码不一致'))
},
}),
]}
>
<Input.Password placeholder="请再次输入新密码" />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" loading={passwordLoading}>
</Button>
</Form.Item>
</Form>
</ContentCard>
</Col>
<Col xs={24} lg={8}>
<ContentCard
title={
<Space>
<SafetyOutlined />
TOTP
</Space>
}
>
{totpEnabled ? (
<Space direction="vertical" style={{ width: '100%' }}>
<Alert
type="success"
showIcon
icon={<CheckCircleOutlined />}
message="TOTP 已启用"
description="当前账号登录时可以使用动态验证码进行二次验证。"
/>
<Button danger onClick={() => setDisableVisible(true)}>
TOTP
</Button>
</Space>
) : (
<Space direction="vertical" style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="TOTP 未启用"
description="后端已接入 `/auth/2fa/*`,可以完成真实的初始化、启用和禁用。"
/>
<Button type="primary" onClick={handleOpenTOTPSetup} loading={totpLoading}>
TOTP
</Button>
</Space>
)}
</ContentCard>
</Col>
</Row>
{user && (
<ContactBindingsSection
userId={user.id}
emailBindingEnabled={emailBindingEnabled}
phoneBindingEnabled={phoneBindingEnabled}
refreshSessionUser={refreshUser}
/>
)}
<ContentCard title="社交账号绑定" style={{ marginTop: 24 }}>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="绑定前会校验当前会话身份;解绑时系统会校验密码或 TOTP并确保至少保留一种登录方式。"
/>
<div>
<Text strong></Text>
<div style={{ marginTop: 12 }}>
<Space wrap>
{availableSocialProviders.map((provider) => (
<Button key={provider.provider} onClick={() => openBindModal(provider.provider)}>
{provider.name}
</Button>
))}
{availableSocialProviders.length === 0 && (
<Text type="secondary"></Text>
)}
</Space>
</div>
</div>
<Table
rowKey="id"
dataSource={socialAccounts}
columns={socialAccountColumns}
loading={socialLoading}
pagination={false}
locale={{ emptyText: '暂无已绑定的社交账号' }}
/>
</Space>
</ContentCard>
<ContentCard
title={
<Space>
<DesktopOutlined />
</Space>
}
style={{ marginTop: 24 }}
>
<Table
rowKey="id"
dataSource={devices}
columns={deviceColumns}
loading={devicesLoading}
pagination={false}
locale={{ emptyText: '暂无设备数据' }}
/>
</ContentCard>
<Row gutter={[24, 24]} style={{ marginTop: 24 }}>
<Col xs={24} xl={12}>
<ContentCard title="我的登录日志">
<Table
rowKey="id"
dataSource={loginLogs}
columns={loginLogColumns}
loading={logsLoading}
pagination={false}
size="small"
locale={{ emptyText: '暂无登录日志' }}
/>
</ContentCard>
</Col>
<Col xs={24} xl={12}>
<ContentCard title="我的操作日志">
<Table
rowKey="id"
dataSource={operationLogs}
columns={operationLogColumns}
loading={logsLoading}
pagination={false}
size="small"
locale={{ emptyText: '暂无操作日志' }}
/>
</ContentCard>
</Col>
</Row>
<Modal
title={activeProvider ? `绑定 ${getProviderLabel(activeProvider)}` : '绑定社交账号'}
open={bindVisible}
onCancel={() => {
setBindVisible(false)
setActiveProvider(null)
bindSocialForm.resetFields()
}}
onOk={() => bindSocialForm.submit()}
confirmLoading={socialSubmitting}
okText="继续授权"
>
<Space direction="vertical" style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="如果当前账号已设置密码或 TOTP请至少提供一种验证方式。纯 OAuth 账号在没有额外因子时会直接使用当前登录会话继续授权。"
/>
<Form form={bindSocialForm} layout="vertical" onFinish={handleStartSocialBinding}>
<Form.Item name="current_password" label="当前密码">
<Input.Password placeholder="有密码时建议填写" />
</Form.Item>
<Form.Item name="totp_code" label="TOTP / 恢复码">
<Input placeholder="已开启 TOTP 时可填写验证码或恢复码" />
</Form.Item>
</Form>
</Space>
</Modal>
<Modal
title={activeProvider ? `解绑 ${getProviderLabel(activeProvider)}` : '解绑社交账号'}
open={unbindVisible}
onCancel={() => {
setUnbindVisible(false)
setActiveProvider(null)
unbindSocialForm.resetFields()
}}
onOk={() => unbindSocialForm.submit()}
confirmLoading={socialSubmitting}
okText="确认解绑"
okButtonProps={{ danger: true }}
>
<Space direction="vertical" style={{ width: '100%' }}>
<Alert
type="warning"
showIcon
message="解绑前需要验证当前账号身份;如果解绑后没有其他可用登录方式,系统会拒绝本次操作。"
/>
<Form form={unbindSocialForm} layout="vertical" onFinish={handleConfirmUnbind}>
<Form.Item name="current_password" label="当前密码">
<Input.Password placeholder="填写当前密码" />
</Form.Item>
<Form.Item name="totp_code" label="TOTP / 恢复码">
<Input placeholder="或填写 TOTP 验证码 / 恢复码" />
</Form.Item>
</Form>
</Space>
</Modal>
<Modal
title="启用 TOTP"
open={setupVisible}
onCancel={() => {
setSetupVisible(false)
setTotpCode('')
setTotpRememberDevice(false)
}}
onOk={handleEnableTOTP}
confirmLoading={totpLoading}
okText="确认启用"
>
{totpSetup && (
<Space direction="vertical" style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="请先使用身份验证器扫码,再输入 6 位验证码完成启用。"
/>
<div style={{ textAlign: 'center' }}>
<img
src={`data:image/png;base64,${totpSetup.qr_code_base64}`}
alt="TOTP QR Code"
style={{ maxWidth: 240 }}
/>
</div>
<Text copyable>{totpSetup.secret}</Text>
{totpSetup.recovery_codes.length > 0 && (
<div>
<Text strong></Text>
<div style={{ marginTop: 8 }}>
<Space wrap>
{totpSetup.recovery_codes.map((code) => (
<Tag key={code}>{code}</Tag>
))}
</Space>
</div>
</div>
)}
<Input
value={totpCode}
onChange={(event) => setTotpCode(event.target.value.trim())}
maxLength={6}
placeholder="请输入 6 位验证码"
/>
<Checkbox checked={totpRememberDevice} onChange={(e) => setTotpRememberDevice(e.target.checked)}>
30
</Checkbox>
</Space>
)}
</Modal>
<Modal
title="禁用 TOTP"
open={disableVisible}
onCancel={() => {
setDisableVisible(false)
setDisableCode('')
}}
onOk={handleDisableTOTP}
confirmLoading={totpLoading}
okText="确认禁用"
>
<Space direction="vertical" style={{ width: '100%' }}>
<Alert
type="warning"
showIcon
message="请输入当前验证码或恢复码完成禁用。"
/>
<Input
value={disableCode}
onChange={(event) => setDisableCode(event.target.value.trim())}
placeholder="验证码或恢复码"
/>
</Space>
</Modal>
</PageLayout>
)
}

View File

@@ -0,0 +1 @@
export { ProfileSecurityPage } from './ProfileSecurityPage'

View File

@@ -0,0 +1,225 @@
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 { Role } from '@/types/role'
import { RoleFormModal } from './RoleFormModal'
const createRoleMock = vi.fn()
const updateRoleMock = vi.fn()
const messageSuccessMock = vi.fn()
const messageErrorMock = vi.fn()
const formApi = {
validateFields: vi.fn(),
setFieldsValue: vi.fn(),
resetFields: vi.fn(),
}
vi.mock('antd', () => {
const Form = ({
children,
}: {
children?: ReactNode
}) => <form>{children}</form>
return {
Modal: ({
open,
title,
onOk,
onCancel,
children,
}: {
open: boolean
title: ReactNode
onOk?: () => void
onCancel?: () => void
children?: ReactNode
}) => (
open ? (
<div data-testid="role-form-modal-root">
<h1>{title}</h1>
{children}
<button type="button" onClick={onOk}>modal ok</button>
<button type="button" onClick={onCancel}>modal cancel</button>
</div>
) : null
),
Form: Object.assign(Form, {
useForm: () => [formApi],
Item: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}),
Input: Object.assign(({
placeholder,
disabled,
}: {
placeholder?: string
disabled?: boolean
}) => <input placeholder={placeholder} disabled={disabled} />, {
TextArea: ({ placeholder }: { placeholder?: string }) => <textarea placeholder={placeholder} />,
}),
Switch: () => <button type="button">switch</button>,
message: {
success: (content: string) => messageSuccessMock(content),
error: (content: string) => messageErrorMock(content),
},
}
})
vi.mock('@/services/roles', () => ({
createRole: (payload: unknown) => createRoleMock(payload),
updateRole: (id: number, payload: unknown) => updateRoleMock(id, payload),
}))
function buildRole(): Role {
return {
id: 7,
name: 'Editor',
code: 'editor',
description: 'editor role',
is_system: false,
is_default: true,
status: 1,
}
}
describe('RoleFormModal', () => {
beforeEach(() => {
createRoleMock.mockReset()
updateRoleMock.mockReset()
messageSuccessMock.mockReset()
messageErrorMock.mockReset()
formApi.validateFields.mockReset()
formApi.setFieldsValue.mockReset()
formApi.resetFields.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('creates a role in create mode and resets/close on cancel', async () => {
const user = userEvent.setup()
const onSuccess = vi.fn()
const onClose = vi.fn()
formApi.validateFields.mockResolvedValue({
name: 'Auditor',
code: 'auditor',
description: 'auditor role',
is_default: false,
})
createRoleMock.mockResolvedValue(undefined)
render(
<RoleFormModal
open
role={null}
onSuccess={onSuccess}
onClose={onClose}
/>,
)
expect(formApi.resetFields).toHaveBeenCalled()
expect(screen.getByText('创建角色')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'modal ok' }))
await waitFor(() => expect(createRoleMock).toHaveBeenCalledWith({
name: 'Auditor',
code: 'auditor',
description: 'auditor role',
is_default: false,
}))
expect(messageSuccessMock).toHaveBeenCalledWith('角色已创建')
expect(onSuccess).toHaveBeenCalledTimes(1)
await user.click(screen.getByRole('button', { name: 'modal cancel' }))
expect(formApi.resetFields).toHaveBeenCalledTimes(2)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('prefills edit mode and updates the role without code changes', async () => {
const user = userEvent.setup()
const role = buildRole()
const onSuccess = vi.fn()
formApi.validateFields.mockResolvedValue({
name: 'Editor Updated',
code: 'ignored-code',
description: 'updated description',
is_default: false,
})
updateRoleMock.mockResolvedValue(undefined)
render(
<RoleFormModal
open
role={role}
onSuccess={onSuccess}
onClose={vi.fn()}
/>,
)
expect(formApi.setFieldsValue).toHaveBeenCalledWith({
name: 'Editor',
code: 'editor',
description: 'editor role',
is_default: true,
})
expect(screen.getByText('编辑角色')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'modal ok' }))
await waitFor(() => expect(updateRoleMock).toHaveBeenCalledWith(7, {
name: 'Editor Updated',
description: 'updated description',
is_default: false,
}))
expect(messageSuccessMock).toHaveBeenCalledWith('角色已更新')
expect(onSuccess).toHaveBeenCalledTimes(1)
})
it('swallows form validation errors and surfaces service failures', async () => {
const user = userEvent.setup()
formApi.validateFields.mockRejectedValueOnce({ errorFields: [{ name: ['name'] }] })
const { rerender } = render(
<RoleFormModal
open
role={null}
onSuccess={vi.fn()}
onClose={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'modal ok' }))
await waitFor(() => expect(createRoleMock).not.toHaveBeenCalled())
expect(updateRoleMock).not.toHaveBeenCalled()
expect(messageErrorMock).not.toHaveBeenCalled()
formApi.validateFields.mockResolvedValueOnce({
name: 'Broken Role',
code: 'broken_role',
description: '',
is_default: false,
})
createRoleMock.mockRejectedValueOnce(new Error('save failed'))
rerender(
<RoleFormModal
open
role={null}
onSuccess={vi.fn()}
onClose={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'modal ok' }))
await waitFor(() => expect(messageErrorMock).toHaveBeenCalledWith('save failed'))
})
})

View File

@@ -0,0 +1,141 @@
/**
* 角色创建/编辑弹窗
*/
import { useState, useEffect } from 'react'
import {
Modal,
Form,
Input,
Switch,
message,
} from 'antd'
import { getErrorMessage, isFormValidationError } from '@/lib/errors'
import type { Role } from '@/types/role'
import { createRole, updateRole } from '@/services/roles'
interface RoleFormModalProps {
open: boolean
role: Role | null
onSuccess: () => void
onClose: () => void
}
export function RoleFormModal({ open, role, onSuccess, onClose }: RoleFormModalProps) {
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const isEdit = !!role
// 初始化表单
useEffect(() => {
if (open) {
if (role) {
form.setFieldsValue({
name: role.name,
code: role.code,
description: role.description,
is_default: role.is_default,
})
} else {
form.resetFields()
}
}
}, [open, role, form])
// 提交表单
const handleSubmit = async () => {
try {
const values = await form.validateFields()
setLoading(true)
if (isEdit && role) {
await updateRole(role.id, {
name: values.name,
description: values.description,
is_default: values.is_default,
})
message.success('角色已更新')
} else {
await createRole(values)
message.success('角色已创建')
}
onSuccess()
} catch (err) {
if (isFormValidationError(err)) {
return
}
message.error(getErrorMessage(err, '操作失败'))
} finally {
setLoading(false)
}
}
// 关闭时重置
const handleClose = () => {
form.resetFields()
onClose()
}
return (
<Modal
title={isEdit ? '编辑角色' : '创建角色'}
open={open}
onOk={handleSubmit}
onCancel={handleClose}
confirmLoading={loading}
width={520}
>
<Form
form={form}
layout="vertical"
initialValues={{
is_default: false,
}}
>
<Form.Item
name="name"
label="角色名称"
rules={[
{ required: true, message: '请输入角色名称' },
{ max: 50, message: '角色名称不能超过50个字符' },
]}
>
<Input placeholder="如:内容管理员" />
</Form.Item>
<Form.Item
name="code"
label="角色代码"
rules={[
{ required: true, message: '请输入角色代码' },
{ pattern: /^[a-zA-Z][a-zA-Z0-9_]*$/, message: '角色代码必须以字母开头,只能包含字母、数字和下划线' },
{ max: 50, message: '角色代码不能超过50个字符' },
]}
extra="角色代码用于权限判断,创建后不可修改"
>
<Input placeholder="如content_manager" disabled={isEdit} />
</Form.Item>
<Form.Item
name="description"
label="角色描述"
rules={[
{ max: 200, message: '角色描述不能超过200个字符' },
]}
>
<Input.TextArea rows={3} placeholder="描述该角色的职责和权限范围" />
</Form.Item>
<Form.Item
name="is_default"
label="默认角色"
valuePropName="checked"
extra="默认角色会在新用户注册时自动分配"
>
<Switch checkedChildren="是" unCheckedChildren="否" />
</Form.Item>
</Form>
</Modal>
)
}

View File

@@ -0,0 +1,204 @@
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 { Permission } from '@/types/permission'
import type { Role } from '@/types/role'
import { RolePermissionsModal } from './RolePermissionsModal'
const getRolePermissionsMock = vi.fn()
const assignRolePermissionsMock = vi.fn()
const getPermissionTreeMock = vi.fn()
const messageSuccessMock = vi.fn()
const messageErrorMock = vi.fn()
vi.mock('antd', () => ({
Empty: ({ description }: { description?: ReactNode }) => <div>{description ?? 'empty'}</div>,
Modal: ({
open,
title,
onOk,
onCancel,
children,
}: {
open: boolean
title: ReactNode
onOk?: () => void
onCancel?: () => void
children?: ReactNode
}) => (
open ? (
<div data-testid="role-permissions-modal-root">
<h1>{title}</h1>
{children}
<button type="button" onClick={onOk}>modal ok</button>
<button type="button" onClick={onCancel}>modal cancel</button>
</div>
) : null
),
Spin: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
Tree: ({
checkedKeys,
onCheck,
treeData = [],
}: {
checkedKeys?: string[]
onCheck?: (keys: string[]) => void
treeData?: Array<{ key: string, title: ReactNode, children?: unknown[] }>
}) => (
<div>
<div>{`checked:${(checkedKeys ?? []).join(',')}`}</div>
{treeData.map((node) => (
<div key={node.key}>{node.title}</div>
))}
<button type="button" onClick={() => onCheck?.(['1', '2'])}>check permissions</button>
</div>
),
message: {
success: (content: string) => messageSuccessMock(content),
error: (content: string) => messageErrorMock(content),
},
}))
vi.mock('@/services/roles', () => ({
getRolePermissions: (id: number) => getRolePermissionsMock(id),
assignRolePermissions: (id: number, permissionIds: number[]) => assignRolePermissionsMock(id, permissionIds),
}))
vi.mock('@/services/permissions', () => ({
getPermissionTree: () => getPermissionTreeMock(),
}))
function buildRole(): Role {
return {
id: 9,
name: 'Editor',
code: 'editor',
description: 'editor role',
is_system: false,
is_default: false,
status: 1,
}
}
function buildPermission(id: number, name: string, children?: Permission[]): Permission {
return {
id,
name,
code: `${name.toLowerCase()}_${id}`,
type: 'menu',
parent_id: null,
sort: id,
status: 1,
children,
}
}
describe('RolePermissionsModal', () => {
beforeEach(() => {
getRolePermissionsMock.mockReset()
assignRolePermissionsMock.mockReset()
getPermissionTreeMock.mockReset()
messageSuccessMock.mockReset()
messageErrorMock.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('loads permission data, tracks checked keys, submits assignments, and closes', async () => {
const user = userEvent.setup()
const role = buildRole()
const onSuccess = vi.fn()
const onClose = vi.fn()
getPermissionTreeMock.mockResolvedValue([
buildPermission(1, 'Dashboard'),
buildPermission(2, 'Users'),
])
getRolePermissionsMock.mockResolvedValue([2])
assignRolePermissionsMock.mockResolvedValue(undefined)
render(
<RolePermissionsModal
open
role={role}
onSuccess={onSuccess}
onClose={onClose}
/>,
)
expect(await screen.findByText('Dashboard')).toBeInTheDocument()
expect(screen.getByText('Users')).toBeInTheDocument()
expect(screen.getByText('checked:2')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'check permissions' }))
expect(screen.getByText('checked:1,2')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'modal ok' }))
await waitFor(() => expect(assignRolePermissionsMock).toHaveBeenCalledWith(9, [1, 2]))
expect(messageSuccessMock).toHaveBeenCalledWith('权限分配成功')
expect(onSuccess).toHaveBeenCalledTimes(1)
await user.click(screen.getByRole('button', { name: 'modal cancel' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('surfaces loading failures and renders the empty state', async () => {
const role = buildRole()
getPermissionTreeMock.mockRejectedValueOnce(new Error('load permissions failed'))
getRolePermissionsMock.mockResolvedValueOnce([])
const { rerender } = render(
<RolePermissionsModal
open
role={role}
onSuccess={vi.fn()}
onClose={vi.fn()}
/>,
)
await waitFor(() => expect(messageErrorMock).toHaveBeenCalledWith('load permissions failed'))
getPermissionTreeMock.mockResolvedValueOnce([])
getRolePermissionsMock.mockResolvedValueOnce([])
rerender(
<RolePermissionsModal
open
role={{ ...role, id: 10, code: 'auditor', name: 'Auditor' }}
onSuccess={vi.fn()}
onClose={vi.fn()}
/>,
)
expect(await screen.findByText('暂无权限数据')).toBeInTheDocument()
})
it('surfaces assignment failures', async () => {
const user = userEvent.setup()
getPermissionTreeMock.mockResolvedValue([buildPermission(1, 'Dashboard')])
getRolePermissionsMock.mockResolvedValue([])
assignRolePermissionsMock.mockRejectedValueOnce(new Error('assign failed'))
render(
<RolePermissionsModal
open
role={buildRole()}
onSuccess={vi.fn()}
onClose={vi.fn()}
/>,
)
expect(await screen.findByText('Dashboard')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'modal ok' }))
await waitFor(() => expect(messageErrorMock).toHaveBeenCalledWith('assign failed'))
})
})

View File

@@ -0,0 +1,112 @@
/**
* 角色权限分配弹窗
*/
import { useState, useEffect } from 'react'
import { Modal, Tree, Spin, message, Empty } from 'antd'
import type { TreeProps } from 'antd'
import { getErrorMessage } from '@/lib/errors'
import type { Role } from '@/types/role'
import type { Permission } from '@/types/permission'
import { getRolePermissions, assignRolePermissions } from '@/services/roles'
import { getPermissionTree } from '@/services/permissions'
interface RolePermissionsModalProps {
open: boolean
role: Role | null
onSuccess: () => void
onClose: () => void
}
export function RolePermissionsModal({
open,
role,
onSuccess,
onClose,
}: RolePermissionsModalProps) {
const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [permissions, setPermissions] = useState<Permission[]>([])
const [checkedKeys, setCheckedKeys] = useState<string[]>([])
// 加载权限树和角色权限
useEffect(() => {
if (open && role) {
const fetchData = async () => {
setLoading(true)
try {
const [permTree, rolePerms] = await Promise.all([
getPermissionTree(),
getRolePermissions(role.id),
])
setPermissions(permTree)
setCheckedKeys(rolePerms.map(String))
} catch (err) {
message.error(getErrorMessage(err, '加载权限数据失败'))
} finally {
setLoading(false)
}
}
fetchData()
}
}, [open, role])
// 处理勾选
const handleCheck: TreeProps['onCheck'] = (keys) => {
setCheckedKeys(keys as string[])
}
// 提交权限分配
const handleSubmit = async () => {
if (!role) return
setSubmitting(true)
try {
await assignRolePermissions(role.id, checkedKeys.map(Number))
message.success('权限分配成功')
onSuccess()
} catch (err) {
message.error(getErrorMessage(err, '权限分配失败'))
} finally {
setSubmitting(false)
}
}
// 将权限树转换为 Tree 组件数据
const buildTreeData = (perms: Permission[]): TreeProps['treeData'] => {
return perms.map((perm) => ({
key: String(perm.id),
title: perm.name,
children: perm.children ? buildTreeData(perm.children) : undefined,
}))
}
return (
<Modal
title={`分配权限 - ${role?.name || ''}`}
open={open}
onOk={handleSubmit}
onCancel={onClose}
confirmLoading={submitting}
width={600}
>
<p style={{ marginBottom: 16, color: 'var(--color-text-muted)' }}>
</p>
<Spin spinning={loading}>
{permissions.length > 0 ? (
<Tree
checkable
checkedKeys={checkedKeys}
onCheck={handleCheck}
treeData={buildTreeData(permissions)}
style={{ minHeight: 200, maxHeight: 400, overflow: 'auto' }}
defaultExpandAll
/>
) : (
<Empty description="暂无权限数据" />
)}
</Spin>
</Modal>
)
}

View File

@@ -0,0 +1,11 @@
.filterCard {
margin-bottom: 16px;
}
.tableCard {
margin-bottom: 16px;
}
.tagSystem {
margin-left: 8px;
}

View File

@@ -0,0 +1,514 @@
import type { ReactElement, ReactNode } from 'react'
import { cloneElement, isValidElement } 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 { PaginatedData } from '@/types/http'
import type { Role, RoleListParams, RoleStatus } from '@/types/role'
import { RolesPage } from './RolesPage'
const listRolesMock = vi.fn<(params?: RoleListParams) => Promise<PaginatedData<Role>>>()
const deleteRoleMock = vi.fn<(id: number) => Promise<void>>()
const updateRoleStatusMock = vi.fn<(id: number, status: RoleStatus) => Promise<void>>()
const messageSuccessMock = vi.fn()
const messageErrorMock = vi.fn()
vi.mock('antd', async () => {
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 {
Button: ({
children,
onClick,
htmlType,
type: buttonType,
icon,
...props
}: {
children?: ReactNode
onClick?: () => void
htmlType?: 'button' | 'submit' | 'reset'
[key: string]: unknown
}) => {
void buttonType
void icon
return (
<button type={htmlType ?? 'button'} onClick={onClick} {...props}>
{children}
</button>
)
},
Input: ({
onPressEnter,
prefix,
allowClear,
...props
}: {
onPressEnter?: () => void
prefix?: ReactNode
allowClear?: boolean
[key: string]: unknown
}) => {
void prefix
void allowClear
return (
<input
{...props}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onPressEnter?.()
}
}}
/>
)
},
Popconfirm: ({
children,
onConfirm,
}: {
children: ReactElement
onConfirm?: () => void
}) => {
if (!isValidElement(children)) {
return children
}
const child = children as ReactElement<{ onClick?: () => void }>
return cloneElement(child, {
onClick: () => onConfirm?.(),
})
},
Select: ({
value,
onChange,
options = [],
placeholder,
allowClear,
...props
}: {
value?: string | number
onChange?: (value: unknown) => void
options?: Array<{ value: string | number, label: ReactNode }>
placeholder?: string
allowClear?: boolean
[key: string]: unknown
}) => {
void allowClear
return (
<select
aria-label={placeholder ?? 'select'}
value={value === undefined ? '' : String(value)}
onChange={(event) => {
const nextValue = event.target.value
if (nextValue === '') {
onChange?.(undefined)
return
}
const matchedOption = options.find((option) => String(option.value) === nextValue)
onChange?.(matchedOption?.value ?? nextValue)
}}
{...props}
>
<option value="">{placeholder ?? 'all'}</option>
{options.map((option) => (
<option key={String(option.value)} value={String(option.value)}>
{flattenChildren(option.label)}
</option>
))}
</select>
)
},
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>
</div>
)
},
Tag: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
message: {
success: (content: string) => messageSuccessMock(content),
error: (content: string) => messageErrorMock(content),
},
}
})
vi.mock('@ant-design/icons', () => ({
DeleteOutlined: () => <span>delete</span>,
EditOutlined: () => <span>edit</span>,
PlusOutlined: () => <span>plus</span>,
ReloadOutlined: () => <span>reload</span>,
SafetyOutlined: () => <span>safety</span>,
SearchOutlined: () => <span>search</span>,
}))
vi.mock('@/components/common', () => ({
PageHeader: ({
title,
description,
actions,
}: {
title: ReactNode
description?: ReactNode
actions?: ReactNode
}) => (
<section>
<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/roles', () => ({
listRoles: (params?: RoleListParams) => listRolesMock(params),
deleteRole: (id: number) => deleteRoleMock(id),
updateRoleStatus: (id: number, status: RoleStatus) => updateRoleStatusMock(id, status),
}))
vi.mock('./RoleFormModal', () => ({
RoleFormModal: ({
open,
role,
onSuccess,
onClose,
}: {
open: boolean
role: Role | null
onSuccess: () => void
onClose: () => void
}) => (
open ? (
<div data-testid="role-form-modal">
<span>{`role-form:${role?.code ?? 'create'}`}</span>
<button type="button" onClick={onSuccess}>role form success</button>
<button type="button" onClick={onClose}>role form close</button>
</div>
) : null
),
}))
vi.mock('./RolePermissionsModal', () => ({
RolePermissionsModal: ({
open,
role,
onSuccess,
onClose,
}: {
open: boolean
role: Role | null
onSuccess: () => void
onClose: () => void
}) => (
open ? (
<div data-testid="role-permissions-modal">
<span>{`role-permissions:${role?.code ?? 'none'}`}</span>
<button type="button" onClick={onSuccess}>role permissions success</button>
<button type="button" onClick={onClose}>role permissions close</button>
</div>
) : null
),
}))
function buildRole(
id: number,
name: string,
code: string,
status: RoleStatus,
isSystem = false,
): Role {
return {
id,
name,
code,
description: `${name} description`,
is_system: isSystem,
is_default: id === 2,
status,
}
}
describe('RolesPage', () => {
let currentRoles: Role[]
beforeEach(() => {
currentRoles = [
buildRole(1, 'Admin', 'admin', 1, true),
buildRole(2, 'Editor', 'editor', 1),
buildRole(3, 'Auditor', 'auditor', 0),
]
listRolesMock.mockReset()
deleteRoleMock.mockReset()
updateRoleStatusMock.mockReset()
messageSuccessMock.mockReset()
messageErrorMock.mockReset()
listRolesMock.mockImplementation(async (params?: RoleListParams) => {
const keyword = (params?.keyword || '').toLowerCase()
let items = currentRoles
if (keyword) {
items = items.filter((role) => (
role.name.toLowerCase().includes(keyword) ||
role.code.toLowerCase().includes(keyword)
))
}
if (params?.status !== undefined) {
items = items.filter((role) => role.status === params.status)
}
return {
items: items.map((role) => ({ ...role })),
total: items.length,
page: params?.page ?? 1,
page_size: params?.page_size ?? 20,
}
})
deleteRoleMock.mockImplementation(async (id: number) => {
currentRoles = currentRoles.filter((role) => role.id !== id)
})
updateRoleStatusMock.mockImplementation(async (id: number, status: RoleStatus) => {
currentRoles = currentRoles.map((role) => (
role.id === id ? { ...role, status } : role
))
})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('loads, filters, resets, refreshes, paginates, and opens role overlays', async () => {
const user = userEvent.setup()
render(<RolesPage />)
expect(await screen.findByText('Admin')).toBeInTheDocument()
expect(screen.getByText('Editor')).toBeInTheDocument()
expect(listRolesMock).toHaveBeenLastCalledWith(expect.objectContaining({
page: 1,
page_size: 20,
keyword: undefined,
status: undefined,
}))
const keywordInput = screen.getByPlaceholderText('角色名称/代码')
const statusSelect = screen.getByRole('combobox')
await user.type(keywordInput, 'audit')
await user.selectOptions(statusSelect, '0')
await user.click(screen.getByRole('button', { name: '查询' }))
await waitFor(() => expect(screen.queryByText('Admin')).not.toBeInTheDocument())
expect(screen.getByText('Auditor')).toBeInTheDocument()
expect(listRolesMock).toHaveBeenLastCalledWith(expect.objectContaining({
keyword: 'audit',
status: 0,
}))
await user.click(screen.getByRole('button', { name: '重置' }))
await waitFor(() => expect(screen.getByText('Admin')).toBeInTheDocument())
expect(screen.getByText('Editor')).toBeInTheDocument()
expect(listRolesMock).toHaveBeenLastCalledWith(expect.objectContaining({
keyword: undefined,
status: undefined,
}))
const refreshCallCount = listRolesMock.mock.calls.length
await user.click(screen.getByRole('button', { name: '刷新' }))
await waitFor(() => expect(listRolesMock.mock.calls.length).toBeGreaterThan(refreshCallCount))
await user.click(screen.getByRole('button', { name: 'paginate' }))
await waitFor(() => expect(listRolesMock).toHaveBeenLastCalledWith(expect.objectContaining({
page: 1,
page_size: 50,
})))
const createCallCount = listRolesMock.mock.calls.length
await user.click(screen.getByRole('button', { name: '创建角色' }))
expect(screen.getByTestId('role-form-modal')).toHaveTextContent('role-form:create')
await user.click(screen.getByRole('button', { name: 'role form success' }))
await waitFor(() => expect(listRolesMock.mock.calls.length).toBeGreaterThan(createCallCount))
await waitFor(() => expect(screen.queryByTestId('role-form-modal')).not.toBeInTheDocument())
const editorRow = screen.getByTestId('table-row-2')
const editCallCount = listRolesMock.mock.calls.length
await user.click(within(editorRow).getByRole('button', { name: '编辑' }))
expect(screen.getByTestId('role-form-modal')).toHaveTextContent('role-form:editor')
await user.click(screen.getByRole('button', { name: 'role form success' }))
await waitFor(() => expect(listRolesMock.mock.calls.length).toBeGreaterThan(editCallCount))
await waitFor(() => expect(screen.queryByTestId('role-form-modal')).not.toBeInTheDocument())
await user.click(within(editorRow).getByRole('button', { name: '权限' }))
expect(screen.getByTestId('role-permissions-modal')).toHaveTextContent('role-permissions:editor')
await user.click(screen.getByRole('button', { name: 'role permissions success' }))
await waitFor(() => expect(screen.queryByTestId('role-permissions-modal')).not.toBeInTheDocument())
})
it('honors system-role protections, toggles status branches, and deletes non-system roles', async () => {
const user = userEvent.setup()
render(<RolesPage />)
expect(await screen.findByText('Admin')).toBeInTheDocument()
const adminRow = screen.getByTestId('table-row-1')
expect(within(adminRow).getByRole('button', { name: '编辑' })).toBeDisabled()
expect(within(adminRow).getByRole('button', { name: '禁用' })).toBeDisabled()
expect(within(adminRow).getByRole('button', { name: '删除' })).toBeDisabled()
const editorRow = screen.getByTestId('table-row-2')
await user.click(within(editorRow).getByRole('button', { name: '禁用' }))
await waitFor(() => expect(updateRoleStatusMock).toHaveBeenCalledWith(2, 0))
expect(messageSuccessMock).toHaveBeenCalledWith('状态已更新')
await waitFor(() => expect(within(screen.getByTestId('table-row-2')).getByRole('button', { name: '启用' })).toBeInTheDocument())
const auditorRow = screen.getByTestId('table-row-3')
await user.click(within(auditorRow).getByRole('button', { name: '启用' }))
await waitFor(() => expect(updateRoleStatusMock).toHaveBeenCalledWith(3, 1))
await user.click(within(screen.getByTestId('table-row-3')).getByRole('button', { name: '删除' }))
await waitFor(() => expect(deleteRoleMock).toHaveBeenCalledWith(3))
expect(messageSuccessMock).toHaveBeenCalledWith('角色已删除')
await waitFor(() => expect(screen.queryByTestId('table-row-3')).not.toBeInTheDocument())
})
it('renders the page error state and retries fetching', async () => {
const user = userEvent.setup()
listRolesMock.mockReset()
listRolesMock.mockRejectedValueOnce(new Error('roles failed'))
listRolesMock.mockResolvedValue({
items: currentRoles.map((role) => ({ ...role })),
total: currentRoles.length,
page: 1,
page_size: 20,
})
render(<RolesPage />)
expect(await screen.findByText('roles failed')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'retry' }))
await waitFor(() => expect(screen.getByText('Admin')).toBeInTheDocument())
expect(listRolesMock).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,300 @@
import { useCallback, useEffect, useState } from 'react'
import {
Button,
Input,
Popconfirm,
Select,
Space,
Table,
Tag,
message,
type TableColumnsType,
type TablePaginationConfig,
} from 'antd'
import {
DeleteOutlined,
EditOutlined,
PlusOutlined,
ReloadOutlined,
SafetyOutlined,
SearchOutlined,
} from '@ant-design/icons'
import { PageHeader } from '@/components/common'
import { PageEmpty, PageError } from '@/components/feedback'
import { PageLayout, FilterCard, TableCard } from '@/components/layout'
import { getErrorMessage } from '@/lib/errors'
import { deleteRole, listRoles, updateRoleStatus } from '@/services/roles'
import { RoleStatusColor, RoleStatusText, type Role, type RoleStatus } from '@/types/role'
import { RoleFormModal } from './RoleFormModal'
import { RolePermissionsModal } from './RolePermissionsModal'
export function RolesPage() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [roles, setRoles] = useState<Role[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [keyword, setKeyword] = useState('')
const [statusFilter, setStatusFilter] = useState<RoleStatus | undefined>()
const [formVisible, setFormVisible] = useState(false)
const [permissionsVisible, setPermissionsVisible] = useState(false)
const [selectedRole, setSelectedRole] = useState<Role | null>(null)
const fetchRoles = useCallback(async () => {
setLoading(true)
setError(null)
try {
const result = await listRoles({
page,
page_size: pageSize,
keyword: keyword || undefined,
status: statusFilter,
})
setRoles(result.items)
setTotal(result.total)
} catch (err) {
setError(getErrorMessage(err, '获取角色列表失败'))
} finally {
setLoading(false)
}
}, [keyword, page, pageSize, statusFilter])
useEffect(() => {
void fetchRoles()
}, [fetchRoles])
// 筛选条件变化时重置到第一页
useEffect(() => {
setPage(1)
}, [keyword, statusFilter])
const handleReset = () => {
setKeyword('')
setStatusFilter(undefined)
setPage(1)
}
const handleDelete = async (id: number) => {
try {
await deleteRole(id)
message.success('角色已删除')
await fetchRoles()
} catch (err) {
message.error(getErrorMessage(err, '删除角色失败'))
}
}
const handleToggleStatus = async (role: Role) => {
const nextStatus: RoleStatus = role.status === 1 ? 0 : 1
try {
await updateRoleStatus(role.id, nextStatus)
message.success('状态已更新')
await fetchRoles()
} catch (err) {
message.error(getErrorMessage(err, '状态更新失败'))
}
}
const columns: TableColumnsType<Role> = [
{
title: '角色名称',
dataIndex: 'name',
key: 'name',
width: 160,
fixed: 'left',
},
{
title: '角色代码',
dataIndex: 'code',
key: 'code',
width: 180,
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
width: 220,
render: (value) => value || '-',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: RoleStatus) => (
<Tag color={RoleStatusColor[status]}>{RoleStatusText[status]}</Tag>
),
},
{
title: '系统角色',
dataIndex: 'is_system',
key: 'is_system',
width: 100,
render: (value) => (value ? <Tag color="blue"></Tag> : '-'),
},
{
title: '默认角色',
dataIndex: 'is_default',
key: 'is_default',
width: 100,
render: (value) => (value ? <Tag color="green"></Tag> : '-'),
},
{
title: '操作',
key: 'action',
width: 260,
fixed: 'right',
render: (_, record) => (
<Space size="small">
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => {
setSelectedRole(record)
setFormVisible(true)
}}
disabled={record.is_system}
>
</Button>
<Button
type="link"
size="small"
icon={<SafetyOutlined />}
onClick={() => {
setSelectedRole(record)
setPermissionsVisible(true)
}}
>
</Button>
<Popconfirm
title={record.status === 1 ? '确定禁用该角色吗?' : '确定启用该角色吗?'}
onConfirm={() => void handleToggleStatus(record)}
>
<Button type="link" size="small" disabled={record.is_system}>
{record.status === 1 ? '禁用' : '启用'}
</Button>
</Popconfirm>
<Popconfirm
title="确定删除该角色吗?"
onConfirm={() => void handleDelete(record.id)}
>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
disabled={record.is_system}
>
</Button>
</Popconfirm>
</Space>
),
},
]
const paginationConfig: TablePaginationConfig = {
current: page,
pageSize,
total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (value) => `${value}`,
onChange: (current, size) => {
setPage(current)
setPageSize(size)
},
}
if (error) {
return <PageError description={error} onRetry={fetchRoles} />
}
return (
<PageLayout>
<PageHeader
title="角色管理"
description="对齐后端分页返回,支持创建、编辑、删除、状态管理和权限分配"
actions={(
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setSelectedRole(null)
setFormVisible(true)
}}
>
</Button>
)}
/>
<FilterCard>
<Space wrap size="middle">
<Input
placeholder="角色名称/代码"
prefix={<SearchOutlined />}
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
onPressEnter={() => void fetchRoles()}
style={{ width: 220 }}
allowClear
/>
<Select
placeholder="角色状态"
value={statusFilter}
onChange={setStatusFilter}
allowClear
style={{ width: 120 }}
options={[
{ value: 0, label: '已禁用' },
{ value: 1, label: '已启用' },
]}
/>
<Button type="primary" icon={<SearchOutlined />} onClick={() => void fetchRoles()}>
</Button>
<Button onClick={handleReset}></Button>
<Button icon={<ReloadOutlined />} onClick={() => void fetchRoles()}>
</Button>
</Space>
</FilterCard>
<TableCard>
<Table
columns={columns}
dataSource={roles}
rowKey="id"
loading={loading}
pagination={paginationConfig}
scroll={{ x: 1200 }}
locale={{ emptyText: <PageEmpty description="暂无角色数据" /> }}
/>
</TableCard>
<RoleFormModal
open={formVisible}
role={selectedRole}
onSuccess={() => {
setFormVisible(false)
void fetchRoles()
}}
onClose={() => setFormVisible(false)}
/>
<RolePermissionsModal
open={permissionsVisible}
role={selectedRole}
onSuccess={() => setPermissionsVisible(false)}
onClose={() => setPermissionsVisible(false)}
/>
</PageLayout>
)
}

View File

@@ -0,0 +1 @@
export { RolesPage } from './RolesPage'

View File

@@ -0,0 +1,179 @@
/**
* 系统设置页
*
* 功能:
* - 显示当前系统配置信息
* - 提供系统配置的静态展示
*/
import { Col, Descriptions, Row, Space, Typography } from 'antd'
import { EnvironmentOutlined, SafetyOutlined, SettingOutlined } from '@ant-design/icons'
import { PageLayout, ContentCard } from '@/components/layout'
import { PageHeader } from '@/components/common'
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() {
return (
<PageLayout>
<PageHeader
title="系统设置"
description="查看当前系统配置和功能开关状态"
actions={
<Space>
<SettingOutlined />
<Text type="secondary"></Text>
</Space>
}
/>
<Row gutter={24}>
<Col xs={24} lg={12}>
<ContentCard
title={
<Space>
<SafetyOutlined />
</Space>
}
>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="密码最小长度">
{systemConfig.security.passwordMinLength}
</Descriptions.Item>
<Descriptions.Item label="密码必须包含大写字母">
{systemConfig.security.passwordRequireUppercase ? '是' : '否'}
</Descriptions.Item>
<Descriptions.Item label="密码必须包含小写字母">
{systemConfig.security.passwordRequireLowercase ? '是' : '否'}
</Descriptions.Item>
<Descriptions.Item label="密码必须包含数字">
{systemConfig.security.passwordRequireNumbers ? '是' : '否'}
</Descriptions.Item>
<Descriptions.Item label="密码必须包含特殊字符">
{systemConfig.security.passwordRequireSymbols ? '是' : '否'}
</Descriptions.Item>
<Descriptions.Item label="密码历史记录">
{systemConfig.security.passwordHistory}
</Descriptions.Item>
<Descriptions.Item label="TOTP 两步验证">
{systemConfig.security.totpEnabled ? '已启用' : '未启用'}
</Descriptions.Item>
<Descriptions.Item label="登录失败锁定">
{systemConfig.security.loginFailLock
? `锁定 ${systemConfig.security.loginFailThreshold} 次后锁定 ${systemConfig.security.loginFailDuration} 分钟`
: '未启用'}
</Descriptions.Item>
<Descriptions.Item label="会话超时">
{systemConfig.security.sessionTimeout / 86400}
</Descriptions.Item>
<Descriptions.Item label="设备信任有效期">
{systemConfig.security.deviceTrustDuration / 86400}
</Descriptions.Item>
</Descriptions>
</ContentCard>
</Col>
<Col xs={24} lg={12}>
<ContentCard
title={
<Space>
<SettingOutlined />
</Space>
}
>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="邮箱验证">
{systemConfig.features.emailVerification ? '已启用' : '未启用'}
</Descriptions.Item>
<Descriptions.Item label="手机验证">
{systemConfig.features.phoneVerification ? '已启用' : '未启用'}
</Descriptions.Item>
<Descriptions.Item label="OAuth 提供商">
{systemConfig.features.oauthProviders.join(', ') || '无'}
</Descriptions.Item>
<Descriptions.Item label="SSO 单点登录">
{systemConfig.features.ssoEnabled ? '已启用' : '未启用'}
</Descriptions.Item>
<Descriptions.Item label="操作日志">
{systemConfig.features.operationLogEnabled ? '已启用' : '未启用'}
</Descriptions.Item>
<Descriptions.Item label="登录日志">
{systemConfig.features.loginLogEnabled ? '已启用' : '未启用'}
</Descriptions.Item>
<Descriptions.Item label="数据导出">
{systemConfig.features.dataExportEnabled ? '已启用' : '未启用'}
</Descriptions.Item>
<Descriptions.Item label="数据导入">
{systemConfig.features.dataImportEnabled ? '已启用' : '未启用'}
</Descriptions.Item>
</Descriptions>
</ContentCard>
</Col>
</Row>
<Row gutter={24} style={{ marginTop: 24 }}>
<Col xs={24}>
<ContentCard
title={
<Space>
<EnvironmentOutlined />
</Space>
}
>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="系统名称">
{systemConfig.system.name}
</Descriptions.Item>
<Descriptions.Item label="版本号">
{systemConfig.system.version}
</Descriptions.Item>
<Descriptions.Item label="运行环境">
{systemConfig.system.environment}
</Descriptions.Item>
<Descriptions.Item label="系统描述">
{systemConfig.system.description}
</Descriptions.Item>
</Descriptions>
</ContentCard>
</Col>
</Row>
</PageLayout>
)
}

View File

@@ -0,0 +1 @@
export { SettingsPage } from './SettingsPage'

View File

@@ -0,0 +1,241 @@
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 { Role, User } from '@/types'
import { AssignRolesModal } from './AssignRolesModal'
const assignUserRolesMock = vi.fn()
const messageSuccessMock = vi.fn()
const messageErrorMock = vi.fn()
vi.mock('antd', () => ({
Modal: ({
open,
title,
onOk,
onCancel,
children,
confirmLoading,
}: {
open: boolean
title?: ReactNode
onOk?: () => void
onCancel?: () => void
children?: ReactNode
confirmLoading?: boolean
}) => (
open ? (
<div data-testid="assign-roles-modal-root">
<h1>{title}</h1>
<div>{`loading:${String(confirmLoading ?? false)}`}</div>
{children}
<button type="button" onClick={onOk}>modal ok</button>
<button type="button" onClick={onCancel}>modal cancel</button>
</div>
) : null
),
Transfer: ({
dataSource = [],
targetKeys = [],
titles,
onChange,
filterOption,
render,
}: {
dataSource?: Array<{
key: string
title: string
description: string
disabled?: boolean
}>
targetKeys?: string[]
titles?: ReactNode[]
onChange?: (keys: string[]) => void
filterOption?: (input: string, item: { title: string, description: string }) => boolean
render?: (item: { title: string }) => ReactNode
}) => (
<div>
<div>{`titles:${(titles ?? []).join('|')}`}</div>
<div>{`target:${targetKeys.join(',')}`}</div>
<div>{`filter-title:${String(filterOption?.('admin', { title: 'Admin', description: 'system role' }))}`}</div>
<div>{`filter-desc:${String(filterOption?.('audit', { title: 'User', description: 'audit role' }))}`}</div>
{dataSource.map((item) => (
<div key={item.key}>{`${render?.(item) ?? item.title}:${String(item.disabled ?? false)}`}</div>
))}
<button type="button" onClick={() => onChange?.(['2'])}>transfer change</button>
</div>
),
message: {
success: (content: string) => messageSuccessMock(content),
error: (content: string) => messageErrorMock(content),
},
}))
vi.mock('@/services/users', () => ({
assignUserRoles: (userId: number, payload: unknown) => assignUserRolesMock(userId, payload),
}))
function buildUser(): User {
return {
id: 42,
username: 'editor-user',
email: 'editor@example.com',
phone: '13800000042',
nickname: 'Editor',
avatar: '',
gender: 0,
birthday: '',
region: '',
bio: '',
status: 1,
last_login_at: '2026-03-28 08:00:00',
last_login_ip: '127.0.0.1',
created_at: '2026-03-20 08:00:00',
updated_at: '2026-03-28 08:00:00',
}
}
function buildRoles(): Role[] {
return [
{
id: 1,
name: 'Admin',
code: 'admin',
description: 'system administrator',
is_system: true,
is_default: false,
status: 1,
},
{
id: 2,
name: 'Auditor',
code: 'auditor',
description: 'audit role',
is_system: false,
is_default: false,
status: 1,
},
]
}
describe('AssignRolesModal', () => {
beforeEach(() => {
assignUserRolesMock.mockReset()
messageSuccessMock.mockReset()
messageErrorMock.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('syncs current roles, reacts to changes, and submits assignments', async () => {
const user = userEvent.setup()
const onSuccess = vi.fn()
const allRoles = buildRoles()
assignUserRolesMock.mockResolvedValue(undefined)
const { rerender } = render(
<AssignRolesModal
open
user={buildUser()}
currentRoles={[allRoles[0]]}
allRoles={allRoles}
onSuccess={onSuccess}
onClose={vi.fn()}
/>,
)
expect(screen.getByText('target:1')).toBeInTheDocument()
expect(screen.getByText('Admin:true')).toBeInTheDocument()
expect(screen.getByText('Auditor:false')).toBeInTheDocument()
expect(screen.getByText('filter-title:true')).toBeInTheDocument()
expect(screen.getByText('filter-desc:true')).toBeInTheDocument()
rerender(
<AssignRolesModal
open
user={buildUser()}
currentRoles={[allRoles[1]]}
allRoles={allRoles}
onSuccess={onSuccess}
onClose={vi.fn()}
/>,
)
await waitFor(() => expect(screen.getByText('target:2')).toBeInTheDocument())
await user.click(screen.getByRole('button', { name: 'modal ok' }))
await waitFor(() => expect(assignUserRolesMock).toHaveBeenCalledWith(42, {
role_ids: [2],
}))
expect(messageSuccessMock).toHaveBeenCalledTimes(1)
expect(onSuccess).toHaveBeenCalledTimes(1)
})
it('resets state on cancel and returns early when there is no user', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
const allRoles = buildRoles()
const { rerender } = render(
<AssignRolesModal
open
user={buildUser()}
currentRoles={[allRoles[0]]}
allRoles={allRoles}
onSuccess={vi.fn()}
onClose={onClose}
/>,
)
await user.click(screen.getByRole('button', { name: 'transfer change' }))
expect(screen.getByText('target:2')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'modal cancel' }))
expect(screen.getByText('target:1')).toBeInTheDocument()
expect(onClose).toHaveBeenCalledTimes(1)
rerender(
<AssignRolesModal
open
user={null}
currentRoles={[allRoles[0]]}
allRoles={allRoles}
onSuccess={vi.fn()}
onClose={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'modal ok' }))
expect(assignUserRolesMock).not.toHaveBeenCalled()
})
it('surfaces assignment failures', async () => {
const user = userEvent.setup()
const allRoles = buildRoles()
assignUserRolesMock.mockRejectedValueOnce(new Error('assign failed'))
render(
<AssignRolesModal
open
user={buildUser()}
currentRoles={[allRoles[0]]}
allRoles={allRoles}
onSuccess={vi.fn()}
onClose={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'transfer change' }))
await user.click(screen.getByRole('button', { name: 'modal ok' }))
await waitFor(() => expect(messageErrorMock).toHaveBeenCalledWith('assign failed'))
})
})

View File

@@ -0,0 +1,107 @@
/**
* 分配角色弹窗
*/
import { useState, useEffect } from 'react'
import { Modal, Transfer, message, type TransferProps } from 'antd'
import { getErrorMessage } from '@/lib/errors'
import type { User, Role } from '@/types'
import { assignUserRoles } from '@/services/users'
interface AssignRolesModalProps {
open: boolean
user: User | null
currentRoles: Role[]
allRoles: Role[]
onSuccess: () => void
onClose: () => void
}
export function AssignRolesModal({
open,
user,
currentRoles,
allRoles,
onSuccess,
onClose,
}: AssignRolesModalProps) {
const [loading, setLoading] = useState(false)
const [targetKeys, setTargetKeys] = useState<string[]>(
currentRoles.map((r) => String(r.id))
)
// 弹窗打开时显示 loading关闭时重置
useEffect(() => {
if (open) {
setTargetKeys(currentRoles.map((r) => String(r.id)))
}
}, [open, currentRoles])
// 当 currentRoles 变化时更新 targetKeys
useEffect(() => {
setTargetKeys(currentRoles.map((r) => String(r.id)))
}, [currentRoles])
const handleChange: TransferProps['onChange'] = (newTargetKeys) => {
setTargetKeys(newTargetKeys as string[])
}
const handleSubmit = async () => {
if (!user) return
setLoading(true)
try {
await assignUserRoles(user.id, {
role_ids: targetKeys.map(Number),
})
message.success('角色分配成功')
onSuccess()
} catch (err) {
message.error(getErrorMessage(err, '角色分配失败'))
} finally {
setLoading(false)
}
}
const handleCancel = () => {
setTargetKeys(currentRoles.map((r) => String(r.id)))
onClose()
}
// Transfer 数据源
const dataSource = allRoles.map((role) => ({
key: String(role.id),
title: role.name,
description: role.description || '',
disabled: role.is_system, // 系统角色不可取消
}))
return (
<Modal
title={`分配角色 - ${user?.username || ''}`}
open={open}
onOk={handleSubmit}
onCancel={handleCancel}
confirmLoading={loading}
width={640}
destroyOnHidden
>
<p style={{ marginBottom: 16, color: 'var(--color-text-muted)' }}>
</p>
<Transfer
dataSource={dataSource}
titles={['可分配角色', '已分配角色']}
targetKeys={targetKeys}
onChange={handleChange}
render={(item) => item.title}
listStyle={{ width: 256, height: 320 }}
showSearch
filterOption={(input, item) =>
item.title.toLowerCase().includes(input.toLowerCase()) ||
item.description.toLowerCase().includes(input.toLowerCase())
}
/>
</Modal>
)
}

View File

@@ -0,0 +1,243 @@
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 { Role } from '@/types'
import { CreateUserModal } from './CreateUserModal'
const createUserMock = vi.fn()
const messageSuccessMock = vi.fn()
const messageErrorMock = vi.fn()
const formApi = {
validateFields: vi.fn(),
setFieldsValue: vi.fn(),
resetFields: vi.fn(),
setFieldValue: vi.fn(),
}
const watchValues = {
email: undefined as string | undefined,
send_activation_email: false,
}
vi.mock('antd', () => {
const Form = ({
children,
}: {
children?: ReactNode
}) => <form>{children}</form>
return {
Alert: ({
message,
}: {
message?: ReactNode
}) => <div>{message}</div>,
Button: ({
children,
onClick,
}: {
children?: ReactNode
onClick?: () => void
}) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
Checkbox: ({
children,
disabled,
}: {
children?: ReactNode
disabled?: boolean
}) => <label>{`${String(disabled ?? false)}:${children ?? ''}`}</label>,
Form: Object.assign(Form, {
useForm: () => [formApi],
useWatch: (name: string) => watchValues[name as keyof typeof watchValues],
Item: ({
label,
children,
}: {
label?: ReactNode
children?: ReactNode
}) => (
<div>
{label ? <span>{label}</span> : null}
{children}
</div>
),
}),
Input: Object.assign(({
placeholder,
}: {
placeholder?: string
}) => <input placeholder={placeholder} readOnly />, {
Password: ({ placeholder }: { placeholder?: string }) => (
<input placeholder={placeholder} readOnly type="password" />
),
}),
Modal: ({
open,
title,
onCancel,
footer,
children,
}: {
open: boolean
title?: ReactNode
onCancel?: () => void
footer?: ReactNode
children?: ReactNode
}) => (
open ? (
<div data-testid="create-user-modal-root">
<h1>{title}</h1>
{children}
{footer}
<button type="button" onClick={onCancel}>modal close</button>
</div>
) : null
),
Select: ({
placeholder,
options = [],
}: {
placeholder?: string
options?: Array<{ value: number | string, label: ReactNode }>
}) => (
<select aria-label={placeholder ?? 'select'}>
{options.map((option) => (
<option key={String(option.value)} value={String(option.value)}>
{option.label}
</option>
))}
</select>
),
Space: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
message: {
success: (content: string) => messageSuccessMock(content),
error: (content: string) => messageErrorMock(content),
},
}
})
vi.mock('@/services/users', () => ({
createUser: (payload: unknown) => createUserMock(payload),
}))
function buildRoles(): Role[] {
return [
{
id: 1,
name: 'Admin',
code: 'admin',
description: 'system admin',
is_system: true,
is_default: false,
status: 1,
},
]
}
describe('CreateUserModal behavior', () => {
beforeEach(() => {
createUserMock.mockReset()
messageSuccessMock.mockReset()
messageErrorMock.mockReset()
formApi.validateFields.mockReset()
formApi.setFieldsValue.mockReset()
formApi.resetFields.mockReset()
formApi.setFieldValue.mockReset()
watchValues.email = undefined
watchValues.send_activation_email = false
})
afterEach(() => {
vi.restoreAllMocks()
})
it('hydrates defaults on open, clears activation email without an email, and resets when closed', () => {
watchValues.send_activation_email = true
const { rerender } = render(
<CreateUserModal
open
roles={buildRoles()}
onSuccess={vi.fn()}
onClose={vi.fn()}
/>,
)
expect(formApi.setFieldsValue).toHaveBeenCalledWith({
status: 1,
role_ids: [],
send_activation_email: false,
})
expect(formApi.setFieldValue).toHaveBeenCalledWith('send_activation_email', false)
expect(screen.getByText('true:创建后发送激活邮件')).toBeInTheDocument()
rerender(
<CreateUserModal
open={false}
roles={buildRoles()}
onSuccess={vi.fn()}
onClose={vi.fn()}
/>,
)
expect(formApi.resetFields).toHaveBeenCalledTimes(1)
})
it('swallows validation errors, surfaces service failures, and resets on close', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
watchValues.email = 'user@example.com'
watchValues.send_activation_email = false
formApi.validateFields.mockRejectedValueOnce({ errorFields: [{ name: ['username'] }] })
const { rerender } = render(
<CreateUserModal
open
roles={buildRoles()}
onSuccess={vi.fn()}
onClose={onClose}
/>,
)
await user.click(screen.getByRole('button', { name: '创建用户' }))
await waitFor(() => expect(createUserMock).not.toHaveBeenCalled())
expect(messageErrorMock).not.toHaveBeenCalled()
formApi.validateFields.mockResolvedValueOnce({
username: 'broken-user',
password: 'Pass123!@#',
email: 'broken@example.com',
phone: '13800000011',
nickname: 'Broken',
role_ids: [],
status: 1,
send_activation_email: false,
})
createUserMock.mockRejectedValueOnce(new Error('create failed'))
rerender(
<CreateUserModal
open
roles={buildRoles()}
onSuccess={vi.fn()}
onClose={onClose}
/>,
)
await user.click(screen.getByRole('button', { name: '创建用户' }))
await waitFor(() => expect(messageErrorMock).toHaveBeenCalledWith('create failed'))
await user.click(screen.getByRole('button', { name: '取消' }))
expect(formApi.resetFields).toHaveBeenCalled()
expect(onClose).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,69 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import type { Role } from '@/types'
import { CreateUserModal } from './CreateUserModal'
const createUserMock = vi.fn()
const originalGetComputedStyle = window.getComputedStyle
vi.mock('@/services/users', () => ({
createUser: (payload: unknown) => createUserMock(payload),
}))
const roles: Role[] = [
{
id: 1,
name: '用户',
code: 'user',
description: 'default user role',
is_system: true,
is_default: true,
status: 1,
},
]
describe('CreateUserModal', () => {
beforeEach(() => {
createUserMock.mockReset()
createUserMock.mockResolvedValue(undefined)
vi.spyOn(window, 'getComputedStyle').mockImplementation((element) => {
return originalGetComputedStyle.call(window, element)
})
})
it('submits the create-user payload and enables activation email when selected', async () => {
const user = userEvent.setup()
const onSuccess = vi.fn()
render(
<CreateUserModal
open
roles={roles}
onSuccess={onSuccess}
onClose={vi.fn()}
/>,
)
await user.type(screen.getByPlaceholderText('请输入用户名'), 'created-user')
await user.type(screen.getByPlaceholderText('请输入初始密码'), 'Pass123!@#')
await user.type(screen.getByPlaceholderText('邮箱地址'), 'created-user@example.com')
await user.click(screen.getByRole('checkbox', { name: '创建后发送激活邮件' }))
await user.click(screen.getByRole('button', { name: '创建用户' }))
await waitFor(() =>
expect(createUserMock).toHaveBeenCalledWith({
username: 'created-user',
password: 'Pass123!@#',
email: 'created-user@example.com',
phone: undefined,
nickname: undefined,
role_ids: undefined,
status: 0,
send_activation_email: true,
}),
)
expect(onSuccess).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,174 @@
import { useEffect, useState } from 'react'
import { Alert, Button, Checkbox, Form, Input, Modal, Select, Space, message } from 'antd'
import { getErrorMessage, isFormValidationError } from '@/lib/errors'
import { createUser } from '@/services/users'
import type { CreateUserRequest, CreatableUserStatus, Role } from '@/types'
interface CreateUserModalProps {
open: boolean
roles: Role[]
onSuccess: () => void
onClose: () => void
}
type CreateUserFormValues = CreateUserRequest
const STATUS_OPTIONS: Array<{ value: CreatableUserStatus; label: string }> = [
{ value: 1, label: '已激活' },
{ value: 0, label: '未激活' },
{ value: 3, label: '已禁用' },
]
export function CreateUserModal({ open, roles, onSuccess, onClose }: CreateUserModalProps) {
const [form] = Form.useForm<CreateUserFormValues>()
const [loading, setLoading] = useState(false)
const email = Form.useWatch('email', form)
const sendActivationEmail = Form.useWatch('send_activation_email', form) ?? false
useEffect(() => {
if (open) {
form.setFieldsValue({
status: 1,
role_ids: [],
send_activation_email: false,
})
return
}
form.resetFields()
}, [open, form])
useEffect(() => {
if (!email && sendActivationEmail) {
form.setFieldValue('send_activation_email', false)
}
}, [email, form, sendActivationEmail])
const handleSubmit = async () => {
try {
const values = await form.validateFields()
setLoading(true)
await createUser({
username: values.username.trim(),
password: values.password,
email: values.email?.trim() || undefined,
phone: values.phone?.trim() || undefined,
nickname: values.nickname?.trim() || undefined,
role_ids: values.role_ids?.length ? values.role_ids : undefined,
status: values.send_activation_email ? 0 : values.status,
send_activation_email: Boolean(values.send_activation_email),
})
message.success(values.send_activation_email ? '用户已创建,激活邮件已发送' : '用户已创建')
onSuccess()
} catch (error) {
if (isFormValidationError(error)) {
return
}
message.error(getErrorMessage(error, '创建用户失败'))
} finally {
setLoading(false)
}
}
const handleClose = () => {
form.resetFields()
onClose()
}
return (
<Modal
title="创建用户"
open={open}
onCancel={handleClose}
destroyOnHidden
footer={
<Space>
<Button onClick={handleClose}></Button>
<Button type="primary" loading={loading} onClick={() => void handleSubmit()}>
</Button>
</Space>
}
>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="未选择初始角色时,系统会自动分配默认角色。"
/>
<Form<CreateUserFormValues> form={form} layout="vertical">
<Form.Item
name="username"
label="用户名"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input placeholder="请输入用户名" autoComplete="off" />
</Form.Item>
<Form.Item
name="password"
label="初始密码"
rules={[{ required: true, message: '请输入初始密码' }]}
>
<Input.Password placeholder="请输入初始密码" autoComplete="new-password" />
</Form.Item>
<Form.Item
name="email"
label="邮箱"
rules={[{ type: 'email', message: '请输入有效的邮箱地址' }]}
>
<Input placeholder="邮箱地址" autoComplete="off" />
</Form.Item>
<Form.Item
name="phone"
label="手机号"
rules={[{ pattern: /^1\d{10}$/, message: '请输入有效的手机号' }]}
>
<Input placeholder="手机号" autoComplete="off" />
</Form.Item>
<Form.Item name="nickname" label="昵称">
<Input placeholder="昵称,不填则默认使用用户名" autoComplete="off" />
</Form.Item>
<Form.Item
name="status"
label="初始状态"
extra={sendActivationEmail ? '发送激活邮件时,用户状态会自动设置为未激活。' : undefined}
>
<Select
options={STATUS_OPTIONS}
disabled={sendActivationEmail}
placeholder="请选择初始状态"
/>
</Form.Item>
<Form.Item
name="role_ids"
label="初始角色"
extra="可多选;留空时自动分配默认角色。"
>
<Select
mode="multiple"
allowClear
placeholder="请选择初始角色"
options={roles.map((role) => ({
value: role.id,
label: role.name,
}))}
/>
</Form.Item>
<Form.Item name="send_activation_email" valuePropName="checked">
<Checkbox disabled={!email}></Checkbox>
</Form.Item>
</Form>
</Modal>
)
}

View File

@@ -0,0 +1,238 @@
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 { Role } from '@/types/auth'
import type { User } from '@/types/user'
import { UserDetailDrawer } from './UserDetailDrawer'
const getUserMock = vi.fn()
const getUserRolesMock = vi.fn()
const consoleErrorMock = vi.fn()
vi.mock('antd', () => {
const Descriptions = ({
children,
}: {
children?: ReactNode
}) => <div>{children}</div>
return {
Avatar: ({
src,
children,
}: {
src?: string
children?: ReactNode
}) => <div>{src ? `avatar:${src}` : 'avatar:none'}{children}</div>,
Descriptions: Object.assign(Descriptions, {
Item: ({
label,
children,
}: {
label?: ReactNode
children?: ReactNode
}) => (
<div>
<span>{label}</span>
<span>{children}</span>
</div>
),
}),
Drawer: ({
open,
title,
onClose,
children,
}: {
open: boolean
title?: ReactNode
onClose?: () => void
children?: ReactNode
}) => (
open ? (
<div data-testid="user-detail-drawer-root">
<h1>{title}</h1>
{children}
<button type="button" onClick={onClose}>drawer close</button>
</div>
) : null
),
Spin: () => <div>loading</div>,
Tag: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
Typography: {
Title: ({ children }: { children?: ReactNode }) => <h2>{children}</h2>,
},
}
})
vi.mock('@ant-design/icons', () => ({
UserOutlined: () => <span>user-icon</span>,
}))
vi.mock('@/services/users', () => ({
getUser: (userId: number) => getUserMock(userId),
getUserRoles: (userId: number) => getUserRolesMock(userId),
}))
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return { promise, resolve }
}
function buildUser(overrides?: Partial<User>): User {
return {
id: 9,
username: 'bob',
email: 'bob@example.com',
phone: '13800000009',
nickname: 'Bob',
avatar: '',
gender: 1,
birthday: '2026-03-10',
region: 'Shanghai',
bio: 'hello',
status: 1,
last_login_at: '2026-03-28 09:30:00',
last_login_ip: '127.0.0.9',
created_at: '2026-03-01 08:00:00',
updated_at: '2026-03-28 08:30:00',
...overrides,
}
}
function buildRoles(): Role[] {
return [
{
id: 1,
name: 'Admin',
code: 'admin',
description: 'system admin',
is_system: true,
is_default: false,
status: 1,
},
{
id: 2,
name: 'Auditor',
code: 'auditor',
description: 'audit role',
is_system: false,
is_default: false,
status: 0,
},
]
}
describe('UserDetailDrawer', () => {
beforeEach(() => {
getUserMock.mockReset()
getUserRolesMock.mockReset()
consoleErrorMock.mockReset()
vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
consoleErrorMock(...args)
})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('shows loading, renders fetched details, and closes the drawer', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
const userRequest = deferred<User>()
const rolesRequest = deferred<Role[]>()
getUserMock.mockReturnValue(userRequest.promise)
getUserRolesMock.mockReturnValue(rolesRequest.promise)
render(
<UserDetailDrawer
open
userId={9}
onClose={onClose}
/>,
)
expect(await screen.findByText('loading')).toBeInTheDocument()
userRequest.resolve(buildUser())
rolesRequest.resolve(buildRoles())
expect(await screen.findByText('最后登录时间')).toBeInTheDocument()
expect(screen.getByText('Admin')).toBeInTheDocument()
expect(screen.getByText('Auditor')).toBeInTheDocument()
expect(screen.getByText('avatar:none')).toBeInTheDocument()
expect(screen.getByText('bob@example.com')).toBeInTheDocument()
expect(screen.getByText('13800000009')).toBeInTheDocument()
expect(screen.getByText('2026-03-28 09:30:00')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'drawer close' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('renders the empty fallback when no user id is provided', () => {
render(
<UserDetailDrawer
open
onClose={vi.fn()}
/>,
)
expect(getUserMock).not.toHaveBeenCalled()
expect(getUserRolesMock).not.toHaveBeenCalled()
expect(screen.getByText('用户信息不存在')).toBeInTheDocument()
})
it('falls back after request failures', async () => {
getUserMock.mockRejectedValueOnce(new Error('load detail failed'))
getUserRolesMock.mockResolvedValueOnce([])
render(
<UserDetailDrawer
open
userId={10}
onClose={vi.fn()}
/>,
)
await waitFor(() => expect(consoleErrorMock).toHaveBeenCalled())
expect(await screen.findByText('用户信息不存在')).toBeInTheDocument()
})
it('renders placeholders for missing optional fields and empty roles', async () => {
getUserMock.mockResolvedValueOnce(buildUser({
nickname: '',
email: '',
phone: '',
avatar: 'https://cdn.example.com/avatar.png',
gender: 0,
birthday: '',
region: '',
bio: '',
last_login_at: '',
last_login_ip: '',
}))
getUserRolesMock.mockResolvedValueOnce([])
render(
<UserDetailDrawer
open
userId={11}
onClose={vi.fn()}
/>,
)
expect(await screen.findByText('avatar:https://cdn.example.com/avatar.png')).toBeInTheDocument()
expect(screen.getByText('avatar:https://cdn.example.com/avatar.png')).toBeInTheDocument()
expect(screen.getAllByText('-').length).toBeGreaterThan(0)
expect(screen.getByText('未知')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,130 @@
/**
* 用户详情抽屉
*/
import { useState, useEffect } from 'react'
import {
Drawer,
Descriptions,
Avatar,
Tag,
Spin,
Typography,
} from 'antd'
import { UserOutlined } from '@ant-design/icons'
import dayjs from 'dayjs'
import { getUser, getUserRoles } from '@/services/users'
import type { User, UserStatus } from '@/types/user'
import type { Role } from '@/types/auth'
import { UserStatusText, UserStatusColor } from '@/types/user'
const { Title } = Typography
interface UserDetailDrawerProps {
open: boolean
userId?: number
onClose: () => void
}
export function UserDetailDrawer({ open, userId, onClose }: UserDetailDrawerProps) {
const [loading, setLoading] = useState(false)
const [user, setUser] = useState<User | null>(null)
const [roles, setRoles] = useState<Role[]>([])
useEffect(() => {
if (open && userId) {
const fetchData = async () => {
setLoading(true)
try {
const [userData, roleData] = await Promise.all([
getUser(userId),
getUserRoles(userId),
])
setUser(userData)
setRoles(roleData)
} catch {
// 获取用户详情失败,忽略
} finally {
setLoading(false)
}
}
fetchData()
}
}, [open, userId])
return (
<Drawer
title="用户详情"
placement="right"
width={520}
open={open}
onClose={onClose}
>
{loading ? (
<div style={{ textAlign: 'center', padding: 48 }}>
<Spin size="large" />
</div>
) : user ? (
<>
{/* 头像和基本信息 */}
<div style={{ textAlign: 'center', marginBottom: 24 }}>
<Avatar
size={80}
icon={<UserOutlined />}
src={user.avatar}
style={{ backgroundColor: user.avatar ? undefined : 'var(--color-primary)', marginBottom: 12 }}
/>
<Title level={5} style={{ margin: 0 }}>
{user.nickname || user.username}
</Title>
<Tag color={UserStatusColor[user.status as UserStatus]} style={{ marginTop: 8 }}>
{UserStatusText[user.status as UserStatus]}
</Tag>
</div>
{/* 详细信息 */}
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="用户 ID">{user.id}</Descriptions.Item>
<Descriptions.Item label="用户名">{user.username}</Descriptions.Item>
<Descriptions.Item label="邮箱">{user.email || '-'}</Descriptions.Item>
<Descriptions.Item label="手机号">{user.phone || '-'}</Descriptions.Item>
<Descriptions.Item label="昵称">{user.nickname || '-'}</Descriptions.Item>
<Descriptions.Item label="性别">
{user.gender === 1 ? '男' : user.gender === 2 ? '女' : '未知'}
</Descriptions.Item>
<Descriptions.Item label="生日">{user.birthday || '-'}</Descriptions.Item>
<Descriptions.Item label="地区">{user.region || '-'}</Descriptions.Item>
<Descriptions.Item label="简介">{user.bio || '-'}</Descriptions.Item>
<Descriptions.Item label="角色">
{roles.length > 0
? roles.map((r) => (
<Tag key={r.id} color={r.status === 1 ? 'blue' : 'default'}>
{r.name}
</Tag>
))
: '-'}
</Descriptions.Item>
<Descriptions.Item label="最后登录时间">
{user.last_login_at
? dayjs(user.last_login_at).format('YYYY-MM-DD HH:mm:ss')
: '-'}
</Descriptions.Item>
<Descriptions.Item label="最后登录 IP">
{user.last_login_ip || '-'}
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{dayjs(user.created_at).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
<Descriptions.Item label="更新时间">
{dayjs(user.updated_at).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
</Descriptions>
</>
) : (
<div style={{ textAlign: 'center', padding: 48, color: 'var(--color-text-muted)' }}>
</div>
)}
</Drawer>
)
}

View File

@@ -0,0 +1,288 @@
import type { ReactNode } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import dayjs from 'dayjs'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { User } from '@/types/user'
import { UserEditDrawer } from './UserEditDrawer'
const updateUserMock = vi.fn()
const messageSuccessMock = vi.fn()
const messageErrorMock = vi.fn()
const formApi = {
validateFields: vi.fn(),
setFieldsValue: vi.fn(),
resetFields: vi.fn(),
}
vi.mock('antd', () => {
const Form = ({
children,
}: {
children?: ReactNode
}) => <form>{children}</form>
return {
Button: ({
children,
onClick,
}: {
children?: ReactNode
onClick?: () => void
}) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
DatePicker: ({
placeholder,
disabledDate,
}: {
placeholder?: string
disabledDate?: (value: ReturnType<typeof dayjs> | null) => boolean
}) => (
<div>
<input placeholder={placeholder} readOnly />
<span>{`disabled-future:${String(disabledDate?.(dayjs('2999-01-01')))}`}</span>
<span>{`disabled-empty:${String(Boolean(disabledDate?.(null)))}`}</span>
</div>
),
Drawer: ({
open,
title,
onClose,
footer,
children,
}: {
open: boolean
title?: ReactNode
onClose?: () => void
footer?: ReactNode
children?: ReactNode
}) => (
open ? (
<div data-testid="user-edit-drawer-root">
<h1>{title}</h1>
{children}
{footer}
<button type="button" onClick={onClose}>drawer close</button>
</div>
) : null
),
Form: Object.assign(Form, {
useForm: () => [formApi],
Item: ({
label,
children,
}: {
label?: ReactNode
children?: ReactNode
}) => (
<div>
{label ? <span>{label}</span> : null}
{children}
</div>
),
}),
Input: Object.assign(({
placeholder,
value,
disabled,
}: {
placeholder?: string
value?: string
disabled?: boolean
}) => (
<input
placeholder={placeholder}
value={value ?? ''}
disabled={disabled}
readOnly
/>
), {
TextArea: ({ placeholder }: { placeholder?: string }) => (
<textarea placeholder={placeholder} readOnly />
),
}),
Select: ({
placeholder,
options = [],
}: {
placeholder?: string
options?: Array<{ value: number | string, label: ReactNode }>
}) => (
<select aria-label={placeholder ?? 'select'}>
{options.map((option) => (
<option key={String(option.value)} value={String(option.value)}>
{option.label}
</option>
))}
</select>
),
Space: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
message: {
success: (content: string) => messageSuccessMock(content),
error: (content: string) => messageErrorMock(content),
},
}
})
vi.mock('@/services/users', () => ({
updateUser: (userId: number, payload: unknown) => updateUserMock(userId, payload),
}))
function buildUser(): User {
return {
id: 7,
username: 'alice',
email: 'alice@example.com',
phone: '13800000007',
nickname: 'Alice',
avatar: '',
gender: 2,
birthday: '2026-03-12',
region: 'Shanghai',
bio: 'hello world',
status: 1,
last_login_at: '2026-03-27 10:00:00',
last_login_ip: '127.0.0.1',
created_at: '2026-03-01 10:00:00',
updated_at: '2026-03-20 10:00:00',
}
}
describe('UserEditDrawer', () => {
beforeEach(() => {
updateUserMock.mockReset()
messageSuccessMock.mockReset()
messageErrorMock.mockReset()
formApi.validateFields.mockReset()
formApi.setFieldsValue.mockReset()
formApi.resetFields.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('prefills the form and submits normalized values', async () => {
const user = userEvent.setup()
const record = buildUser()
const onSuccess = vi.fn()
formApi.validateFields.mockResolvedValue({
email: 'updated@example.com',
phone: '13800000008',
nickname: 'Alice Updated',
gender: 1,
birthday: dayjs('2026-03-15'),
region: 'Beijing',
bio: 'updated bio',
})
updateUserMock.mockResolvedValue(undefined)
render(
<UserEditDrawer
open
user={record}
onSuccess={onSuccess}
onClose={vi.fn()}
/>,
)
expect(formApi.setFieldsValue).toHaveBeenCalledWith({
email: 'alice@example.com',
phone: '13800000007',
nickname: 'Alice',
gender: 2,
birthday: expect.any(Object),
region: 'Shanghai',
bio: 'hello world',
})
const hydratedBirthday = formApi.setFieldsValue.mock.calls[0][0].birthday
expect(dayjs.isDayjs(hydratedBirthday)).toBe(true)
expect(hydratedBirthday.format('YYYY-MM-DD')).toBe('2026-03-12')
expect(screen.getByText('disabled-future:true')).toBeInTheDocument()
expect(screen.getByText('disabled-empty:false')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(updateUserMock).toHaveBeenCalledWith(7, {
email: 'updated@example.com',
phone: '13800000008',
nickname: 'Alice Updated',
gender: 1,
birthday: '2026-03-15',
region: 'Beijing',
bio: 'updated bio',
}))
expect(messageSuccessMock).toHaveBeenCalledTimes(1)
expect(onSuccess).toHaveBeenCalledTimes(1)
})
it('swallows validation errors and returns early when user data is missing', async () => {
const user = userEvent.setup()
formApi.validateFields.mockRejectedValueOnce({ errorFields: [{ name: ['email'] }] })
const { rerender } = render(
<UserEditDrawer
open
user={buildUser()}
onSuccess={vi.fn()}
onClose={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(updateUserMock).not.toHaveBeenCalled())
expect(messageErrorMock).not.toHaveBeenCalled()
rerender(
<UserEditDrawer
open
user={null}
onSuccess={vi.fn()}
onClose={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: '保存' }))
expect(formApi.validateFields).toHaveBeenCalledTimes(1)
})
it('surfaces service errors and resets state on close', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
formApi.validateFields.mockResolvedValue({
email: 'broken@example.com',
phone: '13800000009',
nickname: 'Broken',
gender: 0,
birthday: undefined,
region: 'Hangzhou',
bio: 'broken bio',
})
updateUserMock.mockRejectedValueOnce(new Error('update failed'))
render(
<UserEditDrawer
open
user={buildUser()}
onSuccess={vi.fn()}
onClose={onClose}
/>,
)
await user.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(messageErrorMock).toHaveBeenCalledWith('update failed'))
await user.click(screen.getByRole('button', { name: '取消' }))
expect(formApi.resetFields).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,152 @@
/**
* 用户编辑抽屉
*/
import { useState, useEffect } from 'react'
import {
Drawer,
Form,
Input,
Select,
Button,
message,
Space,
DatePicker,
} from 'antd'
import dayjs from 'dayjs'
import { getErrorMessage, isFormValidationError } from '@/lib/errors'
import type { User } from '@/types/user'
import { updateUser } from '@/services/users'
interface UserEditDrawerProps {
open: boolean
user: User | null
onSuccess: () => void
onClose: () => void
}
export function UserEditDrawer({ open, user, onSuccess, onClose }: UserEditDrawerProps) {
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
// 初始化表单
useEffect(() => {
if (open && user) {
form.setFieldsValue({
email: user.email,
phone: user.phone,
nickname: user.nickname,
gender: user.gender,
birthday: user.birthday ? dayjs(user.birthday) : null,
region: user.region,
bio: user.bio,
})
}
}, [open, user, form])
// 提交表单
const handleSubmit = async () => {
if (!user) return
try {
const values = await form.validateFields()
setLoading(true)
await updateUser(user.id, {
...values,
// DatePicker 返回 dayjs 对象,转换为 YYYY-MM-DD 字符串
birthday: values.birthday ? (values.birthday as ReturnType<typeof dayjs>).format('YYYY-MM-DD') : undefined,
})
message.success('用户信息已更新')
onSuccess()
} catch (err) {
if (isFormValidationError(err)) {
return
}
message.error(getErrorMessage(err, '更新失败'))
} finally {
setLoading(false)
}
}
// 关闭时重置
const handleClose = () => {
form.resetFields()
onClose()
}
return (
<Drawer
title="编辑用户"
placement="right"
width={480}
open={open}
onClose={handleClose}
footer={
<Space>
<Button onClick={handleClose}></Button>
<Button type="primary" loading={loading} onClick={handleSubmit}>
</Button>
</Space>
}
>
<Form form={form} layout="vertical">
<Form.Item label="用户名">
<Input value={user?.username} disabled />
</Form.Item>
<Form.Item
name="email"
label="邮箱"
rules={[
{ type: 'email', message: '请输入有效的邮箱地址' },
]}
>
<Input placeholder="邮箱地址" />
</Form.Item>
<Form.Item
name="phone"
label="手机号"
rules={[
{ pattern: /^1\d{10}$/, message: '请输入有效的手机号' },
]}
>
<Input placeholder="手机号" />
</Form.Item>
<Form.Item name="nickname" label="昵称">
<Input placeholder="昵称" />
</Form.Item>
<Form.Item name="gender" label="性别">
<Select
placeholder="选择性别"
allowClear
options={[
{ value: 0, label: '未知' },
{ value: 1, label: '男' },
{ value: 2, label: '女' },
]}
/>
</Form.Item>
<Form.Item name="birthday" label="生日">
<DatePicker
style={{ width: '100%' }}
placeholder="请选择生日"
disabledDate={(current) => current && current > dayjs().endOf('day')}
/>
</Form.Item>
<Form.Item name="region" label="地区">
<Input placeholder="地区" />
</Form.Item>
<Form.Item name="bio" label="简介">
<Input.TextArea rows={3} placeholder="个人简介" />
</Form.Item>
</Form>
</Drawer>
)
}

View File

@@ -0,0 +1,21 @@
/**
* UsersPage 样式
*/
.filterCard {
margin-bottom: 16px;
border-radius: var(--radius-md);
}
.tableCard {
border-radius: var(--radius-md);
}
.tableCard :global(.ant-table-thead > tr > th) {
background: var(--color-surface-muted);
font-weight: 500;
}
.tableCard :global(.ant-table-tbody > tr:hover > td) {
background: var(--color-surface-muted);
}

View File

@@ -0,0 +1,504 @@
import type { ReactElement, ReactNode } from 'react'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { message } from 'antd'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Role } from '@/types'
import type { PaginatedData } from '@/types/http'
import type { User, UserListParams, UserStatus } from '@/types/user'
import { UsersPage } from './UsersPage'
const originalGetComputedStyle = window.getComputedStyle
const useAuthMock = vi.fn()
const listUsersMock = vi.fn<(params: UserListParams) => Promise<PaginatedData<User>>>()
const deleteUserMock = vi.fn<(id: number) => Promise<void>>()
const updateUserStatusMock = vi.fn<(id: number, payload: { status: UserStatus }) => Promise<void>>()
const getUserRolesMock = vi.fn<(id: number) => Promise<Role[]>>()
const listRolesMock = vi.fn<() => Promise<PaginatedData<Role>>>()
vi.mock('antd', async () => {
const actual = await vi.importActual<typeof import('antd')>('antd')
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)
}
return {
...actual,
Popconfirm: ({
children,
title,
onConfirm,
}: {
children: ReactElement
title?: ReactNode
onConfirm?: () => void
}) => (
<div>
{children}
<button type="button" onClick={() => onConfirm?.()}>
{typeof title === 'string' ? title : 'confirm action'}
</button>
</div>
),
Table: ({
columns,
dataSource,
rowKey,
locale,
}: {
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 }
}) => {
const rows = dataSource ?? []
if (rows.length === 0) {
return <div>{locale?.emptyText ?? null}</div>
}
return (
<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>
)
},
}
})
vi.mock('@/app/providers/auth-context', () => ({
useAuth: () => useAuthMock(),
}))
vi.mock('@/services/users', () => ({
listUsers: (params: UserListParams) => listUsersMock(params),
deleteUser: (id: number) => deleteUserMock(id),
updateUserStatus: (id: number, payload: { status: UserStatus }) => updateUserStatusMock(id, payload),
getUserRoles: (id: number) => getUserRolesMock(id),
}))
vi.mock('@/services/roles', () => ({
listRoles: () => listRolesMock(),
}))
vi.mock('./UserDetailDrawer', () => ({
UserDetailDrawer: ({
open,
userId,
onClose,
}: {
open: boolean
userId?: number
onClose: () => void
}) => (
open ? (
<div data-testid="user-detail-drawer">
<span>{`detail:${userId}`}</span>
<button type="button" onClick={onClose}>
detail close
</button>
</div>
) : null
),
}))
vi.mock('./UserEditDrawer', () => ({
UserEditDrawer: ({
open,
user,
onSuccess,
onClose,
}: {
open: boolean
user: User | null
onSuccess: () => void
onClose: () => void
}) => (
open ? (
<div data-testid="user-edit-drawer">
<span>{`edit:${user?.username ?? 'none'}`}</span>
<button type="button" onClick={onSuccess}>
edit success
</button>
<button type="button" onClick={onClose}>
edit close
</button>
</div>
) : null
),
}))
vi.mock('./AssignRolesModal', () => ({
AssignRolesModal: ({
open,
user,
currentRoles,
allRoles,
onSuccess,
onClose,
}: {
open: boolean
user: User | null
currentRoles: Role[]
allRoles: Role[]
onSuccess: () => void
onClose: () => void
}) => (
open ? (
<div data-testid="assign-roles-modal">
<span>{`assign:${user?.username ?? 'none'}:${currentRoles.length}:${allRoles.length}`}</span>
<button type="button" onClick={onSuccess}>
assign success
</button>
<button type="button" onClick={onClose}>
assign close
</button>
</div>
) : null
),
}))
vi.mock('./CreateUserModal', () => ({
CreateUserModal: ({
open,
roles,
onSuccess,
onClose,
}: {
open: boolean
roles: Role[]
onSuccess: () => void
onClose: () => void
}) => (
open ? (
<div data-testid="create-user-modal">
<span>{`create:${roles.length}`}</span>
<button type="button" onClick={onSuccess}>
create success
</button>
<button type="button" onClick={onClose}>
create close
</button>
</div>
) : null
),
}))
const roles: Role[] = [
{
id: 10,
name: '管理员',
code: 'admin',
description: 'system administrator',
is_system: true,
is_default: false,
status: 1,
},
{
id: 20,
name: '审计员',
code: 'auditor',
description: 'audit operator',
is_system: false,
is_default: false,
status: 1,
},
]
function buildUser(id: number, username: string, status: UserStatus): User {
return {
id,
username,
email: `${username}@example.com`,
phone: `13800000${String(id).padStart(3, '0')}`,
nickname: `${username}-nick`,
avatar: '',
gender: 0,
birthday: '',
region: '',
bio: '',
status,
last_login_at: '2026-03-27 12:30:00',
last_login_ip: '127.0.0.1',
created_at: `2026-03-2${id} 10:00:00`,
updated_at: `2026-03-2${id} 11:00:00`,
}
}
function cloneUser(user: User): User {
return { ...user }
}
describe('UsersPage', () => {
let currentUsers: User[]
beforeEach(() => {
currentUsers = [
buildUser(1, 'admin-root', 1),
buildUser(2, 'disabled-user', 3),
buildUser(3, 'locked-user', 2),
buildUser(4, 'pending-user', 0),
buildUser(5, 'editor-user', 1),
]
useAuthMock.mockReturnValue({
user: {
id: 1,
username: 'admin-root',
email: 'admin-root@example.com',
phone: '13800000001',
nickname: 'admin-root',
avatar: '',
status: 1,
},
roles: [],
isAdmin: true,
isAuthenticated: true,
isLoading: false,
onLoginSuccess: vi.fn(),
logout: vi.fn(),
refreshUser: vi.fn(),
})
listUsersMock.mockReset()
deleteUserMock.mockReset()
updateUserStatusMock.mockReset()
getUserRolesMock.mockReset()
listRolesMock.mockReset()
listUsersMock.mockImplementation(async (params: UserListParams) => {
const keyword = (params.keyword || '').toLowerCase()
const items = currentUsers.filter((user) => {
if (!keyword) {
return true
}
return [
user.username,
user.email,
user.phone,
user.nickname,
].some((value) => value.toLowerCase().includes(keyword))
})
return {
items: items.map(cloneUser),
total: items.length,
page: params.page ?? 1,
page_size: params.page_size ?? 20,
}
})
deleteUserMock.mockImplementation(async (id: number) => {
currentUsers = currentUsers.filter((user) => user.id !== id)
})
updateUserStatusMock.mockImplementation(async (id: number, payload: { status: UserStatus }) => {
currentUsers = currentUsers.map((user) => (
user.id === id ? { ...user, status: payload.status } : user
))
})
getUserRolesMock.mockImplementation(async (id: number) => (
id === 5 ? [roles[0], roles[1]] : [roles[1]]
))
listRolesMock.mockResolvedValue({
items: roles,
total: roles.length,
page: 1,
page_size: 100,
})
vi.spyOn(window, 'getComputedStyle').mockImplementation((element) => (
originalGetComputedStyle.call(window, element)
))
vi.spyOn(message, 'success').mockImplementation(() => undefined as never)
vi.spyOn(message, 'error').mockImplementation(() => undefined as never)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('loads, filters, resets, and refreshes the user list', async () => {
const user = userEvent.setup()
render(<UsersPage />)
expect(await screen.findByText('admin-root')).toBeInTheDocument()
expect(screen.getByText('disabled-user')).toBeInTheDocument()
expect(listRolesMock).toHaveBeenCalledTimes(1)
await user.type(screen.getByPlaceholderText('用户名/邮箱/手机号'), 'editor')
await waitFor(() => expect(screen.queryByText('admin-root')).not.toBeInTheDocument())
expect(screen.getByText('editor-user')).toBeInTheDocument()
expect(listUsersMock).toHaveBeenLastCalledWith(expect.objectContaining({ keyword: 'editor' }))
await user.click(screen.getByRole('button', { name: /重\s*置$/ }))
await waitFor(() => expect(screen.getByText('admin-root')).toBeInTheDocument())
expect(screen.getByText('disabled-user')).toBeInTheDocument()
expect(listUsersMock).toHaveBeenLastCalledWith(expect.objectContaining({ keyword: undefined }))
const callCountBeforeRefresh = listUsersMock.mock.calls.length
await user.click(screen.getByRole('button', { name: /刷新$/ }))
await waitFor(() => expect(listUsersMock.mock.calls.length).toBeGreaterThan(callCountBeforeRefresh))
})
it('opens detail, edit, create, and assign-role overlays and refetches after success', async () => {
const user = userEvent.setup()
render(<UsersPage />)
expect(await screen.findByText('editor-user')).toBeInTheDocument()
const callCountBeforeCreate = listUsersMock.mock.calls.length
await user.click(screen.getByRole('button', { name: /创建用户$/ }))
expect(screen.getByTestId('create-user-modal')).toHaveTextContent('create:2')
await user.click(screen.getByRole('button', { name: 'create success' }))
await waitFor(() => expect(listUsersMock.mock.calls.length).toBeGreaterThan(callCountBeforeCreate))
await waitFor(() => expect(screen.queryByTestId('create-user-modal')).not.toBeInTheDocument())
const editorRow = screen.getByTestId('table-row-5')
await user.click(within(editorRow).getByRole('button', { name: /详情$/ }))
expect(screen.getByTestId('user-detail-drawer')).toHaveTextContent('detail:5')
await user.click(screen.getByRole('button', { name: 'detail close' }))
await waitFor(() => expect(screen.queryByTestId('user-detail-drawer')).not.toBeInTheDocument())
const callCountBeforeEdit = listUsersMock.mock.calls.length
await user.click(within(editorRow).getByRole('button', { name: /编辑$/ }))
expect(screen.getByTestId('user-edit-drawer')).toHaveTextContent('edit:editor-user')
await user.click(screen.getByRole('button', { name: 'edit success' }))
await waitFor(() => expect(listUsersMock.mock.calls.length).toBeGreaterThan(callCountBeforeEdit))
await waitFor(() => expect(screen.queryByTestId('user-edit-drawer')).not.toBeInTheDocument())
const callCountBeforeAssign = listUsersMock.mock.calls.length
await user.click(within(editorRow).getByRole('button', { name: /角色$/ }))
await waitFor(() => expect(getUserRolesMock).toHaveBeenCalledWith(5))
expect(screen.getByTestId('assign-roles-modal')).toHaveTextContent('assign:editor-user:2:2')
await user.click(screen.getByRole('button', { name: 'assign success' }))
await waitFor(() => expect(listUsersMock.mock.calls.length).toBeGreaterThan(callCountBeforeAssign))
await waitFor(() => expect(screen.queryByTestId('assign-roles-modal')).not.toBeInTheDocument())
})
it('blocks self deletion, updates each status branch, and deletes other users', async () => {
const user = userEvent.setup()
render(<UsersPage />)
expect(await screen.findByText('admin-root')).toBeInTheDocument()
const selfRow = screen.getByTestId('table-row-1')
await user.click(within(selfRow).getByRole('button', { name: '确定要删除用户「admin-root」吗此操作不可恢复。' }))
await waitFor(() => expect(message.error).toHaveBeenCalledWith('不能删除当前登录的账号'))
expect(deleteUserMock).not.toHaveBeenCalled()
await user.click(within(screen.getByTestId('table-row-5')).getByRole('button', { name: '确定要禁用该用户吗?' }))
await waitFor(() => expect(updateUserStatusMock).toHaveBeenCalledWith(5, { status: 3 }))
await waitFor(() => expect(within(screen.getByTestId('table-row-5')).getByRole('button', { name: '激活' })).toBeInTheDocument())
await user.click(within(screen.getByTestId('table-row-2')).getByRole('button', { name: '确定要激活该用户吗?' }))
await waitFor(() => expect(updateUserStatusMock).toHaveBeenCalledWith(2, { status: 1 }))
await waitFor(() => expect(within(screen.getByTestId('table-row-2')).getByRole('button', { name: '禁用' })).toBeInTheDocument())
await user.click(within(screen.getByTestId('table-row-3')).getByRole('button', { name: '该用户因多次失败已被锁定,确定要解锁并激活吗?' }))
await waitFor(() => expect(updateUserStatusMock).toHaveBeenCalledWith(3, { status: 1 }))
await user.click(within(screen.getByTestId('table-row-4')).getByRole('button', { name: '该用户尚未激活,确定要激活该用户吗?' }))
await waitFor(() => expect(updateUserStatusMock).toHaveBeenCalledWith(4, { status: 1 }))
await user.click(within(screen.getByTestId('table-row-2')).getByRole('button', { name: '确定要删除用户「disabled-user」吗此操作不可恢复。' }))
await waitFor(() => expect(deleteUserMock).toHaveBeenCalledWith(2))
await waitFor(() => expect(screen.queryByTestId('table-row-2')).not.toBeInTheDocument())
expect(message.success).toHaveBeenCalledWith('用户 disabled-user 已删除')
})
it('surfaces role-loading failures without opening the modal', async () => {
const user = userEvent.setup()
getUserRolesMock.mockRejectedValueOnce(new Error('获取用户角色失败'))
render(<UsersPage />)
expect(await screen.findByText('editor-user')).toBeInTheDocument()
await user.click(within(screen.getByTestId('table-row-5')).getByRole('button', { name: /角色$/ }))
await waitFor(() => expect(message.error).toHaveBeenCalledWith('获取用户角色失败'))
expect(screen.queryByTestId('assign-roles-modal')).not.toBeInTheDocument()
})
it('renders the page error state and retries fetching', async () => {
const user = userEvent.setup()
listUsersMock.mockReset()
listUsersMock.mockRejectedValueOnce(new Error('用户列表拉取失败'))
listUsersMock.mockImplementation(async (params: UserListParams) => ({
items: currentUsers.map(cloneUser),
total: currentUsers.length,
page: params.page ?? 1,
page_size: params.page_size ?? 20,
}))
render(<UsersPage />)
expect(await screen.findByText('用户列表拉取失败')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /重新加载$/ }))
await waitFor(() => expect(screen.getByText('admin-root')).toBeInTheDocument())
expect(listUsersMock).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,518 @@
/**
* 用户管理页
*
* 功能:
* - 用户创建、列表、筛选、详情、编辑、状态切换、删除、角色分配
* - 不包含:批量操作、上传头像、管理员重置密码
*/
import { useState, useEffect, useCallback } from 'react'
import {
Table,
Button,
Space,
Tag,
Input,
Select,
DatePicker,
Popconfirm,
message,
type TableColumnsType,
type TablePaginationConfig,
} from 'antd'
import {
SearchOutlined,
ReloadOutlined,
PlusOutlined,
EyeOutlined,
EditOutlined,
DeleteOutlined,
TeamOutlined,
} from '@ant-design/icons'
import dayjs from 'dayjs'
import { PageHeader } from '@/components/common'
import { PageEmpty, PageError } from '@/components/feedback'
import { PageLayout, FilterCard, TableCard } from '@/components/layout'
import { getErrorMessage } from '@/lib/errors'
import { useAuth } from '@/app/providers/auth-context'
import {
listUsers,
deleteUser,
updateUserStatus,
getUserRoles,
} from '@/services/users'
import { listRoles } from '@/services/roles'
import type { User, UserListParams, UserStatus } from '@/types/user'
import type { Role } from '@/types/auth'
import { UserStatusText, UserStatusColor } from '@/types/user'
import { UserDetailDrawer } from './UserDetailDrawer'
import { UserEditDrawer } from './UserEditDrawer'
import { AssignRolesModal } from './AssignRolesModal'
import { CreateUserModal } from './CreateUserModal'
const { RangePicker } = DatePicker
export function UsersPage() {
// 当前登录用户(用于防止删除自己)
const { user: currentUser } = useAuth()
// 列表数据
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [users, setUsers] = useState<User[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
// 筛选条件
const [keyword, setKeyword] = useState('')
const [statusFilter, setStatusFilter] = useState<UserStatus | undefined>()
const [createdFrom, setCreatedFrom] = useState<string | undefined>()
const [createdTo, setCreatedTo] = useState<string | undefined>()
const [sortBy, setSortBy] = useState<string | undefined>()
const [sortOrder, setSortOrder] = useState<'asc' | 'desc' | undefined>()
// 角色列表(用于筛选和分配)
const [roles, setRoles] = useState<Role[]>([])
const [roleFilter, setRoleFilter] = useState<number | undefined>()
// 抽屉/弹窗
const [detailVisible, setDetailVisible] = useState(false)
const [createVisible, setCreateVisible] = useState(false)
const [editVisible, setEditVisible] = useState(false)
const [assignRolesVisible, setAssignRolesVisible] = useState(false)
const [selectedUser, setSelectedUser] = useState<User | null>(null)
const [selectedUserRoles, setSelectedUserRoles] = useState<Role[]>([])
// 加载角色列表
useEffect(() => {
const fetchRoles = async () => {
try {
const roleList = await listRoles({ page: 1, page_size: 100 })
setRoles(roleList.items)
} catch {
// 获取角色列表失败,忽略
}
}
fetchRoles()
}, [])
// 筛选条件变化时重置到第一页
useEffect(() => {
setPage(1)
}, [keyword, statusFilter, roleFilter, createdFrom, createdTo, sortBy, sortOrder])
// 加载用户列表
const fetchUsers = useCallback(async () => {
setLoading(true)
setError(null)
try {
const params: UserListParams = {
page,
page_size: pageSize,
keyword: keyword || undefined,
status: statusFilter,
role_ids: roleFilter ? String(roleFilter) : undefined,
created_from: createdFrom,
created_to: createdTo,
sort_by: sortBy,
sort_order: sortOrder,
}
const result = await listUsers(params)
setUsers(result.items)
setTotal(result.total)
} catch (err) {
setError(getErrorMessage(err, '获取用户列表失败'))
} finally {
setLoading(false)
}
}, [page, pageSize, keyword, statusFilter, roleFilter, createdFrom, createdTo, sortBy, sortOrder])
useEffect(() => {
fetchUsers()
}, [fetchUsers])
// 重置筛选
const handleReset = () => {
setKeyword('')
setStatusFilter(undefined)
setRoleFilter(undefined)
setCreatedFrom(undefined)
setCreatedTo(undefined)
setSortBy(undefined)
setSortOrder(undefined)
setPage(1)
}
// 查看详情
const handleViewDetail = async (user: User) => {
setSelectedUser(user)
setDetailVisible(true)
}
// 编辑用户
const handleEdit = async (user: User) => {
setSelectedUser(user)
setEditVisible(true)
}
// 删除用户
const handleDelete = async (user: User) => {
// 防止删除自己
if (currentUser && user.id === currentUser.id) {
message.error('不能删除当前登录的账号')
return
}
try {
await deleteUser(user.id)
message.success(`用户 ${user.username} 已删除`)
fetchUsers()
} catch (err) {
message.error(getErrorMessage(err, '删除失败'))
}
}
// 切换状态
const handleToggleStatus = async (user: User) => {
// 状态转换逻辑:
// - 1已激活-> 3禁用
// - 0未激活-> 1激活
// - 2已锁定-> 1解锁并激活
// - 3已禁用-> 1激活
const newStatus: UserStatus = user.status === 1 ? 3 : 1
try {
await updateUserStatus(user.id, { status: newStatus })
message.success('状态已更新')
fetchUsers()
} catch (err) {
message.error(getErrorMessage(err, '状态更新失败'))
}
}
// 分配角色
const handleAssignRoles = async (user: User) => {
setSelectedUser(user)
try {
const userRoles = await getUserRoles(user.id)
setSelectedUserRoles(userRoles)
setAssignRolesVisible(true)
} catch (err) {
message.error(getErrorMessage(err, '获取用户角色失败'))
}
}
// 编辑成功回调
const handleEditSuccess = () => {
setEditVisible(false)
fetchUsers()
}
const handleCreateSuccess = () => {
setCreateVisible(false)
fetchUsers()
}
// 角色分配成功回调
const handleAssignRolesSuccess = () => {
setAssignRolesVisible(false)
fetchUsers()
}
// 表格列定义
const columns: TableColumnsType<User> = [
{
title: '用户名',
dataIndex: 'username',
key: 'username',
width: 120,
fixed: 'left',
},
{
title: '昵称',
dataIndex: 'nickname',
key: 'nickname',
width: 100,
render: (text) => text || '-',
},
{
title: '邮箱',
dataIndex: 'email',
key: 'email',
width: 180,
render: (text) => text || '-',
},
{
title: '手机号',
dataIndex: 'phone',
key: 'phone',
width: 130,
render: (text) => text || '-',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 80,
render: (status: UserStatus) => (
<Tag color={UserStatusColor[status]}>{UserStatusText[status]}</Tag>
),
},
{
title: '最后登录',
dataIndex: 'last_login_at',
key: 'last_login_at',
width: 160,
render: (text) => (text ? dayjs(text).format('YYYY-MM-DD HH:mm') : '-'),
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 160,
render: (text) => dayjs(text).format('YYYY-MM-DD HH:mm'),
},
{
title: '操作',
key: 'action',
width: 200,
fixed: 'right',
render: (_, record) => (
<Space size="small">
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => handleViewDetail(record)}
>
</Button>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
>
</Button>
<Button
type="link"
size="small"
icon={<TeamOutlined />}
onClick={() => handleAssignRoles(record)}
>
</Button>
{record.status === 1 ? (
<Popconfirm
title="确定要禁用该用户吗?"
onConfirm={() => handleToggleStatus(record)}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
) : record.status === 3 ? (
<Popconfirm
title="确定要激活该用户吗?"
onConfirm={() => handleToggleStatus(record)}
>
<Button type="link" size="small">
</Button>
</Popconfirm>
) : record.status === 2 ? (
<Popconfirm
title="该用户因多次失败已被锁定,确定要解锁并激活吗?"
onConfirm={() => handleToggleStatus(record)}
>
<Button type="link" size="small">
</Button>
</Popconfirm>
) : record.status === 0 ? (
<Popconfirm
title="该用户尚未激活,确定要激活该用户吗?"
onConfirm={() => handleToggleStatus(record)}
>
<Button type="link" size="small">
</Button>
</Popconfirm>
) : null}
<Popconfirm
title={`确定要删除用户「${record.username}」吗?此操作不可恢复。`}
onConfirm={() => handleDelete(record)}
>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
>
</Button>
</Popconfirm>
</Space>
),
},
]
// 分页配置
const paginationConfig: TablePaginationConfig = {
current: page,
pageSize,
total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
onChange: (p, ps) => {
setPage(p)
setPageSize(ps)
},
}
if (error) {
return <PageError description={error} onRetry={fetchUsers} />
}
return (
<PageLayout>
<PageHeader
title="用户管理"
description="管理系统用户,支持创建、查看、编辑、状态管理和角色分配"
actions={
<Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateVisible(true)}>
</Button>
<Button icon={<ReloadOutlined />} onClick={fetchUsers}>
</Button>
</Space>
}
/>
{/* 筛选区域 */}
<FilterCard>
<Space wrap size="middle">
<Input
placeholder="用户名/邮箱/手机号"
prefix={<SearchOutlined />}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onPressEnter={() => void fetchUsers()}
style={{ width: 200 }}
allowClear
/>
<Select
placeholder="用户状态"
value={statusFilter}
onChange={setStatusFilter}
allowClear
style={{ width: 120 }}
options={[
{ value: 0, label: '未激活' },
{ value: 1, label: '已激活' },
{ value: 2, label: '已锁定' },
{ value: 3, label: '已禁用' },
]}
/>
<Select
placeholder="按角色筛选"
value={roleFilter}
onChange={setRoleFilter}
allowClear
style={{ width: 150 }}
options={roles.map((r) => ({ value: r.id, label: r.name }))}
/>
<RangePicker
placeholder={['创建开始', '创建结束']}
onChange={(_, dateStrings) => {
setCreatedFrom(dateStrings[0] || undefined)
setCreatedTo(dateStrings[1] || undefined)
}}
/>
<Select
placeholder="排序字段"
value={sortBy}
onChange={setSortBy}
allowClear
style={{ width: 120 }}
options={[
{ value: 'created_at', label: '创建时间' },
{ value: 'last_login_at', label: '最后登录' },
{ value: 'username', label: '用户名' },
]}
/>
<Select
placeholder="排序方向"
value={sortOrder}
onChange={setSortOrder}
allowClear
style={{ width: 100 }}
options={[
{ value: 'asc', label: '升序' },
{ value: 'desc', label: '降序' },
]}
/>
<Button type="primary" icon={<SearchOutlined />} onClick={fetchUsers}>
</Button>
<Button onClick={handleReset}></Button>
</Space>
</FilterCard>
{/* 用户列表 */}
<TableCard>
<Table
columns={columns}
dataSource={users}
rowKey="id"
loading={loading}
pagination={paginationConfig}
scroll={{ x: 1200 }}
locale={{
emptyText: (
<PageEmpty
description="暂无用户数据"
/>
),
}}
/>
</TableCard>
{/* 详情抽屉 */}
<UserDetailDrawer
open={detailVisible}
userId={selectedUser?.id}
onClose={() => setDetailVisible(false)}
/>
{/* 编辑抽屉 */}
<UserEditDrawer
open={editVisible}
user={selectedUser}
onSuccess={handleEditSuccess}
onClose={() => setEditVisible(false)}
/>
{/* 创建用户弹窗 */}
<CreateUserModal
open={createVisible}
roles={roles}
onSuccess={handleCreateSuccess}
onClose={() => setCreateVisible(false)}
/>
{/* 角色分配弹窗 */}
<AssignRolesModal
open={assignRolesVisible}
user={selectedUser}
currentRoles={selectedUserRoles}
allRoles={roles}
onSuccess={handleAssignRolesSuccess}
onClose={() => setAssignRolesVisible(false)}
/>
</PageLayout>
)
}

View File

@@ -0,0 +1 @@
export { UsersPage } from './UsersPage'

View File

@@ -0,0 +1,161 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WebhookDelivery } from '@/types/webhook'
import { DeliveryDetailModal } from './DeliveryDetailModal'
vi.mock('antd', () => {
const Descriptions = ({
children,
}: {
children?: ReactNode
}) => <div>{children}</div>
return {
Alert: ({
message,
description,
}: {
message?: ReactNode
description?: ReactNode
}) => (
<div>
<div>{message}</div>
<div>{description}</div>
</div>
),
Descriptions: Object.assign(Descriptions, {
Item: ({
label,
children,
}: {
label?: ReactNode
children?: ReactNode
}) => (
<div>
<span>{label}</span>
<span>{children}</span>
</div>
),
}),
Modal: ({
open,
title,
onCancel,
children,
}: {
open: boolean
title?: ReactNode
onCancel?: () => void
children?: ReactNode
}) => (
open ? (
<div data-testid="delivery-detail-modal-root">
<h1>{title}</h1>
{children}
<button type="button" onClick={onCancel}>modal close</button>
</div>
) : null
),
Typography: {
Paragraph: ({
children,
}: {
children?: ReactNode
}) => <pre>{children}</pre>,
Text: ({
children,
}: {
children?: ReactNode
}) => <span>{children}</span>,
},
}
})
function buildDelivery(overrides?: Partial<WebhookDelivery>): WebhookDelivery {
return {
id: 5,
webhook_id: 9,
event_type: 'user.registered',
payload: '{"user":"alice"}',
status_code: 204,
response_body: '{"ok":true}',
attempt: 1,
success: true,
error: '',
delivered_at: '2026-03-28 09:30:00',
created_at: '2026-03-28 09:29:00',
...overrides,
}
}
describe('DeliveryDetailModal', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('returns null when there is no delivery', () => {
const { container } = render(
<DeliveryDetailModal
open
delivery={null}
onClose={vi.fn()}
/>,
)
expect(container).toBeEmptyDOMElement()
})
it('renders formatted payload and response content for successful deliveries', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(
<DeliveryDetailModal
open
delivery={buildDelivery()}
onClose={onClose}
/>,
)
expect(screen.getByTestId('delivery-detail-modal-root')).toBeInTheDocument()
expect(screen.getByText('user.registered')).toBeInTheDocument()
expect(screen.getByText('204')).toBeInTheDocument()
expect(screen.getByText('成功')).toBeInTheDocument()
expect(screen.getByText(/"user": "alice"/)).toBeInTheDocument()
expect(screen.getByText(/"ok": true/)).toBeInTheDocument()
expect(screen.queryByText('错误信息')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'modal close' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('renders failure placeholders, alerts, and raw payload text', () => {
render(
<DeliveryDetailModal
open
delivery={buildDelivery({
payload: 'not-json',
response_body: '',
status_code: 0,
success: false,
error: 'delivery failed',
delivered_at: '',
})}
onClose={vi.fn()}
/>,
)
expect(screen.getByText('失败')).toBeInTheDocument()
expect(screen.getByText('delivery failed')).toBeInTheDocument()
expect(screen.getByText('错误信息')).toBeInTheDocument()
expect(screen.getByText('not-json')).toBeInTheDocument()
expect(screen.getAllByText('-').length).toBeGreaterThan(0)
})
})

View File

@@ -0,0 +1,97 @@
import { Alert, Descriptions, Modal, Typography } from 'antd'
import type { WebhookDelivery } from '@/types/webhook'
const { Paragraph, Text } = Typography
interface DeliveryDetailModalProps {
open: boolean
delivery: WebhookDelivery | null
onClose: () => void
}
function formatPayload(payload: string) {
try {
return JSON.stringify(JSON.parse(payload), null, 2)
} catch {
return payload
}
}
export function DeliveryDetailModal({ open, delivery, onClose }: DeliveryDetailModalProps) {
if (!delivery) {
return null
}
return (
<Modal title="投递详情" open={open} onCancel={onClose} footer={null} width={720}>
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="投递 ID">{delivery.id}</Descriptions.Item>
<Descriptions.Item label="Webhook ID">{delivery.webhook_id}</Descriptions.Item>
<Descriptions.Item label="事件类型">{delivery.event_type}</Descriptions.Item>
<Descriptions.Item label="尝试次数">{delivery.attempt}</Descriptions.Item>
<Descriptions.Item label="状态码">{delivery.status_code || '-'}</Descriptions.Item>
<Descriptions.Item label="结果">
{delivery.success ? (
<Text type="success"></Text>
) : (
<Text type="danger"></Text>
)}
</Descriptions.Item>
<Descriptions.Item label="创建时间" span={2}>
{delivery.created_at || '-'}
</Descriptions.Item>
<Descriptions.Item label="投递时间" span={2}>
{delivery.delivered_at || '-'}
</Descriptions.Item>
</Descriptions>
{delivery.error && (
<Alert
type="error"
message="错误信息"
description={delivery.error}
style={{ marginTop: 16 }}
showIcon
/>
)}
<div style={{ marginTop: 16 }}>
<Text strong></Text>
<Paragraph
code
style={{
marginTop: 8,
maxHeight: 200,
overflow: 'auto',
backgroundColor: '#f5f5f5',
padding: 12,
borderRadius: 4,
whiteSpace: 'pre-wrap',
}}
>
{formatPayload(delivery.payload)}
</Paragraph>
</div>
{delivery.response_body && (
<div style={{ marginTop: 16 }}>
<Text strong></Text>
<Paragraph
code
style={{
marginTop: 8,
maxHeight: 200,
overflow: 'auto',
backgroundColor: '#f5f5f5',
padding: 12,
borderRadius: 4,
whiteSpace: 'pre-wrap',
}}
>
{formatPayload(delivery.response_body)}
</Paragraph>
</div>
)}
</Modal>
)
}

View File

@@ -0,0 +1,283 @@
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 { Webhook, WebhookDelivery } from '@/types/webhook'
import { WebhookDeliveriesDrawer } from './WebhookDeliveriesDrawer'
const getWebhookDeliveriesMock = vi.fn()
const messageErrorMock = vi.fn()
vi.mock('antd', () => {
const Descriptions = ({
children,
}: {
children?: ReactNode
}) => <div>{children}</div>
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)
}
return {
Button: ({
children,
onClick,
}: {
children?: ReactNode
onClick?: () => void
}) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
Descriptions: Object.assign(Descriptions, {
Item: ({
label,
children,
}: {
label?: ReactNode
children?: ReactNode
}) => (
<div>
<span>{label}</span>
<span>{children}</span>
</div>
),
}),
Drawer: ({
open,
title,
onClose,
extra,
children,
}: {
open: boolean
title?: ReactNode
onClose?: () => void
extra?: ReactNode
children?: ReactNode
}) => (
open ? (
<div data-testid="webhook-deliveries-drawer-root">
<h1>{title}</h1>
{extra}
{children}
<button type="button" onClick={onClose}>drawer close</button>
</div>
) : null
),
Table: ({
columns,
dataSource,
rowKey,
}: {
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)
}) => {
const rows = dataSource ?? []
return (
<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={`delivery-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>
)
},
Tag: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
Typography: {
Text: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
},
message: {
error: (content: string) => messageErrorMock(content),
},
}
})
vi.mock('@ant-design/icons', () => ({
CheckCircleOutlined: () => <span>success-icon</span>,
CloseCircleOutlined: () => <span>error-icon</span>,
EyeOutlined: () => <span>eye-icon</span>,
ReloadOutlined: () => <span>reload-icon</span>,
}))
vi.mock('@/services/webhooks', () => ({
getWebhookDeliveries: (id: number, params: unknown) => getWebhookDeliveriesMock(id, params),
}))
vi.mock('./DeliveryDetailModal', () => ({
DeliveryDetailModal: ({
open,
delivery,
onClose,
}: {
open: boolean
delivery: Pick<WebhookDelivery, 'id'> | null
onClose: () => void
}) => (
open ? (
<div data-testid="delivery-detail-modal">
<span>{delivery ? `detail:${delivery.id}` : 'detail:none'}</span>
<button type="button" onClick={onClose}>detail close</button>
</div>
) : null
),
}))
function buildWebhook(): Webhook {
return {
id: 3,
name: 'Audit Hook',
url: 'https://audit.example.com/webhook',
events: ['user.registered'],
status: 1,
max_retries: 3,
timeout_sec: 15,
created_by: 1,
created_at: '2026-03-28 09:00:00',
updated_at: '2026-03-28 09:10:00',
}
}
function buildDeliveries(): WebhookDelivery[] {
return [
{
id: 11,
webhook_id: 3,
event_type: 'user.registered',
payload: '{"user":"alice"}',
status_code: 204,
response_body: '{"ok":true}',
attempt: 1,
success: true,
error: '',
delivered_at: '2026-03-28 09:31:00',
created_at: '2026-03-28 09:30:00',
},
{
id: 12,
webhook_id: 3,
event_type: 'user.deleted',
payload: '{"user":"bob"}',
status_code: 0,
response_body: '',
attempt: 2,
success: false,
error: 'delivery failed',
delivered_at: '',
created_at: '2026-03-28 09:32:00',
},
]
}
describe('WebhookDeliveriesDrawer', () => {
beforeEach(() => {
getWebhookDeliveriesMock.mockReset()
messageErrorMock.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('returns early when there is no webhook selected', () => {
render(
<WebhookDeliveriesDrawer
open
webhook={null}
onClose={vi.fn()}
/>,
)
expect(getWebhookDeliveriesMock).not.toHaveBeenCalled()
})
it('loads deliveries, refreshes, opens detail view, and closes the drawer', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
getWebhookDeliveriesMock.mockResolvedValue(buildDeliveries())
render(
<WebhookDeliveriesDrawer
open
webhook={buildWebhook()}
onClose={onClose}
/>,
)
await waitFor(() => expect(getWebhookDeliveriesMock).toHaveBeenCalledWith(3, { limit: 50 }))
expect(screen.getByText('https://audit.example.com/webhook')).toBeInTheDocument()
expect(screen.getByText('已启用')).toBeInTheDocument()
expect(screen.getByText('204')).toBeInTheDocument()
expect(screen.getByText('成功')).toBeInTheDocument()
expect(screen.getByText('失败')).toBeInTheDocument()
expect(screen.getByText('delivery failed')).toBeInTheDocument()
await user.click(within(screen.getByTestId('delivery-row-12')).getByRole('button', { name: '详情' }))
expect(screen.getByTestId('delivery-detail-modal')).toHaveTextContent('detail:12')
await user.click(screen.getByRole('button', { name: 'detail close' }))
await waitFor(() => expect(screen.queryByTestId('delivery-detail-modal')).not.toBeInTheDocument())
await user.click(screen.getByRole('button', { name: '刷新' }))
await waitFor(() => expect(getWebhookDeliveriesMock).toHaveBeenCalledTimes(2))
await user.click(screen.getByRole('button', { name: 'drawer close' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('surfaces delivery loading failures', async () => {
getWebhookDeliveriesMock.mockRejectedValueOnce(new Error('load deliveries failed'))
render(
<WebhookDeliveriesDrawer
open
webhook={buildWebhook()}
onClose={vi.fn()}
/>,
)
await waitFor(() => expect(messageErrorMock).toHaveBeenCalledWith('load deliveries failed'))
})
})

View File

@@ -0,0 +1,182 @@
import { useCallback, useEffect, useState } from 'react'
import {
Button,
Descriptions,
Drawer,
Table,
Tag,
Typography,
message,
type TableColumnsType,
} from 'antd'
import {
CheckCircleOutlined,
CloseCircleOutlined,
EyeOutlined,
ReloadOutlined,
} from '@ant-design/icons'
import { getErrorMessage } from '@/lib/errors'
import { getWebhookDeliveries } from '@/services/webhooks'
import { WebhookStatusText, type Webhook, type WebhookDelivery } from '@/types/webhook'
import { DeliveryDetailModal } from './DeliveryDetailModal'
const { Text } = Typography
interface WebhookDeliveriesDrawerProps {
open: boolean
webhook: Webhook | null
onClose: () => void
}
export function WebhookDeliveriesDrawer({ open, webhook, onClose }: WebhookDeliveriesDrawerProps) {
const [loading, setLoading] = useState(false)
const [deliveries, setDeliveries] = useState<WebhookDelivery[]>([])
const [detailVisible, setDetailVisible] = useState(false)
const [selectedDelivery, setSelectedDelivery] = useState<WebhookDelivery | null>(null)
const fetchDeliveries = useCallback(async () => {
if (!webhook) {
return
}
setLoading(true)
try {
const result = await getWebhookDeliveries(webhook.id, { limit: 50 })
setDeliveries(result)
} catch (err) {
message.error(getErrorMessage(err, '获取投递记录失败'))
} finally {
setLoading(false)
}
}, [webhook])
useEffect(() => {
if (open) {
void fetchDeliveries()
}
}, [fetchDeliveries, open])
const columns: TableColumnsType<WebhookDelivery> = [
{
title: '事件',
dataIndex: 'event_type',
key: 'event_type',
width: 180,
render: (value) => <Tag color="blue">{value}</Tag>,
},
{
title: '尝试次数',
dataIndex: 'attempt',
key: 'attempt',
width: 100,
},
{
title: '状态码',
dataIndex: 'status_code',
key: 'status_code',
width: 100,
render: (value) => (
<Tag color={value >= 200 && value < 300 ? 'success' : 'error'}>{value || '-'}</Tag>
),
},
{
title: '结果',
dataIndex: 'success',
key: 'success',
width: 100,
render: (value) => (
<Tag
color={value ? 'success' : 'error'}
icon={value ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
>
{value ? '成功' : '失败'}
</Tag>
),
},
{
title: '错误信息',
dataIndex: 'error',
key: 'error',
width: 220,
ellipsis: true,
render: (value) => value || '-',
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180,
render: (value) => value || '-',
},
{
title: '操作',
key: 'action',
width: 100,
render: (_, record) => (
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => {
setSelectedDelivery(record)
setDetailVisible(true)
}}
>
</Button>
),
},
]
return (
<>
<Drawer
title={`投递记录 - ${webhook?.name || ''}`}
placement="right"
width={960}
open={open}
onClose={onClose}
extra={(
<Button icon={<ReloadOutlined />} onClick={() => void fetchDeliveries()}>
</Button>
)}
>
{webhook && (
<Descriptions size="small" column={2} style={{ marginBottom: 16 }}>
<Descriptions.Item label="目标 URL">
<Text copyable ellipsis style={{ maxWidth: 420 }}>
{webhook.url}
</Text>
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={webhook.status === 1 ? 'success' : 'default'}>
{WebhookStatusText[webhook.status]}
</Tag>
</Descriptions.Item>
</Descriptions>
)}
<Table
columns={columns}
dataSource={deliveries}
rowKey="id"
loading={loading}
pagination={{
pageSize: 10,
showSizeChanger: true,
showTotal: (value) => `${value}`,
}}
scroll={{ x: 1000 }}
size="small"
/>
</Drawer>
<DeliveryDetailModal
open={detailVisible}
delivery={selectedDelivery}
onClose={() => setDetailVisible(false)}
/>
</>
)
}

View File

@@ -0,0 +1,246 @@
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 { Webhook } from '@/types/webhook'
import { WebhookFormModal } from './WebhookFormModal'
const createWebhookMock = vi.fn()
const updateWebhookMock = vi.fn()
const messageSuccessMock = vi.fn()
const messageErrorMock = vi.fn()
const formApi = {
validateFields: vi.fn(),
setFieldsValue: vi.fn(),
resetFields: vi.fn(),
}
vi.mock('antd', () => {
const Form = ({
children,
}: {
children?: ReactNode
}) => <form>{children}</form>
return {
Form: Object.assign(Form, {
useForm: () => [formApi],
Item: ({
label,
children,
}: {
label?: ReactNode
children?: ReactNode
}) => (
<div>
{label ? <span>{label}</span> : null}
{children}
</div>
),
}),
Input: Object.assign(({
placeholder,
}: {
placeholder?: string
}) => <input placeholder={placeholder} readOnly />, {
Password: ({ placeholder }: { placeholder?: string }) => (
<input placeholder={placeholder} type="password" readOnly />
),
}),
Modal: ({
open,
title,
onOk,
onCancel,
children,
confirmLoading,
}: {
open: boolean
title?: ReactNode
onOk?: () => void
onCancel?: () => void
children?: ReactNode
confirmLoading?: boolean
}) => (
open ? (
<div data-testid="webhook-form-modal-root">
<h1>{title}</h1>
<div>{`loading:${String(confirmLoading ?? false)}`}</div>
{children}
<button type="button" onClick={onOk}>modal ok</button>
<button type="button" onClick={onCancel}>modal cancel</button>
</div>
) : null
),
Select: ({
placeholder,
options = [],
}: {
placeholder?: string
options?: Array<{ value: string, label: ReactNode }>
}) => (
<select aria-label={placeholder ?? 'select'}>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
),
message: {
success: (content: string) => messageSuccessMock(content),
error: (content: string) => messageErrorMock(content),
},
}
})
vi.mock('@/services/webhooks', () => ({
createWebhook: (payload: unknown) => createWebhookMock(payload),
updateWebhook: (id: number, payload: unknown) => updateWebhookMock(id, payload),
}))
function buildWebhook(): Webhook {
return {
id: 7,
name: 'Audit Hook',
url: 'https://audit.example.com/webhook',
events: ['user.registered', 'user.deleted'],
status: 1,
max_retries: 3,
timeout_sec: 15,
created_by: 1,
created_at: '2026-03-28 09:00:00',
updated_at: '2026-03-28 09:10:00',
}
}
describe('WebhookFormModal', () => {
beforeEach(() => {
createWebhookMock.mockReset()
updateWebhookMock.mockReset()
messageSuccessMock.mockReset()
messageErrorMock.mockReset()
formApi.validateFields.mockReset()
formApi.setFieldsValue.mockReset()
formApi.resetFields.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('resets create mode, renders event options, and submits create payloads', async () => {
const user = userEvent.setup()
const onSuccess = vi.fn()
formApi.validateFields.mockResolvedValue({
name: 'Created Hook',
url: 'https://created.example.com/webhook',
secret: 'secret-token',
events: ['user.registered'],
})
createWebhookMock.mockResolvedValue(undefined)
render(
<WebhookFormModal
open
webhook={null}
onSuccess={onSuccess}
onClose={vi.fn()}
/>,
)
expect(formApi.resetFields).toHaveBeenCalledTimes(1)
expect(screen.getByText('用户注册')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'modal ok' }))
await waitFor(() => expect(createWebhookMock).toHaveBeenCalledWith({
name: 'Created Hook',
url: 'https://created.example.com/webhook',
secret: 'secret-token',
events: ['user.registered'],
}))
expect(messageSuccessMock).toHaveBeenCalledTimes(1)
expect(onSuccess).toHaveBeenCalledTimes(1)
})
it('prefills edit mode and updates editable fields only', async () => {
const user = userEvent.setup()
const webhook = buildWebhook()
formApi.validateFields.mockResolvedValue({
name: 'Updated Hook',
url: 'https://updated.example.com/webhook',
secret: 'ignored-secret',
events: ['user.deleted'],
})
updateWebhookMock.mockResolvedValue(undefined)
render(
<WebhookFormModal
open
webhook={webhook}
onSuccess={vi.fn()}
onClose={vi.fn()}
/>,
)
expect(formApi.setFieldsValue).toHaveBeenCalledWith({
name: 'Audit Hook',
url: 'https://audit.example.com/webhook',
events: ['user.registered', 'user.deleted'],
})
await user.click(screen.getByRole('button', { name: 'modal ok' }))
await waitFor(() => expect(updateWebhookMock).toHaveBeenCalledWith(7, {
name: 'Updated Hook',
url: 'https://updated.example.com/webhook',
events: ['user.deleted'],
}))
expect(messageSuccessMock).toHaveBeenCalledTimes(1)
})
it('swallows validation errors and surfaces service failures', async () => {
const user = userEvent.setup()
formApi.validateFields.mockRejectedValueOnce({ errorFields: [{ name: ['name'] }] })
const { rerender } = render(
<WebhookFormModal
open
webhook={null}
onSuccess={vi.fn()}
onClose={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'modal ok' }))
await waitFor(() => expect(createWebhookMock).not.toHaveBeenCalled())
expect(messageErrorMock).not.toHaveBeenCalled()
formApi.validateFields.mockResolvedValueOnce({
name: 'Broken Hook',
url: 'https://broken.example.com/webhook',
secret: '',
events: ['user.registered'],
})
createWebhookMock.mockRejectedValueOnce(new Error('save failed'))
rerender(
<WebhookFormModal
open
webhook={null}
onSuccess={vi.fn()}
onClose={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'modal ok' }))
await waitFor(() => expect(messageErrorMock).toHaveBeenCalledWith('save failed'))
})
})

View File

@@ -0,0 +1,122 @@
import { useEffect, useState } from 'react'
import { Form, Input, Modal, Select, message } from 'antd'
import { getErrorMessage, isFormValidationError } from '@/lib/errors'
import { createWebhook, updateWebhook } from '@/services/webhooks'
import { WebhookEventText, type Webhook } from '@/types/webhook'
interface WebhookFormModalProps {
open: boolean
webhook: Webhook | null
onSuccess: () => void
onClose: () => void
}
export function WebhookFormModal({ open, webhook, onSuccess, onClose }: WebhookFormModalProps) {
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const isEdit = Boolean(webhook)
useEffect(() => {
if (!open) {
return
}
if (webhook) {
form.setFieldsValue({
name: webhook.name,
url: webhook.url,
events: webhook.events,
})
return
}
form.resetFields()
}, [form, open, webhook])
const handleSubmit = async () => {
try {
const values = await form.validateFields()
setLoading(true)
if (isEdit && webhook) {
await updateWebhook(webhook.id, {
name: values.name,
url: values.url,
events: values.events,
})
message.success('Webhook 已更新')
} else {
await createWebhook({
name: values.name,
url: values.url,
secret: values.secret,
events: values.events,
})
message.success('Webhook 已创建')
}
onSuccess()
} catch (err) {
if (isFormValidationError(err)) {
return
}
message.error(getErrorMessage(err, '保存 Webhook 失败'))
} finally {
setLoading(false)
}
}
const eventOptions = Object.entries(WebhookEventText).map(([value, label]) => ({
value,
label,
}))
return (
<Modal
title={isEdit ? '编辑 Webhook' : '创建 Webhook'}
open={open}
onOk={() => void handleSubmit()}
onCancel={onClose}
confirmLoading={loading}
width={640}
>
<Form form={form} layout="vertical" initialValues={{ events: [] }}>
<Form.Item
name="name"
label="Webhook 名称"
rules={[
{ required: true, message: '请输入 Webhook 名称' },
{ max: 100, message: '名称不能超过 100 个字符' },
]}
>
<Input placeholder="例如:用户事件通知" />
</Form.Item>
<Form.Item
name="url"
label="目标 URL"
rules={[
{ required: true, message: '请输入目标 URL' },
{ type: 'url', message: '请输入有效的 URL' },
]}
>
<Input placeholder="https://example.com/webhook" />
</Form.Item>
{!isEdit && (
<Form.Item name="secret" label="签名密钥" extra="可选。不传则由后端生成。">
<Input.Password placeholder="可选" />
</Form.Item>
)}
<Form.Item
name="events"
label="订阅事件"
rules={[{ required: true, message: '请至少选择一个事件' }]}
>
<Select mode="multiple" options={eventOptions} placeholder="选择事件类型" />
</Form.Item>
</Form>
</Modal>
)
}

View File

@@ -0,0 +1,7 @@
.filterCard {
margin-bottom: 16px;
}
.tableCard {
margin-bottom: 16px;
}

View File

@@ -0,0 +1,285 @@
import type { ReactElement, ReactNode } from 'react'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { message } from 'antd'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Webhook } from '@/types/webhook'
import { WebhooksPage } from './WebhooksPage'
const originalGetComputedStyle = window.getComputedStyle
const listWebhooksMock = vi.fn<() => Promise<{ data: Webhook[]; total: number; page: number; page_size: number }>>()
const deleteWebhookMock = vi.fn<(id: number) => Promise<void>>()
const updateWebhookStatusMock = vi.fn<(id: number, status: 0 | 1) => Promise<void>>()
vi.mock('antd', async () => {
const actual = await vi.importActual<typeof import('antd')>('antd')
return {
...actual,
Popconfirm: ({
children,
title,
onConfirm,
}: {
children: ReactElement
title?: ReactNode
onConfirm?: () => void
}) => (
<div>
{children}
<button type="button" onClick={() => onConfirm?.()}>
{typeof title === 'string' ? title : 'confirm action'}
</button>
</div>
),
}
})
vi.mock('@/services/webhooks', () => ({
listWebhooks: () => listWebhooksMock(),
deleteWebhook: (id: number) => deleteWebhookMock(id),
updateWebhookStatus: (id: number, status: 0 | 1) => updateWebhookStatusMock(id, status),
}))
vi.mock('./WebhookFormModal', () => ({
WebhookFormModal: ({
open,
webhook,
onSuccess,
onClose,
}: {
open: boolean
webhook: Pick<Webhook, 'name'> | null
onSuccess: () => void
onClose: () => void
}) => {
if (!open) {
return null
}
return (
<div data-testid="webhook-form-modal">
<span>{webhook ? `edit:${webhook.name}` : 'create'}</span>
<button onClick={onSuccess}>form success</button>
<button onClick={onClose}>form close</button>
</div>
)
},
}))
vi.mock('./WebhookDeliveriesDrawer', () => ({
WebhookDeliveriesDrawer: ({
open,
webhook,
onClose,
}: {
open: boolean
webhook: Pick<Webhook, 'name'> | null
onClose: () => void
}) => {
if (!open) {
return null
}
return (
<div data-testid="webhook-deliveries-drawer">
<span>{webhook ? `drawer:${webhook.name}` : 'drawer:none'}</span>
<button onClick={onClose}>drawer close</button>
</div>
)
},
}))
const seedWebhooks: Webhook[] = [
{
id: 1,
name: 'Alpha Hook',
url: 'https://alpha.example.com/webhook',
events: ['user.registered'],
status: 1,
max_retries: 3,
timeout_sec: 10,
created_by: 100,
created_at: '2026-03-27 20:00:00',
updated_at: '2026-03-27 20:00:00',
},
{
id: 2,
name: 'Beta Hook',
url: 'https://beta.example.com/webhook',
events: ['user.login'],
status: 0,
max_retries: 5,
timeout_sec: 15,
created_by: 200,
created_at: '2026-03-27 20:05:00',
updated_at: '2026-03-27 20:05:00',
},
]
function cloneWebhook(webhook: Webhook): Webhook {
return {
...webhook,
events: [...webhook.events],
}
}
function getRowByCellText(text: string): HTMLElement {
const cell = screen.getByText(text)
const row = cell.closest('tr')
if (!row) {
throw new Error(`unable to resolve table row for ${text}`)
}
return row as HTMLElement
}
describe('WebhooksPage', () => {
let currentWebhooks: Webhook[]
beforeEach(() => {
currentWebhooks = seedWebhooks.map(cloneWebhook)
listWebhooksMock.mockReset()
deleteWebhookMock.mockReset()
updateWebhookStatusMock.mockReset()
listWebhooksMock.mockImplementation(async () => ({
data: currentWebhooks.map(cloneWebhook),
total: currentWebhooks.length,
page: 1,
page_size: 20,
}))
deleteWebhookMock.mockImplementation(async (id: number) => {
currentWebhooks = currentWebhooks.filter((webhook) => webhook.id !== id)
})
updateWebhookStatusMock.mockImplementation(async (id: number, status: 0 | 1) => {
currentWebhooks = currentWebhooks.map((webhook) => (
webhook.id === id ? { ...webhook, status } : webhook
))
})
vi.spyOn(window, 'getComputedStyle').mockImplementation((element) => (
originalGetComputedStyle.call(window, element)
))
vi.spyOn(message, 'success').mockImplementation(() => undefined as never)
vi.spyOn(message, 'error').mockImplementation(() => undefined as never)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('loads, filters, resets, and refreshes the webhook list', async () => {
const user = userEvent.setup()
render(<WebhooksPage />)
expect(await screen.findByText('Alpha Hook')).toBeInTheDocument()
expect(screen.getByText('Beta Hook')).toBeInTheDocument()
await user.type(screen.getByPlaceholderText('Webhook 名称或 URL'), 'beta')
await waitFor(() => expect(screen.queryByText('Alpha Hook')).not.toBeInTheDocument())
expect(screen.getByText('Beta Hook')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /重\s*置/ }))
await waitFor(() => expect(screen.getByText('Alpha Hook')).toBeInTheDocument())
expect(screen.getByText('Beta Hook')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /刷新/ }))
await waitFor(() => expect(listWebhooksMock).toHaveBeenCalledTimes(2))
})
it('opens create, edit, and deliveries overlays and refetches after modal success', async () => {
const user = userEvent.setup()
render(<WebhooksPage />)
expect(await screen.findByText('Alpha Hook')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /创建 Webhook/ }))
expect(screen.getByTestId('webhook-form-modal')).toHaveTextContent('create')
await user.click(screen.getByRole('button', { name: 'form success' }))
await waitFor(() => expect(listWebhooksMock).toHaveBeenCalledTimes(2))
await waitFor(() => expect(screen.queryByTestId('webhook-form-modal')).not.toBeInTheDocument())
const alphaRow = getRowByCellText('Alpha Hook')
await user.click(within(alphaRow).getByRole('button', { name: /编辑/ }))
expect(screen.getByTestId('webhook-form-modal')).toHaveTextContent('edit:Alpha Hook')
await user.click(screen.getByRole('button', { name: 'form close' }))
await waitFor(() => expect(screen.queryByTestId('webhook-form-modal')).not.toBeInTheDocument())
await user.click(within(alphaRow).getByRole('button', { name: /记录/ }))
expect(screen.getByTestId('webhook-deliveries-drawer')).toHaveTextContent('drawer:Alpha Hook')
await user.click(screen.getByRole('button', { name: 'drawer close' }))
await waitFor(() => expect(screen.queryByTestId('webhook-deliveries-drawer')).not.toBeInTheDocument())
})
it('updates webhook status and deletes webhooks with a refreshed list', async () => {
const user = userEvent.setup()
render(<WebhooksPage />)
expect(await screen.findByText('Alpha Hook')).toBeInTheDocument()
await user.click(within(getRowByCellText('Alpha Hook')).getByRole('button', { name: /^禁用$/ }))
await user.click(within(getRowByCellText('Alpha Hook')).getByRole('button', { name: '确定禁用该 Webhook 吗?' }))
await waitFor(() => expect(updateWebhookStatusMock).toHaveBeenCalledWith(1, 0))
expect(message.success).toHaveBeenCalledWith('Webhook 状态已更新')
expect(within(getRowByCellText('Alpha Hook')).getByRole('button', { name: '启用' })).toBeInTheDocument()
await user.click(within(getRowByCellText('Beta Hook')).getByRole('button', { name: /^delete 删除$|^删除$/ }))
await user.click(within(getRowByCellText('Beta Hook')).getByRole('button', { name: '确定删除该 Webhook 吗?' }))
await waitFor(() => expect(deleteWebhookMock).toHaveBeenCalledWith(2))
expect(message.success).toHaveBeenCalledWith('Webhook 已删除')
await waitFor(() => expect(screen.queryByText('Beta Hook')).not.toBeInTheDocument())
})
it('renders the page error state and retries loading', async () => {
const user = userEvent.setup()
listWebhooksMock.mockReset()
listWebhooksMock.mockRejectedValueOnce(new Error('列表拉取失败'))
listWebhooksMock.mockImplementation(async () => ({
data: currentWebhooks.map(cloneWebhook),
total: currentWebhooks.length,
page: 1,
page_size: 20,
}))
render(<WebhooksPage />)
expect(await screen.findByText('列表拉取失败')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /重新加载/ }))
await waitFor(() => expect(screen.getByText('Alpha Hook')).toBeInTheDocument())
expect(listWebhooksMock).toHaveBeenCalledTimes(2)
})
it('shows an error message when status updates fail', async () => {
const user = userEvent.setup()
updateWebhookStatusMock.mockRejectedValueOnce(new Error('状态更新失败'))
render(<WebhooksPage />)
expect(await screen.findByText('Alpha Hook')).toBeInTheDocument()
await user.click(within(getRowByCellText('Alpha Hook')).getByRole('button', { name: /^禁用$/ }))
await user.click(within(getRowByCellText('Alpha Hook')).getByRole('button', { name: '确定禁用该 Webhook 吗?' }))
await waitFor(() => expect(message.error).toHaveBeenCalledWith('状态更新失败'))
expect(listWebhooksMock).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,304 @@
import { useCallback, useEffect, useState } from 'react'
import {
Button,
Input,
Popconfirm,
Select,
Space,
Table,
Tag,
Tooltip,
message,
type TableColumnsType,
} from 'antd'
import {
DeleteOutlined,
EditOutlined,
HistoryOutlined,
PlusOutlined,
ReloadOutlined,
SearchOutlined,
} from '@ant-design/icons'
import { PageHeader } from '@/components/common'
import { PageEmpty, PageError } from '@/components/feedback'
import { PageLayout, FilterCard, TableCard } from '@/components/layout'
import { getErrorMessage } from '@/lib/errors'
import { deleteWebhook, listWebhooks, updateWebhookStatus } from '@/services/webhooks'
import {
WebhookEventText,
WebhookStatusColor,
WebhookStatusText,
type Webhook,
type WebhookEvent,
type WebhookStatus,
} from '@/types/webhook'
import { WebhookDeliveriesDrawer } from './WebhookDeliveriesDrawer'
import { WebhookFormModal } from './WebhookFormModal'
export function WebhooksPage() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [webhooks, setWebhooks] = useState<Webhook[]>([])
const [keyword, setKeyword] = useState('')
const [statusFilter, setStatusFilter] = useState<WebhookStatus | undefined>()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [formVisible, setFormVisible] = useState(false)
const [deliveriesVisible, setDeliveriesVisible] = useState(false)
const [selectedWebhook, setSelectedWebhook] = useState<Webhook | null>(null)
const fetchWebhooks = useCallback(async () => {
setLoading(true)
setError(null)
try {
const result = await listWebhooks({ page, page_size: pageSize })
setWebhooks(result.data)
} catch (err) {
setError(getErrorMessage(err, '获取 Webhook 列表失败'))
} finally {
setLoading(false)
}
}, [page, pageSize])
useEffect(() => {
void fetchWebhooks()
}, [fetchWebhooks])
// 关键字或状态变化时重置页码
useEffect(() => {
setPage(1)
}, [keyword, statusFilter])
const filteredWebhooks = webhooks.filter((webhook) => {
const matchesKeyword = !keyword
|| webhook.name.toLowerCase().includes(keyword.toLowerCase())
|| webhook.url.toLowerCase().includes(keyword.toLowerCase())
const matchesStatus = statusFilter === undefined || webhook.status === statusFilter
return matchesKeyword && matchesStatus
})
// 前端分页切片(前端全量过滤后再分页)
const pagedWebhooks = filteredWebhooks.slice((page - 1) * pageSize, page * pageSize)
const handleReset = () => {
setKeyword('')
setStatusFilter(undefined)
setPage(1)
}
const handleDelete = async (id: number) => {
try {
await deleteWebhook(id)
message.success('Webhook 已删除')
await fetchWebhooks()
} catch (err) {
message.error(getErrorMessage(err, '删除 Webhook 失败'))
}
}
const handleToggleStatus = async (webhook: Webhook) => {
const nextStatus: WebhookStatus = webhook.status === 1 ? 0 : 1
try {
await updateWebhookStatus(webhook.id, nextStatus)
message.success('Webhook 状态已更新')
await fetchWebhooks()
} catch (err) {
message.error(getErrorMessage(err, '更新 Webhook 状态失败'))
}
}
const columns: TableColumnsType<Webhook> = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
},
{
title: '名称',
dataIndex: 'name',
key: 'name',
width: 180,
ellipsis: true,
},
{
title: '目标 URL',
dataIndex: 'url',
key: 'url',
width: 320,
ellipsis: true,
render: (value) => (
<Tooltip title={value}>
<span>{value}</span>
</Tooltip>
),
},
{
title: '订阅事件',
dataIndex: 'events',
key: 'events',
width: 260,
render: (events: WebhookEvent[]) => (
<Space size={[0, 4]} wrap>
{events.map((event) => (
<Tag key={event} color="blue">
{WebhookEventText[event] || event}
</Tag>
))}
</Space>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (value: WebhookStatus) => (
<Tag color={WebhookStatusColor[value]}>{WebhookStatusText[value]}</Tag>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180,
render: (value) => value || '-',
},
{
title: '操作',
key: 'action',
width: 260,
fixed: 'right',
render: (_, record) => (
<Space size="small">
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => {
setSelectedWebhook(record)
setFormVisible(true)
}}>
</Button>
<Button type="link" size="small" icon={<HistoryOutlined />} onClick={() => {
setSelectedWebhook(record)
setDeliveriesVisible(true)
}}>
</Button>
<Popconfirm
title={record.status === 1 ? '确定禁用该 Webhook 吗?' : '确定启用该 Webhook 吗?'}
onConfirm={() => void handleToggleStatus(record)}
>
<Button type="link" size="small">
{record.status === 1 ? '禁用' : '启用'}
</Button>
</Popconfirm>
<Popconfirm
title="确定删除该 Webhook 吗?"
onConfirm={() => void handleDelete(record.id)}
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
]
if (error) {
return <PageError description={error} onRetry={fetchWebhooks} />
}
return (
<PageLayout>
<PageHeader
title="Webhook 管理"
description="对齐真实后端 `/webhooks` 与 `/webhooks/:id/deliveries`,移除不存在的状态/测试子接口"
actions={(
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setSelectedWebhook(null)
setFormVisible(true)
}}
>
Webhook
</Button>
)}
/>
<FilterCard>
<Space wrap size="middle">
<Input
placeholder="Webhook 名称或 URL"
prefix={<SearchOutlined />}
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
onPressEnter={() => setPage(1)}
style={{ width: 240 }}
allowClear
/>
<Select
placeholder="状态"
value={statusFilter}
onChange={setStatusFilter}
allowClear
style={{ width: 120 }}
options={[
{ value: 0, label: '已禁用' },
{ value: 1, label: '已启用' },
]}
/>
<Button type="primary" icon={<SearchOutlined />} onClick={() => setPage(1)}>
</Button>
<Button onClick={handleReset}></Button>
<Button icon={<ReloadOutlined />} onClick={() => void fetchWebhooks()}>
</Button>
</Space>
</FilterCard>
<TableCard>
<Table
columns={columns}
dataSource={pagedWebhooks}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize,
total: filteredWebhooks.length,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (value) => `${value}`,
onChange: (current, size) => {
setPage(current)
setPageSize(size)
},
}}
scroll={{ x: 1400 }}
locale={{ emptyText: <PageEmpty description="暂无 Webhook 数据" /> }}
/>
</TableCard>
<WebhookFormModal
open={formVisible}
webhook={selectedWebhook}
onSuccess={() => {
setFormVisible(false)
void fetchWebhooks()
}}
onClose={() => setFormVisible(false)}
/>
<WebhookDeliveriesDrawer
open={deliveriesVisible}
webhook={selectedWebhook}
onClose={() => setDeliveriesVisible(false)}
/>
</PageLayout>
)
}

View File

@@ -0,0 +1,4 @@
export { WebhooksPage } from './WebhooksPage'
export { WebhookFormModal } from './WebhookFormModal'
export { WebhookDeliveriesDrawer } from './WebhookDeliveriesDrawer'
export { DeliveryDetailModal } from './DeliveryDetailModal'

View File

@@ -0,0 +1,10 @@
export { DashboardPage } from './DashboardPage'
export { UsersPage } from './UsersPage'
export { RolesPage } from './RolesPage'
export { PermissionsPage } from './PermissionsPage'
export { LoginLogsPage } from './LoginLogsPage'
export { OperationLogsPage } from './OperationLogsPage'
export { WebhooksPage } from './WebhooksPage'
export { ImportExportPage } from './ImportExportPage'
export { ProfilePage } from './ProfilePage'
export { ProfileSecurityPage } from './ProfileSecurityPage'

View File

@@ -0,0 +1,106 @@
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AuthCapabilities } from '@/types'
import { ActivateAccountPage } from './ActivateAccountPage'
const getAuthCapabilitiesMock = vi.fn<() => Promise<AuthCapabilities>>()
const activateEmailMock = vi.fn<(token: string) => Promise<unknown>>()
const resendActivationEmailMock = vi.fn<(payload: { email: string }) => Promise<{ message: string }>>()
vi.mock('@/services/auth', () => ({
getAuthCapabilities: () => getAuthCapabilitiesMock(),
activateEmail: (token: string) => activateEmailMock(token),
resendActivationEmail: (payload: { email: string }) => resendActivationEmailMock(payload),
}))
function renderActivateAccountPage(initialEntry: string) {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/activate-account" element={<ActivateAccountPage />} />
</Routes>
</MemoryRouter>,
)
}
describe('ActivateAccountPage', () => {
beforeEach(() => {
getAuthCapabilitiesMock.mockReset()
activateEmailMock.mockReset()
resendActivationEmailMock.mockReset()
getAuthCapabilitiesMock.mockResolvedValue({
password: true,
email_activation: true,
email_code: false,
sms_code: false,
password_reset: false,
admin_bootstrap_required: false,
oauth_providers: [],
})
activateEmailMock.mockResolvedValue({ message: 'account activated successfully' })
resendActivationEmailMock.mockResolvedValue({
message: 'if the email exists, an activation email will be sent shortly',
})
})
it('activates the account when a token is present in the URL', async () => {
renderActivateAccountPage('/activate-account?token=token-123')
await waitFor(() => expect(activateEmailMock).toHaveBeenCalledWith('token-123'))
expect(await screen.findByText('邮箱验证成功')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '立即登录' })).toBeInTheDocument()
})
it('shows the resend form when activation fails', async () => {
activateEmailMock.mockRejectedValueOnce(new Error('activation token expired or missing'))
renderActivateAccountPage('/activate-account?token=expired-token&email=user@example.com')
await waitFor(() => expect(activateEmailMock).toHaveBeenCalledWith('expired-token'))
expect(await screen.findByText('激活链接不可用')).toBeInTheDocument()
expect(screen.getByPlaceholderText('邮箱地址')).toHaveValue('user@example.com')
})
it('resends the activation email from the public activation page', async () => {
const user = userEvent.setup()
renderActivateAccountPage('/activate-account?email=user@example.com')
await waitFor(() => expect(getAuthCapabilitiesMock).toHaveBeenCalledTimes(1))
await user.clear(screen.getByPlaceholderText('邮箱地址'))
await user.type(screen.getByPlaceholderText('邮箱地址'), 'user@example.com')
await user.click(screen.getByRole('button', { name: '重新发送激活邮件' }))
await waitFor(() =>
expect(resendActivationEmailMock).toHaveBeenCalledWith({ email: 'user@example.com' }),
)
expect(await screen.findByText(/请检查收件箱和垃圾邮件目录/)).toBeInTheDocument()
expect(screen.getByText('user@example.com')).toBeInTheDocument()
})
it('shows a disabled warning when email activation is not available', async () => {
getAuthCapabilitiesMock.mockResolvedValue({
password: true,
email_activation: false,
email_code: false,
sms_code: false,
password_reset: false,
admin_bootstrap_required: false,
oauth_providers: [],
})
renderActivateAccountPage('/activate-account')
await waitFor(() => expect(getAuthCapabilitiesMock).toHaveBeenCalledTimes(1))
expect(await screen.findByText('邮箱激活未启用')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '重新发送激活邮件' })).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,234 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { ArrowLeftOutlined, MailOutlined } from '@ant-design/icons'
import { Button, Form, Input, Result, Spin, Typography, message } from 'antd'
import { AuthLayout } from '@/layouts'
import { getErrorMessage } from '@/lib/errors'
import {
activateEmail,
getAuthCapabilities,
resendActivationEmail,
} from '@/services/auth'
import type { AuthCapabilities } from '@/types'
const { Paragraph, Text, Title } = Typography
const DEFAULT_CAPABILITIES: AuthCapabilities = {
password: true,
email_activation: false,
email_code: false,
sms_code: false,
password_reset: false,
admin_bootstrap_required: false,
oauth_providers: [],
}
type ResendFormValues = {
email: string
}
export function ActivateAccountPage() {
const [form] = Form.useForm<ResendFormValues>()
const [searchParams] = useSearchParams()
const token = searchParams.get('token')?.trim() ?? ''
const initialEmail = searchParams.get('email')?.trim() ?? ''
const [loading, setLoading] = useState(Boolean(token))
const [capabilities, setCapabilities] = useState<AuthCapabilities>(DEFAULT_CAPABILITIES)
const [capabilitiesLoaded, setCapabilitiesLoaded] = useState(false)
const [activated, setActivated] = useState(false)
const [activationError, setActivationError] = useState('')
const [resending, setResending] = useState(false)
const [resentEmail, setResentEmail] = useState<string | null>(null)
const activatedTokenRef = useRef<string | null>(null)
const activationRequestRef = useRef<Promise<void> | null>(null)
useEffect(() => {
let cancelled = false
const loadCapabilities = async () => {
try {
const result = await getAuthCapabilities()
if (!cancelled) {
setCapabilities(result)
}
} catch {
if (!cancelled) {
setCapabilities(DEFAULT_CAPABILITIES)
}
} finally {
if (!cancelled) {
setCapabilitiesLoaded(true)
}
}
}
void loadCapabilities()
return () => {
cancelled = true
}
}, [])
useEffect(() => {
if (!token) {
setLoading(false)
return
}
if (activatedTokenRef.current === token && activationRequestRef.current) {
return
}
activatedTokenRef.current = token
setActivationError('')
setActivated(false)
setLoading(true)
const runActivation = (async () => {
try {
await activateEmail(token)
setActivated(true)
} catch (error) {
setActivationError(getErrorMessage(error, '激活失败,请稍后重试'))
} finally {
setLoading(false)
}
})()
activationRequestRef.current = runActivation
void runActivation
}, [token])
const handleResend = useCallback(async (values: ResendFormValues) => {
const email = values.email.trim()
setResending(true)
try {
const result = await resendActivationEmail({ email })
setResentEmail(email)
message.success(result.message || '激活邮件已发送,请检查邮箱')
} catch (error) {
message.error(getErrorMessage(error, '激活邮件发送失败,请稍后重试'))
} finally {
setResending(false)
}
}, [])
if (loading) {
return (
<AuthLayout>
<div style={{ textAlign: 'center', padding: '48px 0' }}>
<Spin size="large" />
<Paragraph type="secondary" style={{ marginTop: 16 }}>
...
</Paragraph>
</div>
</AuthLayout>
)
}
if (activated) {
return (
<AuthLayout>
<Result
status="success"
title="邮箱验证成功"
subTitle="您的账号已激活,现在可以返回登录页面正常使用。"
extra={[
<Link key="login" to="/login">
<Button type="primary"></Button>
</Link>,
]}
/>
</AuthLayout>
)
}
const emailActivationEnabled = capabilities.email_activation
const resultStatus = token
? 'error'
: capabilitiesLoaded && !emailActivationEnabled
? 'warning'
: 'info'
const resultTitle = token
? '激活链接不可用'
: capabilitiesLoaded && !emailActivationEnabled
? '邮箱激活未启用'
: '重新发送激活邮件'
const resultSubtitle = token
? activationError || '当前激活链接无效、已过期或已被使用。'
: capabilitiesLoaded && !emailActivationEnabled
? '当前环境未配置可用的邮件投递能力,无法重新发送激活邮件。'
: '请输入注册邮箱,我们会重新发送激活链接。'
return (
<AuthLayout>
<Result
status={resultStatus}
title={resultTitle}
subTitle={resultSubtitle}
extra={[
<Link key="login" to="/login">
<Button type="primary"></Button>
</Link>,
<Link key="register" to="/register">
<Button></Button>
</Link>,
]}
/>
{emailActivationEnabled && (
<div style={{ maxWidth: 420, margin: '0 auto' }}>
<Title level={5} style={{ marginBottom: 8 }}>
</Title>
<Paragraph type="secondary" style={{ marginBottom: 24 }}>
</Paragraph>
{resentEmail && (
<Paragraph style={{ marginBottom: 16 }}>
<Text strong>{resentEmail}</Text>
</Paragraph>
)}
<Form<ResendFormValues>
form={form}
layout="vertical"
initialValues={{ email: initialEmail }}
onFinish={handleResend}
autoComplete="off"
>
<Form.Item
name="email"
rules={[
{ required: true, message: '请输入邮箱地址' },
{ type: 'email', message: '请输入有效的邮箱地址' },
]}
>
<Input
prefix={<MailOutlined />}
placeholder="邮箱地址"
size="large"
autoComplete="email"
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" size="large" block loading={resending}>
</Button>
</Form.Item>
</Form>
<div style={{ textAlign: 'center', marginTop: 16 }}>
<Link to="/login">
<Text type="secondary">
<ArrowLeftOutlined style={{ marginRight: 4 }} />
</Text>
</Link>
</div>
</div>
)}
</AuthLayout>
)
}

View File

@@ -0,0 +1 @@
export { ActivateAccountPage } from './ActivateAccountPage'

View File

@@ -0,0 +1,129 @@
import { MemoryRouter } from 'react-router-dom'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AuthContext, type AuthContextValue } from '@/app/providers/auth-context'
import type { AuthCapabilities, TokenBundle } from '@/types'
import { BootstrapAdminPage } from './BootstrapAdminPage'
const getAuthCapabilitiesMock = vi.fn<() => Promise<AuthCapabilities>>()
const bootstrapAdminMock = vi.fn<(payload: unknown) => Promise<TokenBundle>>()
const onLoginSuccessMock = vi.fn<(tokenBundle: TokenBundle) => Promise<void>>()
vi.mock('@/services/auth', () => ({
getAuthCapabilities: () => getAuthCapabilitiesMock(),
bootstrapAdmin: (payload: unknown) => bootstrapAdminMock(payload),
}))
const authContextValue: AuthContextValue = {
user: null,
roles: [],
isAdmin: false,
isAuthenticated: false,
isLoading: false,
onLoginSuccess: (tokenBundle) => onLoginSuccessMock(tokenBundle),
logout: vi.fn(async () => {}),
refreshUser: vi.fn(async () => {}),
}
function renderBootstrapAdminPage() {
return render(
<MemoryRouter initialEntries={['/bootstrap-admin']}>
<AuthContext.Provider value={authContextValue}>
<BootstrapAdminPage />
</AuthContext.Provider>
</MemoryRouter>,
)
}
describe('BootstrapAdminPage', () => {
beforeEach(() => {
getAuthCapabilitiesMock.mockReset()
bootstrapAdminMock.mockReset()
onLoginSuccessMock.mockReset()
getAuthCapabilitiesMock.mockResolvedValue({
password: true,
email_activation: false,
email_code: false,
sms_code: false,
password_reset: false,
admin_bootstrap_required: true,
oauth_providers: [],
})
bootstrapAdminMock.mockResolvedValue({
access_token: 'access-token',
refresh_token: 'refresh-token',
expires_in: 7200,
user: {
id: 1,
username: 'bootstrap_admin',
email: 'bootstrap_admin@example.com',
phone: '',
nickname: 'Bootstrap Admin',
avatar: '',
status: 1,
},
})
onLoginSuccessMock.mockResolvedValue(undefined)
})
it('renders the first-admin bootstrap form when the system has no active admin', async () => {
renderBootstrapAdminPage()
await waitFor(() => expect(getAuthCapabilitiesMock).toHaveBeenCalledTimes(1))
expect(screen.getByRole('heading', { name: '初始化首个管理员账号' })).toBeInTheDocument()
expect(screen.getByPlaceholderText('管理员用户名')).toBeInTheDocument()
expect(screen.getByPlaceholderText('管理员密码')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '完成初始化并进入系统' })).toBeInTheDocument()
})
it('submits the bootstrap request and hands the created session to the auth provider', async () => {
const user = userEvent.setup()
renderBootstrapAdminPage()
await waitFor(() => expect(screen.getByPlaceholderText('管理员用户名')).toBeInTheDocument())
await user.type(screen.getByPlaceholderText('管理员用户名'), 'bootstrap_admin')
await user.type(screen.getByPlaceholderText('管理员昵称(选填)'), 'Bootstrap Admin')
await user.type(screen.getByPlaceholderText('管理员邮箱(选填)'), 'bootstrap_admin@example.com')
await user.type(screen.getByPlaceholderText('管理员密码'), 'Bootstrap123!@#')
await user.type(screen.getByPlaceholderText('确认管理员密码'), 'Bootstrap123!@#')
await user.click(screen.getByRole('button', { name: '完成初始化并进入系统' }))
await waitFor(() =>
expect(bootstrapAdminMock).toHaveBeenCalledWith({
username: 'bootstrap_admin',
nickname: 'Bootstrap Admin',
email: 'bootstrap_admin@example.com',
password: 'Bootstrap123!@#',
}),
)
await waitFor(() =>
expect(onLoginSuccessMock).toHaveBeenCalledWith(expect.objectContaining({
access_token: 'access-token',
refresh_token: 'refresh-token',
})),
)
})
it('shows an informational state when admin bootstrap is already closed', async () => {
getAuthCapabilitiesMock.mockResolvedValue({
password: true,
email_activation: false,
email_code: false,
sms_code: false,
password_reset: false,
admin_bootstrap_required: false,
oauth_providers: [],
})
renderBootstrapAdminPage()
expect(await screen.findByText('管理员已完成初始化')).toBeInTheDocument()
expect(screen.getByRole('link', { name: '返回登录' })).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,210 @@
import { useCallback, useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { ArrowLeftOutlined, LockOutlined, MailOutlined, UserOutlined } from '@ant-design/icons'
import { Alert, Button, Form, Input, Result, Space, Typography, message } from 'antd'
import { useAuth } from '@/app/providers/auth-context'
import { AuthLayout } from '@/layouts'
import { getErrorMessage } from '@/lib/errors'
import { bootstrapAdmin, getAuthCapabilities } from '@/services/auth'
import type { AuthCapabilities } from '@/types'
const { Paragraph, Text, Title } = Typography
const DEFAULT_CAPABILITIES: AuthCapabilities = {
password: true,
email_activation: false,
email_code: false,
sms_code: false,
password_reset: false,
admin_bootstrap_required: false,
oauth_providers: [],
}
type BootstrapAdminFormValues = {
username: string
nickname?: string
email?: string
password: string
confirmPassword: string
}
export function BootstrapAdminPage() {
const [form] = Form.useForm<BootstrapAdminFormValues>()
const [loading, setLoading] = useState(false)
const [capabilities, setCapabilities] = useState<AuthCapabilities>(DEFAULT_CAPABILITIES)
const [capabilitiesLoaded, setCapabilitiesLoaded] = useState(false)
const { onLoginSuccess } = useAuth()
const navigate = useNavigate()
useEffect(() => {
let cancelled = false
const loadCapabilities = async () => {
try {
const result = await getAuthCapabilities()
if (!cancelled) {
setCapabilities(result)
}
} catch {
if (!cancelled) {
setCapabilities(DEFAULT_CAPABILITIES)
}
} finally {
if (!cancelled) {
setCapabilitiesLoaded(true)
}
}
}
void loadCapabilities()
return () => {
cancelled = true
}
}, [])
const handleSubmit = useCallback(async (values: BootstrapAdminFormValues) => {
setLoading(true)
try {
const tokenBundle = await bootstrapAdmin({
username: values.username.trim(),
nickname: values.nickname?.trim() || undefined,
email: values.email?.trim() || undefined,
password: values.password,
})
await onLoginSuccess(tokenBundle)
message.success('管理员初始化完成')
navigate('/dashboard', { replace: true })
} catch (error) {
message.error(getErrorMessage(error, '管理员初始化失败,请检查输入信息后重试'))
} finally {
setLoading(false)
}
}, [navigate, onLoginSuccess])
if (capabilitiesLoaded && !capabilities.admin_bootstrap_required) {
return (
<AuthLayout>
<Result
status="info"
title="管理员已完成初始化"
subTitle="系统已经存在可登录的管理员账号。请直接返回登录页,使用现有管理员账号进入系统。"
extra={[
<Link key="login" to="/login">
<Button type="primary"></Button>
</Link>,
<Link key="register" to="/register">
<Button></Button>
</Link>,
]}
/>
</AuthLayout>
)
}
return (
<AuthLayout>
<Title level={3} style={{ marginBottom: 8 }}>
</Title>
<Paragraph type="secondary" style={{ marginBottom: 24 }}>
</Paragraph>
<Alert
type="warning"
showIcon
message="该入口仅在系统没有可登录管理员时开放"
description="初始化成功后,你会直接进入后台。后续管理员新增、禁用和权限调整应在系统内完成。"
style={{ marginBottom: 24 }}
/>
<Form<BootstrapAdminFormValues> form={form} layout="vertical" onFinish={handleSubmit} autoComplete="off">
<Form.Item
name="username"
rules={[{ required: true, message: '请输入管理员用户名' }]}
>
<Input
prefix={<UserOutlined />}
placeholder="管理员用户名"
size="large"
autoComplete="username"
/>
</Form.Item>
<Form.Item name="nickname">
<Input
prefix={<UserOutlined />}
placeholder="管理员昵称(选填)"
size="large"
autoComplete="nickname"
/>
</Form.Item>
<Form.Item
name="email"
rules={[{ type: 'email', message: '请输入有效的邮箱地址' }]}
>
<Input
prefix={<MailOutlined />}
placeholder="管理员邮箱(选填)"
size="large"
autoComplete="email"
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入管理员密码' }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="管理员密码"
size="large"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item
name="confirmPassword"
dependencies={['password']}
rules={[
{ required: true, message: '请再次输入管理员密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve()
}
return Promise.reject(new Error('两次输入的密码不一致'))
},
}),
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="确认管理员密码"
size="large"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" size="large" block loading={loading}>
</Button>
</Form.Item>
</Form>
<div style={{ textAlign: 'center', marginTop: 16 }}>
<Space split={<Text type="secondary">|</Text>}>
<Link to="/login">
<Text type="secondary">
<ArrowLeftOutlined style={{ marginRight: 4 }} />
</Text>
</Link>
<Link to="/register">
<Text type="secondary"></Text>
</Link>
</Space>
</div>
</AuthLayout>
)
}

View File

@@ -0,0 +1 @@
export { BootstrapAdminPage } from './BootstrapAdminPage'

View File

@@ -0,0 +1,115 @@
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { message } from 'antd'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AuthCapabilities } from '@/types'
import { ForgotPasswordPage } from './ForgotPasswordPage'
const getAuthCapabilitiesMock = vi.fn<() => Promise<AuthCapabilities>>()
const forgotPasswordMock = vi.fn<(payload: { email: string }) => Promise<void>>()
vi.mock('@/services/auth', () => ({
getAuthCapabilities: () => getAuthCapabilitiesMock(),
forgotPassword: (payload: { email: string }) => forgotPasswordMock(payload),
}))
function renderForgotPasswordPage() {
return render(
<MemoryRouter initialEntries={['/forgot-password']}>
<Routes>
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
</Routes>
</MemoryRouter>,
)
}
describe('ForgotPasswordPage', () => {
beforeEach(() => {
getAuthCapabilitiesMock.mockReset()
forgotPasswordMock.mockReset()
getAuthCapabilitiesMock.mockResolvedValue({
password: true,
email_activation: false,
email_code: false,
sms_code: false,
password_reset: true,
admin_bootstrap_required: false,
oauth_providers: [],
})
forgotPasswordMock.mockResolvedValue(undefined)
vi.spyOn(message, 'success').mockImplementation(() => undefined as never)
vi.spyOn(message, 'error').mockImplementation(() => undefined as never)
})
it('renders the reset request form when password reset is enabled', async () => {
renderForgotPasswordPage()
await waitFor(() => expect(getAuthCapabilitiesMock).toHaveBeenCalledTimes(1))
expect(screen.getByRole('heading', { name: '忘记密码' })).toBeInTheDocument()
expect(screen.getByPlaceholderText('邮箱地址')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '发送重置链接' })).toBeInTheDocument()
})
it('shows a warning state when password reset is disabled', async () => {
getAuthCapabilitiesMock.mockResolvedValue({
password: true,
email_activation: false,
email_code: false,
sms_code: false,
password_reset: false,
admin_bootstrap_required: false,
oauth_providers: [],
})
renderForgotPasswordPage()
expect(await screen.findByText('密码重置未启用')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '返回登录' })).toBeInTheDocument()
})
it('falls back to the warning state when capability loading fails', async () => {
getAuthCapabilitiesMock.mockRejectedValueOnce(new Error('capabilities failed'))
renderForgotPasswordPage()
expect(await screen.findByText('密码重置未启用')).toBeInTheDocument()
expect(screen.queryByPlaceholderText('邮箱地址')).not.toBeInTheDocument()
})
it('submits the forgot-password request and shows the success summary', async () => {
const user = userEvent.setup()
renderForgotPasswordPage()
await waitFor(() => expect(screen.getByPlaceholderText('邮箱地址')).toBeInTheDocument())
await user.type(screen.getByPlaceholderText('邮箱地址'), 'user@example.com')
await user.click(screen.getByRole('button', { name: '发送重置链接' }))
await waitFor(() =>
expect(forgotPasswordMock).toHaveBeenCalledWith({ email: 'user@example.com' }),
)
expect(await screen.findByText('邮件已发送')).toBeInTheDocument()
expect(screen.getByText('user@example.com')).toBeInTheDocument()
expect(message.success).toHaveBeenCalledWith('重置邮件已发送')
})
it('surfaces backend failures when sending the reset email', async () => {
const user = userEvent.setup()
forgotPasswordMock.mockRejectedValueOnce(new Error('send failed'))
renderForgotPasswordPage()
await waitFor(() => expect(screen.getByPlaceholderText('邮箱地址')).toBeInTheDocument())
await user.type(screen.getByPlaceholderText('邮箱地址'), 'user@example.com')
await user.click(screen.getByRole('button', { name: '发送重置链接' }))
await waitFor(() => expect(message.error).toHaveBeenCalledWith('send failed'))
})
})

View File

@@ -0,0 +1,138 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { ArrowLeftOutlined, MailOutlined } from '@ant-design/icons'
import { Button, Form, Input, Result, Typography, message } from 'antd'
import { AuthLayout } from '@/layouts'
import { getErrorMessage } from '@/lib/errors'
import { forgotPassword, getAuthCapabilities } from '@/services/auth'
const { Paragraph, Text, Title } = Typography
type ForgotPasswordFormValues = {
email: string
}
export function ForgotPasswordPage() {
const [loading, setLoading] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [submittedEmail, setSubmittedEmail] = useState('')
const [passwordResetEnabled, setPasswordResetEnabled] = useState(false)
const [capabilitiesLoaded, setCapabilitiesLoaded] = useState(false)
useEffect(() => {
let cancelled = false
const loadCapabilities = async () => {
try {
const result = await getAuthCapabilities()
if (!cancelled) {
setPasswordResetEnabled(result.password_reset)
}
} catch {
if (!cancelled) {
setPasswordResetEnabled(false)
}
} finally {
if (!cancelled) {
setCapabilitiesLoaded(true)
}
}
}
void loadCapabilities()
return () => {
cancelled = true
}
}, [])
const handleSubmit = useCallback(async (values: ForgotPasswordFormValues) => {
setLoading(true)
try {
await forgotPassword({ email: values.email })
setSubmittedEmail(values.email)
setSubmitted(true)
message.success('重置邮件已发送')
} catch (error) {
message.error(getErrorMessage(error, '发送失败,请稍后重试'))
} finally {
setLoading(false)
}
}, [])
if (capabilitiesLoaded && !passwordResetEnabled) {
return (
<AuthLayout>
<Result
status="warning"
title="密码重置未启用"
subTitle="当前运行环境未配置可用的邮件发送能力,密码重置入口已关闭。"
extra={[
<Link key="login" to="/login">
<Button type="primary"></Button>
</Link>,
]}
/>
</AuthLayout>
)
}
if (submitted) {
return (
<AuthLayout>
<Result
status="success"
title="邮件已发送"
subTitle={(
<Paragraph>
<Text strong>{submittedEmail}</Text>
</Paragraph>
)}
extra={[
<Link key="login" to="/login">
<Button type="primary"></Button>
</Link>,
]}
/>
</AuthLayout>
)
}
return (
<AuthLayout>
<Title level={3} style={{ marginBottom: 8 }}>
</Title>
<Paragraph type="secondary" style={{ marginBottom: 24 }}>
</Paragraph>
<Form<ForgotPasswordFormValues> layout="vertical" onFinish={handleSubmit} autoComplete="off">
<Form.Item
name="email"
rules={[
{ required: true, message: '请输入邮箱地址' },
{ type: 'email', message: '请输入有效的邮箱地址' },
]}
>
<Input prefix={<MailOutlined />} placeholder="邮箱地址" size="large" autoComplete="email" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" size="large" block loading={loading}>
</Button>
</Form.Item>
</Form>
<div style={{ textAlign: 'center', marginTop: 16 }}>
<Link to="/login">
<Text type="secondary">
<ArrowLeftOutlined style={{ marginRight: 4 }} />
</Text>
</Link>
</div>
</AuthLayout>
)
}

View File

@@ -0,0 +1 @@
export { ForgotPasswordPage } from './ForgotPasswordPage'

View File

@@ -0,0 +1,586 @@
import { MemoryRouter } from 'react-router-dom'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { message } from 'antd'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AuthCapabilities, TokenBundle } from '@/types'
import { AuthContext, type AuthContextValue } from '@/app/providers/auth-context'
import { LoginPage } from './LoginPage'
const TEXT = {
usernamePlaceholder: '用户名',
passwordPlaceholder: '密码',
passwordLoginTab: '密码登录',
emailCodeTab: '邮箱验证码',
smsCodeTab: '短信验证码',
forgotPassword: '忘记密码?',
resendActivation: '重新发送激活邮件',
createAccount: '创建账号',
oauthLogin: '第三方登录',
useGitHub: '使用 GitHub 登录',
useWeChat: '使用 微信 登录',
adminBootstrapTitle: '系统尚未初始化首个管理员账号',
adminBootstrapDescription: '当前版本不提供默认账号。请先通过部署初始化流程或管理员初始化工具创建管理员,再使用登录页进入系统。',
bootstrapAdminAction: '初始化管理员',
}
const navigateMock = vi.fn()
const assignMock = vi.fn()
const getAuthCapabilitiesMock = vi.fn<() => Promise<AuthCapabilities>>()
const getOAuthAuthorizationUrlMock = vi.fn()
const loginByPasswordMock = vi.fn()
const loginByEmailCodeMock = vi.fn()
const loginBySmsCodeMock = vi.fn()
const sendEmailCodeMock = vi.fn()
const sendSmsCodeMock = vi.fn()
const onLoginSuccessMock = vi.fn()
const defaultCapabilities: AuthCapabilities = {
password: true,
email_activation: false,
email_code: false,
sms_code: false,
password_reset: false,
admin_bootstrap_required: false,
oauth_providers: [],
}
const loginTokenBundle: TokenBundle = {
access_token: 'access-token',
refresh_token: 'refresh-token',
expires_in: 7200,
user: {
id: 1,
username: 'admin',
email: 'admin@example.com',
phone: '13800138000',
nickname: 'Admin',
avatar: '',
status: 1,
},
}
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom')
return {
...actual,
useNavigate: () => navigateMock,
}
})
vi.mock('@/services/auth', () => ({
getAuthCapabilities: () => getAuthCapabilitiesMock(),
getOAuthAuthorizationUrl: (provider: string, returnTo: string) =>
getOAuthAuthorizationUrlMock(provider, returnTo),
loginByPassword: (payload: unknown) => loginByPasswordMock(payload),
loginByEmailCode: (payload: unknown) => loginByEmailCodeMock(payload),
loginBySmsCode: (payload: unknown) => loginBySmsCodeMock(payload),
sendEmailCode: (payload: unknown) => sendEmailCodeMock(payload),
sendSmsCode: (payload: unknown) => sendSmsCodeMock(payload),
}))
const authContextValue: AuthContextValue = {
user: null,
roles: [],
isAdmin: false,
isAuthenticated: false,
isLoading: false,
onLoginSuccess: async (tokenBundle) => onLoginSuccessMock(tokenBundle),
logout: vi.fn(async () => {}),
refreshUser: vi.fn(async () => {}),
}
function renderLoginPage(
initialEntry:
| string
| {
pathname: string
search?: string
state?: unknown
} = '/login',
) {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<AuthContext.Provider value={authContextValue}>
<LoginPage />
</AuthContext.Provider>
</MemoryRouter>,
)
}
async function openEmailTab() {
fireEvent.click(screen.getByRole('tab', { name: TEXT.emailCodeTab }))
await waitFor(() => expect(screen.getByRole('tabpanel')).toBeInTheDocument())
return screen.getByRole('tabpanel')
}
async function openSmsTab() {
fireEvent.click(screen.getByRole('tab', { name: TEXT.smsCodeTab }))
await waitFor(() => expect(screen.getByRole('tabpanel')).toBeInTheDocument())
return screen.getByRole('tabpanel')
}
describe('LoginPage', () => {
beforeEach(() => {
navigateMock.mockReset()
assignMock.mockReset()
getAuthCapabilitiesMock.mockReset()
getOAuthAuthorizationUrlMock.mockReset()
loginByPasswordMock.mockReset()
loginByEmailCodeMock.mockReset()
loginBySmsCodeMock.mockReset()
sendEmailCodeMock.mockReset()
sendSmsCodeMock.mockReset()
onLoginSuccessMock.mockReset()
getAuthCapabilitiesMock.mockResolvedValue(defaultCapabilities)
getOAuthAuthorizationUrlMock.mockResolvedValue({
auth_url: 'https://oauth.example.com/default',
state: 'oauth-state',
})
onLoginSuccessMock.mockResolvedValue(undefined)
vi.spyOn(message, 'success').mockImplementation(() => undefined as never)
vi.spyOn(message, 'error').mockImplementation(() => undefined as never)
Object.defineProperty(window, 'location', {
configurable: true,
value: {
...window.location,
assign: assignMock,
},
})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('renders password login only when email, sms, reset, and activation are disabled', async () => {
renderLoginPage()
await waitFor(() => expect(getAuthCapabilitiesMock).toHaveBeenCalledTimes(1))
expect(screen.getByPlaceholderText(TEXT.usernamePlaceholder)).toBeInTheDocument()
expect(screen.getByPlaceholderText(TEXT.passwordPlaceholder)).toBeInTheDocument()
expect(screen.queryByText(TEXT.emailCodeTab)).not.toBeInTheDocument()
expect(screen.queryByText(TEXT.smsCodeTab)).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: TEXT.forgotPassword })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: TEXT.resendActivation })).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: TEXT.createAccount })).toBeInTheDocument()
})
it('falls back to default capabilities when loading auth capabilities fails', async () => {
getAuthCapabilitiesMock.mockRejectedValue(new Error('capabilities unavailable'))
renderLoginPage()
await waitFor(() => expect(getAuthCapabilitiesMock).toHaveBeenCalledTimes(1))
expect(screen.getByPlaceholderText(TEXT.usernamePlaceholder)).toBeInTheDocument()
expect(screen.getByPlaceholderText(TEXT.passwordPlaceholder)).toBeInTheDocument()
expect(screen.queryByText(TEXT.emailCodeTab)).not.toBeInTheDocument()
expect(screen.queryByText(TEXT.smsCodeTab)).not.toBeInTheDocument()
expect(screen.queryByText(TEXT.oauthLogin)).not.toBeInTheDocument()
})
it('shows enabled login methods and recovery entries from auth capabilities', async () => {
getAuthCapabilitiesMock.mockResolvedValue({
...defaultCapabilities,
email_activation: true,
email_code: true,
sms_code: true,
password_reset: true,
})
renderLoginPage()
await waitFor(() => expect(screen.getByText(TEXT.emailCodeTab)).toBeInTheDocument())
expect(screen.getByText(TEXT.passwordLoginTab)).toBeInTheDocument()
expect(screen.getByText(TEXT.emailCodeTab)).toBeInTheDocument()
expect(screen.getByText(TEXT.smsCodeTab)).toBeInTheDocument()
expect(screen.getByRole('link', { name: TEXT.forgotPassword })).toBeInTheDocument()
expect(screen.getByRole('link', { name: TEXT.resendActivation })).toBeInTheDocument()
expect(screen.getByRole('link', { name: TEXT.createAccount })).toBeInTheDocument()
})
it('submits password login and navigates to the saved route after success', async () => {
loginByPasswordMock.mockResolvedValue(loginTokenBundle)
renderLoginPage({
pathname: '/login',
state: { from: { pathname: '/profile' } },
})
await waitFor(() => expect(getAuthCapabilitiesMock).toHaveBeenCalledTimes(1))
fireEvent.change(screen.getByPlaceholderText(TEXT.usernamePlaceholder), {
target: { value: 'admin' },
})
fireEvent.change(screen.getByPlaceholderText(TEXT.passwordPlaceholder), {
target: { value: 'SecurePass123!' },
})
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(loginByPasswordMock).toHaveBeenCalledWith(expect.objectContaining({
username: 'admin',
password: 'SecurePass123!',
device_id: expect.any(String),
device_name: expect.any(String),
device_browser: expect.any(String),
device_os: expect.any(String),
}))
})
expect(onLoginSuccessMock).toHaveBeenCalledWith(loginTokenBundle)
expect(message.success).toHaveBeenCalledTimes(1)
expect(navigateMock).toHaveBeenCalledWith('/profile', { replace: true })
})
it('uses the safe default redirect when the login query contains an unsafe target', async () => {
loginByPasswordMock.mockResolvedValue(loginTokenBundle)
renderLoginPage('/login?redirect=https://evil.example.com/phish')
await waitFor(() => expect(getAuthCapabilitiesMock).toHaveBeenCalledTimes(1))
fireEvent.change(screen.getByPlaceholderText(TEXT.usernamePlaceholder), {
target: { value: 'admin' },
})
fireEvent.change(screen.getByPlaceholderText(TEXT.passwordPlaceholder), {
target: { value: 'SecurePass123!' },
})
fireEvent.click(screen.getByRole('button'))
await waitFor(() => expect(onLoginSuccessMock).toHaveBeenCalledWith(loginTokenBundle))
expect(navigateMock).toHaveBeenCalledWith('/dashboard', { replace: true })
})
it('surfaces password login failures from the backend', async () => {
loginByPasswordMock.mockRejectedValue(new Error('invalid credentials'))
renderLoginPage()
await waitFor(() => expect(getAuthCapabilitiesMock).toHaveBeenCalledTimes(1))
fireEvent.change(screen.getByPlaceholderText(TEXT.usernamePlaceholder), {
target: { value: 'admin' },
})
fireEvent.change(screen.getByPlaceholderText(TEXT.passwordPlaceholder), {
target: { value: 'wrong-pass' },
})
fireEvent.click(screen.getByRole('button'))
await waitFor(() => expect(message.error).toHaveBeenCalledWith('invalid credentials'))
expect(onLoginSuccessMock).not.toHaveBeenCalled()
expect(navigateMock).not.toHaveBeenCalled()
})
it('sends an email verification code and starts the resend countdown', async () => {
getAuthCapabilitiesMock.mockResolvedValue({
...defaultCapabilities,
email_code: true,
})
sendEmailCodeMock.mockResolvedValue(undefined)
renderLoginPage()
await waitFor(() => expect(screen.getByRole('tab', { name: TEXT.emailCodeTab })).toBeInTheDocument())
const panel = await openEmailTab()
const [emailInput] = within(panel).getAllByRole('textbox')
const [sendCodeButton] = within(panel).getAllByRole('button')
fireEvent.change(emailInput, { target: { value: 'admin@example.com' } })
fireEvent.click(sendCodeButton)
await waitFor(() => {
expect(sendEmailCodeMock).toHaveBeenCalledWith({ email: 'admin@example.com' })
})
expect(message.success).toHaveBeenCalledTimes(1)
expect(within(screen.getByRole('tabpanel')).getAllByRole('button')[0]).toHaveTextContent('60s')
})
it('ignores validation errors when the email address is missing', async () => {
getAuthCapabilitiesMock.mockResolvedValue({
...defaultCapabilities,
email_code: true,
})
renderLoginPage()
await waitFor(() => expect(screen.getByRole('tab', { name: TEXT.emailCodeTab })).toBeInTheDocument())
const panel = await openEmailTab()
const [sendCodeButton] = within(panel).getAllByRole('button')
fireEvent.click(sendCodeButton)
await waitFor(() => expect(sendEmailCodeMock).not.toHaveBeenCalled())
expect(message.error).not.toHaveBeenCalled()
expect(within(screen.getByRole('tabpanel')).getAllByRole('button')[0].textContent).not.toContain('60s')
})
it('submits email code login and uses the redirect query after success', async () => {
getAuthCapabilitiesMock.mockResolvedValue({
...defaultCapabilities,
email_code: true,
})
loginByEmailCodeMock.mockResolvedValue(loginTokenBundle)
renderLoginPage('/login?redirect=/profile')
await waitFor(() => expect(screen.getByRole('tab', { name: TEXT.emailCodeTab })).toBeInTheDocument())
const panel = await openEmailTab()
const [emailInput, codeInput] = within(panel).getAllByRole('textbox')
const [, submitButton] = within(panel).getAllByRole('button')
fireEvent.change(emailInput, { target: { value: 'admin@example.com' } })
fireEvent.change(codeInput, { target: { value: '123456' } })
fireEvent.click(submitButton)
await waitFor(() => {
expect(loginByEmailCodeMock).toHaveBeenCalledWith({
email: 'admin@example.com',
code: '123456',
})
})
expect(onLoginSuccessMock).toHaveBeenCalledWith(loginTokenBundle)
expect(navigateMock).toHaveBeenCalledWith('/profile', { replace: true })
})
it('surfaces email code login failures from the backend', async () => {
getAuthCapabilitiesMock.mockResolvedValue({
...defaultCapabilities,
email_code: true,
})
loginByEmailCodeMock.mockRejectedValue(new Error('invalid email code'))
renderLoginPage()
await waitFor(() => expect(screen.getByRole('tab', { name: TEXT.emailCodeTab })).toBeInTheDocument())
const panel = await openEmailTab()
const [emailInput, codeInput] = within(panel).getAllByRole('textbox')
const [, submitButton] = within(panel).getAllByRole('button')
fireEvent.change(emailInput, { target: { value: 'admin@example.com' } })
fireEvent.change(codeInput, { target: { value: '654321' } })
fireEvent.click(submitButton)
await waitFor(() => expect(message.error).toHaveBeenCalledWith('invalid email code'))
expect(onLoginSuccessMock).not.toHaveBeenCalled()
})
it('sends an sms verification code with the login purpose and starts the resend countdown', async () => {
getAuthCapabilitiesMock.mockResolvedValue({
...defaultCapabilities,
sms_code: true,
})
sendSmsCodeMock.mockResolvedValue(undefined)
renderLoginPage()
await waitFor(() => expect(screen.getByRole('tab', { name: TEXT.smsCodeTab })).toBeInTheDocument())
const panel = await openSmsTab()
const [phoneInput] = within(panel).getAllByRole('textbox')
const [sendCodeButton] = within(panel).getAllByRole('button')
fireEvent.change(phoneInput, { target: { value: '13800138000' } })
fireEvent.click(sendCodeButton)
await waitFor(() => {
expect(sendSmsCodeMock).toHaveBeenCalledWith({
phone: '13800138000',
purpose: 'login',
})
})
expect(message.success).toHaveBeenCalledTimes(1)
expect(within(screen.getByRole('tabpanel')).getAllByRole('button')[0]).toHaveTextContent('60s')
})
it('surfaces sms code send failures and resets the countdown state', async () => {
getAuthCapabilitiesMock.mockResolvedValue({
...defaultCapabilities,
sms_code: true,
})
sendSmsCodeMock.mockRejectedValue(new Error('sms send failed'))
renderLoginPage()
await waitFor(() => expect(screen.getByRole('tab', { name: TEXT.smsCodeTab })).toBeInTheDocument())
const panel = await openSmsTab()
const [phoneInput] = within(panel).getAllByRole('textbox')
const [sendCodeButton] = within(panel).getAllByRole('button')
fireEvent.change(phoneInput, { target: { value: '13800138000' } })
fireEvent.click(sendCodeButton)
await waitFor(() => expect(message.error).toHaveBeenCalledWith('sms send failed'))
expect(within(screen.getByRole('tabpanel')).getAllByRole('button')[0].textContent).not.toContain('60s')
})
it('submits sms code login and completes the login flow after success', async () => {
getAuthCapabilitiesMock.mockResolvedValue({
...defaultCapabilities,
sms_code: true,
})
loginBySmsCodeMock.mockResolvedValue(loginTokenBundle)
renderLoginPage()
await waitFor(() => expect(screen.getByRole('tab', { name: TEXT.smsCodeTab })).toBeInTheDocument())
const panel = await openSmsTab()
const [phoneInput, codeInput] = within(panel).getAllByRole('textbox')
const [, submitButton] = within(panel).getAllByRole('button')
fireEvent.change(phoneInput, { target: { value: '13800138000' } })
fireEvent.change(codeInput, { target: { value: '123456' } })
fireEvent.click(submitButton)
await waitFor(() => {
expect(loginBySmsCodeMock).toHaveBeenCalledWith({
phone: '13800138000',
code: '123456',
})
})
expect(onLoginSuccessMock).toHaveBeenCalledWith(loginTokenBundle)
expect(navigateMock).toHaveBeenCalledWith('/dashboard', { replace: true })
})
it('surfaces sms code login failures from the backend', async () => {
getAuthCapabilitiesMock.mockResolvedValue({
...defaultCapabilities,
sms_code: true,
})
loginBySmsCodeMock.mockRejectedValue(new Error('invalid sms code'))
renderLoginPage()
await waitFor(() => expect(screen.getByRole('tab', { name: TEXT.smsCodeTab })).toBeInTheDocument())
const panel = await openSmsTab()
const [phoneInput, codeInput] = within(panel).getAllByRole('textbox')
const [, submitButton] = within(panel).getAllByRole('button')
fireEvent.change(phoneInput, { target: { value: '13800138000' } })
fireEvent.change(codeInput, { target: { value: '654321' } })
fireEvent.click(submitButton)
await waitFor(() => expect(message.error).toHaveBeenCalledWith('invalid sms code'))
expect(onLoginSuccessMock).not.toHaveBeenCalled()
})
it('renders oauth login actions when oauth providers are available', async () => {
getAuthCapabilitiesMock.mockResolvedValue({
...defaultCapabilities,
oauth_providers: [
{ provider: 'github', name: 'GitHub' },
{ provider: 'wechat', name: '微信' },
],
})
renderLoginPage()
await waitFor(() => expect(screen.getByText(TEXT.oauthLogin)).toBeInTheDocument())
expect(screen.getByRole('button', { name: TEXT.useGitHub })).toBeInTheDocument()
expect(screen.getByRole('button', { name: TEXT.useWeChat })).toBeInTheDocument()
})
it('starts oauth login with a sanitized callback return target and filters disabled providers', async () => {
getAuthCapabilitiesMock.mockResolvedValue({
...defaultCapabilities,
oauth_providers: [
{ provider: 'wechat', name: '微信', enabled: false },
{ provider: 'github', name: 'GitHub' },
],
})
getOAuthAuthorizationUrlMock.mockResolvedValue({
auth_url: 'https://oauth.example.com/github',
state: 'oauth-state',
})
renderLoginPage('/login?redirect=https://evil.example.com/phish')
await waitFor(() => expect(screen.getByRole('button', { name: TEXT.useGitHub })).toBeInTheDocument())
expect(screen.queryByRole('button', { name: TEXT.useWeChat })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: TEXT.useGitHub }))
await waitFor(() => {
expect(getOAuthAuthorizationUrlMock).toHaveBeenCalledWith(
'github',
`${window.location.origin}/login/oauth/callback`,
)
})
expect(assignMock).toHaveBeenCalledWith('https://oauth.example.com/github')
})
it('surfaces oauth startup failures and clears the loading state', async () => {
getAuthCapabilitiesMock.mockResolvedValue({
...defaultCapabilities,
oauth_providers: [{ provider: 'github', name: 'GitHub' }],
})
getOAuthAuthorizationUrlMock.mockRejectedValue(new Error('oauth start failed'))
renderLoginPage('/login?redirect=/profile')
await waitFor(() => expect(screen.getByRole('button', { name: TEXT.useGitHub })).toBeInTheDocument())
const oauthButton = screen.getByRole('button', { name: TEXT.useGitHub })
fireEvent.click(oauthButton)
await waitFor(() => expect(message.error).toHaveBeenCalledWith('oauth start failed'))
expect(assignMock).not.toHaveBeenCalled()
await waitFor(() => expect(screen.getByRole('button', { name: TEXT.useGitHub })).not.toBeDisabled())
})
it('tolerates null oauth provider payloads without crashing the login page', async () => {
getAuthCapabilitiesMock.mockResolvedValue({
...defaultCapabilities,
oauth_providers: null as unknown as AuthCapabilities['oauth_providers'],
})
renderLoginPage()
await waitFor(() => expect(getAuthCapabilitiesMock).toHaveBeenCalledTimes(1))
expect(screen.getByPlaceholderText(TEXT.usernamePlaceholder)).toBeInTheDocument()
expect(screen.getByPlaceholderText(TEXT.passwordPlaceholder)).toBeInTheDocument()
expect(screen.queryByText(TEXT.oauthLogin)).not.toBeInTheDocument()
})
it('shows a first-run admin bootstrap hint when no active admin is available', async () => {
getAuthCapabilitiesMock.mockResolvedValue({
...defaultCapabilities,
admin_bootstrap_required: true,
})
renderLoginPage()
await waitFor(() => expect(screen.getByText(TEXT.adminBootstrapTitle)).toBeInTheDocument())
expect(screen.getByText(TEXT.adminBootstrapDescription)).toBeInTheDocument()
expect(screen.getByRole('button', { name: TEXT.bootstrapAdminAction }).closest('a')).toHaveAttribute('href', '/bootstrap-admin')
})
})

View File

@@ -0,0 +1,501 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { Alert, Button, Divider, Form, Input, Space, Tabs, Typography, message } from 'antd'
import {
LockOutlined,
MailOutlined,
MobileOutlined,
SafetyOutlined,
UserOutlined,
} from '@ant-design/icons'
import { useAuth } from '@/app/providers/auth-context'
import { AuthLayout } from '@/layouts'
import { buildOAuthCallbackReturnTo, sanitizeAuthRedirect } from '@/lib/auth/oauth'
import { getErrorMessage, isFormValidationError } from '@/lib/errors'
import {
getAuthCapabilities,
getOAuthAuthorizationUrl,
loginByEmailCode,
loginByPassword,
loginBySmsCode,
sendEmailCode,
sendSmsCode,
} from '@/services/auth'
import type { AuthCapabilities, TokenBundle } from '@/types'
const { Paragraph, Text, Title } = Typography
const COUNTDOWN_SECONDS = 60
const DEFAULT_CAPABILITIES: AuthCapabilities = {
password: true,
email_activation: false,
email_code: false,
sms_code: false,
password_reset: false,
admin_bootstrap_required: false,
oauth_providers: [],
}
type LoginFormValues = {
username: string
password: string
}
type EmailCodeFormValues = {
email: string
code: string
}
type SmsCodeFormValues = {
phone: string
code: string
}
// 构建设备指纹
function buildDeviceFingerprint(): { device_id: string; device_name: string; device_browser: string; device_os: string } {
const ua = navigator.userAgent
let browser = 'Unknown'
let os = 'Unknown'
if (ua.includes('Chrome')) browser = 'Chrome'
else if (ua.includes('Firefox')) browser = 'Firefox'
else if (ua.includes('Safari')) browser = 'Safari'
else if (ua.includes('Edge')) browser = 'Edge'
if (ua.includes('Windows')) os = 'Windows'
else if (ua.includes('Mac')) os = 'macOS'
else if (ua.includes('Linux')) os = 'Linux'
else if (ua.includes('Android')) os = 'Android'
else if (ua.includes('iOS')) os = 'iOS'
// 使用随机ID作为设备唯一标识
const deviceId = `${browser}-${os}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
return {
device_id: deviceId,
device_name: `${browser} on ${os}`,
device_browser: browser,
device_os: os,
}
}
export function LoginPage() {
const [activeTab, setActiveTab] = useState('password')
const [loading, setLoading] = useState(false)
const [oauthLoadingProvider, setOAuthLoadingProvider] = useState<string | null>(null)
const [emailCountdown, setEmailCountdown] = useState(0)
const [smsCountdown, setSmsCountdown] = useState(0)
const [capabilities, setCapabilities] = useState<AuthCapabilities>(DEFAULT_CAPABILITIES)
const [emailForm] = Form.useForm<EmailCodeFormValues>()
const [smsForm] = Form.useForm<SmsCodeFormValues>()
const { onLoginSuccess } = useAuth()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const location = useLocation()
const redirect = sanitizeAuthRedirect(
(location.state as { from?: { pathname: string } } | null)?.from?.pathname ||
searchParams.get('redirect'),
)
useEffect(() => {
if (emailCountdown <= 0) {
return
}
const timer = window.setTimeout(() => setEmailCountdown((current) => current - 1), 1000)
return () => window.clearTimeout(timer)
}, [emailCountdown])
useEffect(() => {
if (smsCountdown <= 0) {
return
}
const timer = window.setTimeout(() => setSmsCountdown((current) => current - 1), 1000)
return () => window.clearTimeout(timer)
}, [smsCountdown])
useEffect(() => {
let cancelled = false
const loadCapabilities = async () => {
try {
const result = await getAuthCapabilities()
if (!cancelled) {
setCapabilities(result)
}
} catch {
if (!cancelled) {
setCapabilities(DEFAULT_CAPABILITIES)
}
}
}
void loadCapabilities()
return () => {
cancelled = true
}
}, [])
useEffect(() => {
if (activeTab === 'email' && !capabilities.email_code) {
setActiveTab('password')
return
}
if (activeTab === 'sms' && !capabilities.sms_code) {
setActiveTab('password')
}
}, [activeTab, capabilities.email_code, capabilities.sms_code])
const handleLoginSuccess = useCallback(async (tokenBundle: TokenBundle) => {
await onLoginSuccess(tokenBundle)
message.success('登录成功')
navigate(redirect, { replace: true })
}, [navigate, onLoginSuccess, redirect])
const handleOAuthLogin = useCallback(async (provider: string) => {
setOAuthLoadingProvider(provider)
try {
const result = await getOAuthAuthorizationUrl(
provider,
buildOAuthCallbackReturnTo(redirect),
)
window.location.assign(result.auth_url)
} catch (error) {
message.error(getErrorMessage(error, '启动第三方登录失败'))
setOAuthLoadingProvider(null)
}
}, [redirect])
const handlePasswordLogin = useCallback(async (values: LoginFormValues) => {
setLoading(true)
try {
const deviceInfo = buildDeviceFingerprint()
// Store device info for "remember device" feature on TOTP enable
localStorage.setItem('device_fingerprint', JSON.stringify(deviceInfo))
const tokenBundle = await loginByPassword({
username: values.username,
password: values.password,
...deviceInfo,
})
await handleLoginSuccess(tokenBundle)
} catch (error) {
message.error(getErrorMessage(error, '登录失败,请检查用户名和密码'))
} finally {
setLoading(false)
}
}, [handleLoginSuccess])
const handleSendEmailCode = useCallback(async () => {
try {
const values = await emailForm.validateFields(['email'])
setEmailCountdown(COUNTDOWN_SECONDS)
await sendEmailCode({ email: values.email })
message.success('验证码已发送到邮箱')
} catch (error) {
setEmailCountdown(0)
if (isFormValidationError(error)) {
return
}
message.error(getErrorMessage(error, '发送验证码失败'))
}
}, [emailForm])
const handleEmailCodeLogin = useCallback(async (values: EmailCodeFormValues) => {
setLoading(true)
try {
const tokenBundle = await loginByEmailCode({
email: values.email,
code: values.code,
})
await handleLoginSuccess(tokenBundle)
} catch (error) {
message.error(getErrorMessage(error, '登录失败,请检查验证码'))
} finally {
setLoading(false)
}
}, [handleLoginSuccess])
const handleSendSmsCode = useCallback(async () => {
try {
const values = await smsForm.validateFields(['phone'])
setSmsCountdown(COUNTDOWN_SECONDS)
await sendSmsCode({ phone: values.phone, purpose: 'login' })
message.success('验证码已发送到手机')
} catch (error) {
setSmsCountdown(0)
if (isFormValidationError(error)) {
return
}
message.error(getErrorMessage(error, '发送验证码失败'))
}
}, [smsForm])
const handleSmsCodeLogin = useCallback(async (values: SmsCodeFormValues) => {
setLoading(true)
try {
const tokenBundle = await loginBySmsCode({
phone: values.phone,
code: values.code,
})
await handleLoginSuccess(tokenBundle)
} catch (error) {
message.error(getErrorMessage(error, '登录失败,请检查验证码'))
} finally {
setLoading(false)
}
}, [handleLoginSuccess])
const tabItems = useMemo(() => {
const items = [
{
key: 'password',
label: '密码登录',
children: (
<Form<LoginFormValues> layout="vertical" onFinish={handlePasswordLogin} autoComplete="off">
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input
prefix={<UserOutlined />}
placeholder="用户名"
size="large"
autoComplete="username"
/>
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password
prefix={<LockOutlined />}
placeholder="密码"
size="large"
autoComplete="current-password"
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" size="large" block loading={loading}>
</Button>
</Form.Item>
</Form>
),
},
]
if (capabilities.email_code) {
items.push({
key: 'email',
label: '邮箱验证码',
children: (
<Form<EmailCodeFormValues>
form={emailForm}
layout="vertical"
onFinish={handleEmailCodeLogin}
autoComplete="off"
>
<Form.Item
name="email"
rules={[
{ required: true, message: '请输入邮箱' },
{ type: 'email', message: '请输入有效的邮箱地址' },
]}
>
<Input
prefix={<MailOutlined />}
placeholder="邮箱地址"
size="large"
autoComplete="email"
/>
</Form.Item>
<Form.Item
name="code"
rules={[
{ required: true, message: '请输入验证码' },
{ len: 6, message: '验证码为 6 位数字' },
]}
>
<Space.Compact style={{ width: '100%' }}>
<Input
prefix={<SafetyOutlined />}
placeholder="验证码"
size="large"
maxLength={6}
style={{ flex: 1 }}
/>
<Button
size="large"
disabled={emailCountdown > 0}
onClick={() => void handleSendEmailCode()}
style={{ width: 120 }}
>
{emailCountdown > 0 ? `${emailCountdown}s` : '获取验证码'}
</Button>
</Space.Compact>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" size="large" block loading={loading}>
</Button>
</Form.Item>
</Form>
),
})
}
if (capabilities.sms_code) {
items.push({
key: 'sms',
label: '短信验证码',
children: (
<Form<SmsCodeFormValues>
form={smsForm}
layout="vertical"
onFinish={handleSmsCodeLogin}
autoComplete="off"
>
<Form.Item
name="phone"
rules={[
{ required: true, message: '请输入手机号' },
{ pattern: /^1\d{10}$/, message: '请输入有效的手机号' },
]}
>
<Input
prefix={<MobileOutlined />}
placeholder="手机号"
size="large"
autoComplete="tel"
/>
</Form.Item>
<Form.Item
name="code"
rules={[
{ required: true, message: '请输入验证码' },
{ len: 6, message: '验证码为 6 位数字' },
]}
>
<Space.Compact style={{ width: '100%' }}>
<Input
prefix={<SafetyOutlined />}
placeholder="验证码"
size="large"
maxLength={6}
style={{ flex: 1 }}
/>
<Button
size="large"
disabled={smsCountdown > 0}
onClick={() => void handleSendSmsCode()}
style={{ width: 120 }}
>
{smsCountdown > 0 ? `${smsCountdown}s` : '获取验证码'}
</Button>
</Space.Compact>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" size="large" block loading={loading}>
</Button>
</Form.Item>
</Form>
),
})
}
return items
}, [
capabilities.email_code,
capabilities.sms_code,
emailCountdown,
emailForm,
handleEmailCodeLogin,
handlePasswordLogin,
handleSendEmailCode,
handleSendSmsCode,
handleSmsCodeLogin,
loading,
smsCountdown,
smsForm,
])
const currentTab = tabItems.find((item) => item.key === activeTab) ?? tabItems[0]
const oauthProviders = useMemo(() => {
const providers = Array.isArray(capabilities.oauth_providers) ? capabilities.oauth_providers : []
return providers
.filter((provider) => provider.enabled !== false)
.sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
}, [capabilities.oauth_providers])
return (
<AuthLayout>
<Title level={3} style={{ marginBottom: 8 }}>
</Title>
<Paragraph type="secondary" style={{ marginBottom: 24 }}>
</Paragraph>
{capabilities.admin_bootstrap_required && (
<Alert
type="warning"
showIcon
message="系统尚未初始化首个管理员账号"
description="当前版本不提供默认账号。请先通过部署初始化流程或管理员初始化工具创建管理员,再使用登录页进入系统。"
action={(
<Link to="/bootstrap-admin">
<Button size="small" type="primary">
</Button>
</Link>
)}
style={{ marginBottom: 24 }}
/>
)}
{tabItems.length > 1 ? (
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} centered />
) : (
currentTab.children
)}
{oauthProviders.length > 0 && (
<>
<Divider plain></Divider>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{oauthProviders.map((provider) => (
<Button
key={provider.provider}
block
size="large"
onClick={() => void handleOAuthLogin(provider.provider)}
loading={oauthLoadingProvider === provider.provider}
>
使 {provider.name}
</Button>
))}
</Space>
</>
)}
<div style={{ textAlign: 'center', marginTop: 16 }}>
<Space split={<Text type="secondary">|</Text>}>
{capabilities.password_reset && (
<Link to="/forgot-password">
<Text type="secondary"></Text>
</Link>
)}
{capabilities.email_activation && (
<Link to="/activate-account">
<Text type="secondary"></Text>
</Link>
)}
<Link to="/register">
<Text type="secondary"></Text>
</Link>
</Space>
</div>
</AuthLayout>
)
}

View File

@@ -0,0 +1 @@
export { LoginPage } from './LoginPage'

View File

@@ -0,0 +1,70 @@
import { MemoryRouter } from 'react-router-dom'
import { render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TokenBundle } from '@/types'
import { AuthContext, type AuthContextValue } from '@/app/providers/auth-context'
import { OAuthCallbackPage } from './OAuthCallbackPage'
const exchangeOAuthHandoffMock = vi.fn<(code: string) => Promise<TokenBundle>>()
vi.mock('@/services/auth', () => ({
exchangeOAuthHandoff: (code: string) => exchangeOAuthHandoffMock(code),
}))
const authContextValue: AuthContextValue = {
user: null,
roles: [],
isAdmin: false,
isAuthenticated: false,
isLoading: false,
onLoginSuccess: vi.fn(async () => {}),
logout: vi.fn(async () => {}),
refreshUser: vi.fn(async () => {}),
}
function renderOAuthCallbackPage(entry: string) {
return render(
<MemoryRouter initialEntries={[entry]}>
<AuthContext.Provider value={authContextValue}>
<OAuthCallbackPage />
</AuthContext.Provider>
</MemoryRouter>,
)
}
describe('OAuthCallbackPage', () => {
beforeEach(() => {
exchangeOAuthHandoffMock.mockReset()
vi.mocked(authContextValue.onLoginSuccess).mockClear()
})
it('exchanges handoff code and completes login', async () => {
exchangeOAuthHandoffMock.mockResolvedValue({
access_token: 'access-token',
refresh_token: 'refresh-token',
expires_in: 7200,
user: {
id: 1,
username: 'oauth_user',
email: 'oauth@example.com',
phone: '',
nickname: 'OAuth User',
avatar: '',
status: 1,
},
})
renderOAuthCallbackPage('/login/oauth/callback?redirect=%2Fusers#status=success&code=handoff-code&provider=github')
await waitFor(() => expect(exchangeOAuthHandoffMock).toHaveBeenCalledWith('handoff-code'))
await waitFor(() => expect(authContextValue.onLoginSuccess).toHaveBeenCalledTimes(1))
})
it('shows error state returned from oauth callback fragment', async () => {
renderOAuthCallbackPage('/login/oauth/callback#status=error&message=OAuth%20Denied')
expect(await screen.findByText('第三方登录失败')).toBeInTheDocument()
expect(screen.getByText('OAuth Denied')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,107 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { Button, Result, Spin, Typography, message } from 'antd'
import { useAuth } from '@/app/providers/auth-context'
import { AuthLayout } from '@/layouts'
import { parseOAuthCallbackHash, sanitizeAuthRedirect } from '@/lib/auth/oauth'
import { getErrorMessage } from '@/lib/errors'
import { exchangeOAuthHandoff } from '@/services/auth'
const { Paragraph } = Typography
export function OAuthCallbackPage() {
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
const [errorMessage, setErrorMessage] = useState('')
const [searchParams] = useSearchParams()
const location = useLocation()
const navigate = useNavigate()
const { onLoginSuccess } = useAuth()
const redirect = sanitizeAuthRedirect(searchParams.get('redirect'))
const callbackPayload = useMemo(() => parseOAuthCallbackHash(location.hash), [location.hash])
useEffect(() => {
let cancelled = false
const consumeHandoff = async () => {
if (callbackPayload.status === 'error') {
if (!cancelled) {
setStatus('error')
setErrorMessage(callbackPayload.message || '第三方登录失败,请重试')
}
return
}
if (!callbackPayload.code) {
if (!cancelled) {
setStatus('error')
setErrorMessage('缺少OAuth登录交接码请重新发起登录')
}
return
}
try {
const tokenBundle = await exchangeOAuthHandoff(callbackPayload.code)
await onLoginSuccess(tokenBundle)
if (!cancelled) {
setStatus('success')
message.success('第三方登录成功')
navigate(redirect, { replace: true })
}
} catch (error) {
if (!cancelled) {
setStatus('error')
setErrorMessage(getErrorMessage(error, '第三方登录失败,请重试'))
}
}
}
void consumeHandoff()
return () => {
cancelled = true
}
}, [callbackPayload.code, callbackPayload.message, callbackPayload.status, navigate, onLoginSuccess, redirect])
if (status === 'loading') {
return (
<AuthLayout>
<div style={{ textAlign: 'center', padding: '48px 0' }}>
<Spin size="large" />
<Paragraph type="secondary" style={{ marginTop: 16 }}>
...
</Paragraph>
</div>
</AuthLayout>
)
}
if (status === 'success') {
return (
<AuthLayout>
<Result
status="success"
title="登录成功"
subTitle="正在跳转到目标页面..."
/>
</AuthLayout>
)
}
return (
<AuthLayout>
<Result
status="error"
title="第三方登录失败"
subTitle={errorMessage}
extra={[
<Link key="login" to={`/login${redirect !== '/dashboard' ? `?redirect=${encodeURIComponent(redirect)}` : ''}`}>
<Button type="primary"></Button>
</Link>,
]}
/>
</AuthLayout>
)
}

View File

@@ -0,0 +1 @@
export { OAuthCallbackPage } from './OAuthCallbackPage'

View File

@@ -0,0 +1,391 @@
import { MemoryRouter } from 'react-router-dom'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { message } from 'antd'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AuthCapabilities, RegisterResponse } from '@/types'
import { RegisterPage } from './RegisterPage'
const TEXT = {
usernamePlaceholder: '用户名',
nicknamePlaceholder: '昵称(选填)',
emailPlaceholder: '邮箱地址(选填)',
phonePlaceholder: '手机号(选填)',
passwordPlaceholder: '密码',
confirmPasswordPlaceholder: '确认密码',
createAccount: '创建账号',
returnLogin: '返回登录',
forgotPassword: '忘记密码?',
sendCode: '获取验证码',
resendActivation: '重新发送激活邮件',
adminBootstrapTitle: '当前仍需管理员初始化',
adminBootstrapAction: '初始化管理员',
phoneCodeValidation: '请输入 6 位短信验证码',
activeSummary: '账号已创建,现在可以返回登录页使用新账号登录。',
inactiveSummaryNoEmail: '账号已创建,请按页面提示完成激活后再登录。',
smsDisabledDescription: '支持用户名和邮箱注册;当前环境未启用短信能力,因此手机号注册暂不可用。',
}
const getAuthCapabilitiesMock = vi.fn<() => Promise<AuthCapabilities>>()
const registerMock = vi.fn<(payload: unknown) => Promise<RegisterResponse>>()
const sendSmsCodeMock = vi.fn<(payload: unknown) => Promise<void>>()
const defaultCapabilities: AuthCapabilities = {
password: true,
email_activation: false,
email_code: false,
sms_code: false,
password_reset: false,
admin_bootstrap_required: false,
oauth_providers: [],
}
const activeRegisterResponse: RegisterResponse = {
user: {
id: 2,
username: 'new-user',
email: 'new-user@example.com',
phone: '',
nickname: 'New User',
avatar: '',
status: 1,
},
message: 'registered successfully',
}
vi.mock('@/services/auth', () => ({
getAuthCapabilities: () => getAuthCapabilitiesMock(),
register: (payload: unknown) => registerMock(payload),
sendSmsCode: (payload: unknown) => sendSmsCodeMock(payload),
}))
function renderRegisterPage() {
return render(
<MemoryRouter initialEntries={['/register']}>
<RegisterPage />
</MemoryRouter>,
)
}
function fillBaseRegistrationFields(values?: {
username?: string
nickname?: string
email?: string
password?: string
confirmPassword?: string
}) {
fireEvent.change(screen.getByPlaceholderText(TEXT.usernamePlaceholder), {
target: { value: values?.username ?? 'new-user' },
})
if (values?.nickname !== undefined || screen.queryByPlaceholderText(TEXT.nicknamePlaceholder)) {
fireEvent.change(screen.getByPlaceholderText(TEXT.nicknamePlaceholder), {
target: { value: values?.nickname ?? 'New User' },
})
}
if (values?.email !== undefined || screen.queryByPlaceholderText(TEXT.emailPlaceholder)) {
fireEvent.change(screen.getByPlaceholderText(TEXT.emailPlaceholder), {
target: { value: values?.email ?? 'new-user@example.com' },
})
}
fireEvent.change(screen.getByPlaceholderText(TEXT.passwordPlaceholder), {
target: { value: values?.password ?? 'SecurePass123!' },
})
fireEvent.change(screen.getByPlaceholderText(TEXT.confirmPasswordPlaceholder), {
target: { value: values?.confirmPassword ?? values?.password ?? 'SecurePass123!' },
})
}
describe('RegisterPage', () => {
beforeEach(() => {
getAuthCapabilitiesMock.mockReset()
registerMock.mockReset()
sendSmsCodeMock.mockReset()
getAuthCapabilitiesMock.mockResolvedValue(defaultCapabilities)
registerMock.mockResolvedValue(activeRegisterResponse)
sendSmsCodeMock.mockResolvedValue(undefined)
vi.spyOn(message, 'success').mockImplementation(() => undefined as never)
vi.spyOn(message, 'error').mockImplementation(() => undefined as never)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('renders username registration fields and hides phone registration when sms is disabled', async () => {
renderRegisterPage()
await waitFor(() => expect(getAuthCapabilitiesMock).toHaveBeenCalledTimes(1))
expect(screen.getByPlaceholderText(TEXT.usernamePlaceholder)).toBeInTheDocument()
expect(screen.getByPlaceholderText(TEXT.nicknamePlaceholder)).toBeInTheDocument()
expect(screen.getByPlaceholderText(TEXT.emailPlaceholder)).toBeInTheDocument()
expect(screen.queryByPlaceholderText(TEXT.phonePlaceholder)).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: /返回登录/ })).toBeInTheDocument()
})
it('falls back to default capabilities when loading capabilities fails', async () => {
getAuthCapabilitiesMock.mockRejectedValue(new Error('capabilities unavailable'))
renderRegisterPage()
await waitFor(() => expect(screen.getByPlaceholderText(TEXT.usernamePlaceholder)).toBeInTheDocument())
expect(screen.getByText(TEXT.smsDisabledDescription)).toBeInTheDocument()
expect(screen.queryByPlaceholderText(TEXT.phonePlaceholder)).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: TEXT.forgotPassword })).not.toBeInTheDocument()
})
it('shows the forgot-password entry when the capability is enabled', async () => {
getAuthCapabilitiesMock.mockResolvedValue({
...defaultCapabilities,
password_reset: true,
})
renderRegisterPage()
await waitFor(() => expect(screen.getByRole('link', { name: TEXT.forgotPassword })).toBeInTheDocument())
})
it('sends register sms codes with the normalized purpose payload and starts the countdown', async () => {
getAuthCapabilitiesMock.mockResolvedValue({
...defaultCapabilities,
sms_code: true,
})
renderRegisterPage()
await waitFor(() => expect(screen.getByPlaceholderText(TEXT.phonePlaceholder)).toBeInTheDocument())
fireEvent.change(screen.getByPlaceholderText(TEXT.phonePlaceholder), {
target: { value: '13812345678' },
})
fireEvent.click(screen.getByRole('button', { name: TEXT.sendCode }))
await waitFor(() =>
expect(sendSmsCodeMock).toHaveBeenCalledWith({
phone: '13812345678',
purpose: 'register',
}),
)
expect(message.success).toHaveBeenCalledTimes(1)
expect(screen.getByRole('button', { name: '60s' })).toBeInTheDocument()
})
it('ignores form validation errors when the phone format is invalid', async () => {
getAuthCapabilitiesMock.mockResolvedValue({
...defaultCapabilities,
sms_code: true,
})
renderRegisterPage()
await waitFor(() => expect(screen.getByPlaceholderText(TEXT.phonePlaceholder)).toBeInTheDocument())
fireEvent.change(screen.getByPlaceholderText(TEXT.phonePlaceholder), {
target: { value: '123' },
})
fireEvent.click(screen.getByRole('button', { name: TEXT.sendCode }))
await waitFor(() => expect(sendSmsCodeMock).not.toHaveBeenCalled())
expect(message.error).not.toHaveBeenCalled()
expect(screen.queryByRole('button', { name: '60s' })).not.toBeInTheDocument()
})
it('surfaces sms code send failures from the backend', async () => {
getAuthCapabilitiesMock.mockResolvedValue({
...defaultCapabilities,
sms_code: true,
})
sendSmsCodeMock.mockRejectedValue(new Error('sms send failed'))
renderRegisterPage()
await waitFor(() => expect(screen.getByPlaceholderText(TEXT.phonePlaceholder)).toBeInTheDocument())
fireEvent.change(screen.getByPlaceholderText(TEXT.phonePlaceholder), {
target: { value: '13812345678' },
})
fireEvent.click(screen.getByRole('button', { name: TEXT.sendCode }))
await waitFor(() => expect(message.error).toHaveBeenCalledWith('sms send failed'))
expect(screen.queryByRole('button', { name: '60s' })).not.toBeInTheDocument()
})
it('blocks sms-backed registration when the phone code is missing', async () => {
getAuthCapabilitiesMock.mockResolvedValue({
...defaultCapabilities,
sms_code: true,
})
renderRegisterPage()
await waitFor(() => expect(screen.getByPlaceholderText(TEXT.phonePlaceholder)).toBeInTheDocument())
fillBaseRegistrationFields()
fireEvent.change(screen.getByPlaceholderText(TEXT.phonePlaceholder), {
target: { value: '13812345678' },
})
fireEvent.click(screen.getByRole('button', { name: TEXT.createAccount }))
await waitFor(() => expect(registerMock).not.toHaveBeenCalled())
expect(message.error).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: TEXT.createAccount })).toBeInTheDocument()
})
it('submits the self-service registration payload and shows the success state', async () => {
renderRegisterPage()
await waitFor(() => expect(screen.getByPlaceholderText(TEXT.usernamePlaceholder)).toBeInTheDocument())
fillBaseRegistrationFields()
fireEvent.click(screen.getByRole('button', { name: TEXT.createAccount }))
await waitFor(() =>
expect(registerMock).toHaveBeenCalledWith({
username: 'new-user',
password: 'SecurePass123!',
nickname: 'New User',
email: 'new-user@example.com',
phone: undefined,
phone_code: undefined,
}),
)
expect(await screen.findByRole('button', { name: TEXT.returnLogin })).toBeInTheDocument()
expect(screen.getByText(TEXT.activeSummary)).toBeInTheDocument()
})
it('submits sms-backed registration payload with trimmed username, nickname, and phone code', async () => {
getAuthCapabilitiesMock.mockResolvedValue({
...defaultCapabilities,
sms_code: true,
})
renderRegisterPage()
await waitFor(() => expect(screen.getByPlaceholderText(TEXT.phonePlaceholder)).toBeInTheDocument())
fillBaseRegistrationFields({
username: ' new-user ',
nickname: ' New User ',
email: 'new-user@example.com',
})
fireEvent.change(screen.getByPlaceholderText(TEXT.phonePlaceholder), {
target: { value: '13812345678' },
})
fireEvent.change(screen.getAllByRole('textbox')[4], {
target: { value: ' 654321 ' },
})
fireEvent.click(screen.getByRole('button', { name: TEXT.createAccount }))
await waitFor(() =>
expect(registerMock).toHaveBeenCalledWith({
username: 'new-user',
password: 'SecurePass123!',
nickname: 'New User',
email: 'new-user@example.com',
phone: '13812345678',
phone_code: '654321',
}),
)
})
it('surfaces self-service registration failures from the backend', async () => {
registerMock.mockRejectedValue(new Error('register failed'))
renderRegisterPage()
await waitFor(() => expect(screen.getByPlaceholderText(TEXT.usernamePlaceholder)).toBeInTheDocument())
fillBaseRegistrationFields()
fireEvent.click(screen.getByRole('button', { name: TEXT.createAccount }))
await waitFor(() => expect(message.error).toHaveBeenCalledWith('register failed'))
expect(screen.getByRole('button', { name: TEXT.createAccount })).toBeInTheDocument()
})
it('shows a resend-activation entry for newly created inactive email accounts', async () => {
getAuthCapabilitiesMock.mockResolvedValue({
...defaultCapabilities,
email_activation: true,
})
registerMock.mockResolvedValue({
user: {
id: 3,
username: 'inactive-user',
email: 'inactive-user@example.com',
phone: '',
nickname: 'Inactive User',
avatar: '',
status: 0,
},
message: 'registered successfully, please check your email to activate the account',
})
renderRegisterPage()
await waitFor(() => expect(screen.getByPlaceholderText(TEXT.usernamePlaceholder)).toBeInTheDocument())
fillBaseRegistrationFields({
username: 'inactive-user',
nickname: '',
email: 'inactive-user@example.com',
})
fireEvent.click(screen.getByRole('button', { name: TEXT.createAccount }))
const resendLink = await screen.findByRole('link', { name: TEXT.resendActivation })
expect(resendLink).toHaveAttribute('href', '/activate-account?email=inactive-user%40example.com')
})
it('shows the generic activation summary when the new inactive account has no email address', async () => {
registerMock.mockResolvedValue({
user: {
id: 4,
username: 'inactive-without-email',
email: '',
phone: '',
nickname: '',
avatar: '',
status: 0,
},
message: 'registered successfully, activation required',
})
renderRegisterPage()
await waitFor(() => expect(screen.getByPlaceholderText(TEXT.usernamePlaceholder)).toBeInTheDocument())
fillBaseRegistrationFields({
username: 'inactive-without-email',
nickname: '',
email: '',
})
fireEvent.click(screen.getByRole('button', { name: TEXT.createAccount }))
expect(await screen.findByText(TEXT.inactiveSummaryNoEmail)).toBeInTheDocument()
expect(screen.queryByRole('link', { name: TEXT.resendActivation })).not.toBeInTheDocument()
})
it('shows an admin bootstrap entry when the system still has no active admin', async () => {
getAuthCapabilitiesMock.mockResolvedValue({
...defaultCapabilities,
admin_bootstrap_required: true,
})
renderRegisterPage()
expect(await screen.findByText(TEXT.adminBootstrapTitle)).toBeInTheDocument()
expect(screen.getByRole('button', { name: TEXT.adminBootstrapAction }).closest('a')).toHaveAttribute('href', '/bootstrap-admin')
})
})

View File

@@ -0,0 +1,342 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import {
ArrowLeftOutlined,
LockOutlined,
MailOutlined,
MobileOutlined,
SafetyOutlined,
UserOutlined,
} from '@ant-design/icons'
import { Alert, Button, Form, Input, Result, Space, Typography, message } from 'antd'
import { AuthLayout } from '@/layouts'
import { getErrorMessage, isFormValidationError } from '@/lib/errors'
import { getAuthCapabilities, register, sendSmsCode } from '@/services/auth'
import type { AuthCapabilities, RegisterResponse } from '@/types'
const { Paragraph, Text, Title } = Typography
const COUNTDOWN_SECONDS = 60
const DEFAULT_CAPABILITIES: AuthCapabilities = {
password: true,
email_activation: false,
email_code: false,
sms_code: false,
password_reset: false,
admin_bootstrap_required: false,
oauth_providers: [],
}
type RegisterFormValues = {
username: string
nickname?: string
email?: string
phone?: string
phoneCode?: string
password: string
confirmPassword: string
}
function buildRegisterSummary(result: RegisterResponse) {
if (result.user.status === 0) {
if (result.user.email) {
return `账号已创建,激活邮件会发送到 ${result.user.email}。请完成激活后再登录。`
}
return '账号已创建,请按页面提示完成激活后再登录。'
}
return '账号已创建,现在可以返回登录页使用新账号登录。'
}
export function RegisterPage() {
const [form] = Form.useForm<RegisterFormValues>()
const [loading, setLoading] = useState(false)
const [smsCountdown, setSmsCountdown] = useState(0)
const [capabilities, setCapabilities] = useState<AuthCapabilities>(DEFAULT_CAPABILITIES)
const [capabilitiesLoaded, setCapabilitiesLoaded] = useState(false)
const [submitted, setSubmitted] = useState<RegisterResponse | null>(null)
useEffect(() => {
if (smsCountdown <= 0) {
return
}
const timer = window.setTimeout(() => setSmsCountdown((current) => current - 1), 1000)
return () => window.clearTimeout(timer)
}, [smsCountdown])
useEffect(() => {
let cancelled = false
const loadCapabilities = async () => {
try {
const result = await getAuthCapabilities()
if (!cancelled) {
setCapabilities(result)
}
} catch {
if (!cancelled) {
setCapabilities(DEFAULT_CAPABILITIES)
}
} finally {
if (!cancelled) {
setCapabilitiesLoaded(true)
}
}
}
void loadCapabilities()
return () => {
cancelled = true
}
}, [])
const handleSendSmsCode = useCallback(async () => {
try {
const values = await form.validateFields(['phone'])
const phone = values.phone?.trim()
if (!phone) {
return
}
setSmsCountdown(COUNTDOWN_SECONDS)
await sendSmsCode({ phone, purpose: 'register' })
message.success('验证码已发送到手机')
} catch (error) {
setSmsCountdown(0)
if (isFormValidationError(error)) {
return
}
message.error(getErrorMessage(error, '发送验证码失败'))
}
}, [form])
const handleSubmit = useCallback(async (values: RegisterFormValues) => {
setLoading(true)
try {
const phone = capabilities.sms_code ? values.phone?.trim() || undefined : undefined
const result = await register({
username: values.username.trim(),
password: values.password,
nickname: values.nickname?.trim() || undefined,
email: values.email?.trim() || undefined,
phone,
phone_code: phone ? values.phoneCode?.trim() || undefined : undefined,
})
form.resetFields()
setSmsCountdown(0)
setSubmitted(result)
message.success(result.user.status === 0 ? '注册成功,请完成邮箱激活' : '注册成功')
} catch (error) {
message.error(getErrorMessage(error, '注册失败,请检查输入信息后重试'))
} finally {
setLoading(false)
}
}, [capabilities.sms_code, form])
if (submitted) {
const activationEmail = submitted.user.email?.trim()
return (
<AuthLayout>
<Result
status="success"
title="注册成功"
subTitle={(
<Paragraph>
<Text strong>{submitted.user.username}</Text>
{' '}
{buildRegisterSummary(submitted)}
</Paragraph>
)}
extra={[
<Link key="login" to="/login">
<Button type="primary"></Button>
</Link>,
submitted.user.status === 0 && activationEmail && capabilities.email_activation ? (
<Link key="activation" to={`/activate-account?email=${encodeURIComponent(activationEmail)}`}>
<Button></Button>
</Link>
) : null,
]}
/>
</AuthLayout>
)
}
return (
<AuthLayout>
<Title level={3} style={{ marginBottom: 8 }}>
</Title>
<Paragraph type="secondary" style={{ marginBottom: 24 }}>
{capabilitiesLoaded
? capabilities.sms_code
? '支持用户名注册;填写手机号时需要先完成短信验证码校验。'
: '支持用户名和邮箱注册;当前环境未启用短信能力,因此手机号注册暂不可用。'
: '填写基础信息后即可创建账号。'}
</Paragraph>
{capabilities.admin_bootstrap_required && (
<Alert
type="warning"
showIcon
message="当前仍需管理员初始化"
description="自助注册不会创建首个管理员账号。如需进入后台管理,请先完成管理员初始化。"
action={(
<Link to="/bootstrap-admin">
<Button size="small" type="primary"></Button>
</Link>
)}
style={{ marginBottom: 24 }}
/>
)}
<Form<RegisterFormValues> form={form} layout="vertical" onFinish={handleSubmit} autoComplete="off">
<Form.Item
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input
prefix={<UserOutlined />}
placeholder="用户名"
size="large"
autoComplete="username"
/>
</Form.Item>
<Form.Item name="nickname">
<Input
prefix={<UserOutlined />}
placeholder="昵称(选填)"
size="large"
autoComplete="nickname"
/>
</Form.Item>
<Form.Item
name="email"
rules={[
{ type: 'email', message: '请输入有效的邮箱地址' },
]}
>
<Input
prefix={<MailOutlined />}
placeholder="邮箱地址(选填)"
size="large"
autoComplete="email"
/>
</Form.Item>
{capabilities.sms_code && (
<>
<Form.Item
name="phone"
rules={[
{ pattern: /^$|^1\d{10}$/, message: '请输入有效的手机号' },
]}
>
<Input
prefix={<MobileOutlined />}
placeholder="手机号(选填)"
size="large"
autoComplete="tel"
/>
</Form.Item>
<Form.Item
name="phoneCode"
dependencies={['phone']}
rules={[
({ getFieldValue }) => ({
validator(_, value) {
const phone = String(getFieldValue('phone') ?? '').trim()
if (!phone) {
return Promise.resolve()
}
if (/^\d{6}$/.test(String(value ?? '').trim())) {
return Promise.resolve()
}
return Promise.reject(new Error('请输入 6 位短信验证码'))
},
}),
]}
>
<Space.Compact style={{ width: '100%' }}>
<Input
prefix={<SafetyOutlined />}
placeholder="短信验证码"
size="large"
maxLength={6}
style={{ flex: 1 }}
/>
<Button
size="large"
disabled={smsCountdown > 0}
onClick={() => void handleSendSmsCode()}
style={{ width: 120 }}
>
{smsCountdown > 0 ? `${smsCountdown}s` : '获取验证码'}
</Button>
</Space.Compact>
</Form.Item>
</>
)}
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="密码"
size="large"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item
name="confirmPassword"
dependencies={['password']}
rules={[
{ required: true, message: '请再次输入密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve()
}
return Promise.reject(new Error('两次输入的密码不一致'))
},
}),
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="确认密码"
size="large"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" size="large" block loading={loading}>
</Button>
</Form.Item>
</Form>
<div style={{ textAlign: 'center', marginTop: 16 }}>
<Space split={<Text type="secondary">|</Text>}>
<Link to="/login">
<Text type="secondary">
<ArrowLeftOutlined style={{ marginRight: 4 }} />
</Text>
</Link>
{capabilities.password_reset && (
<Link to="/forgot-password">
<Text type="secondary"></Text>
</Link>
)}
</Space>
</div>
</AuthLayout>
)
}

View File

@@ -0,0 +1 @@
export { RegisterPage } from './RegisterPage'

View File

@@ -0,0 +1,121 @@
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { message } from 'antd'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ResetPasswordPage } from './ResetPasswordPage'
const validateResetTokenMock = vi.fn()
const resetPasswordMock = vi.fn()
vi.mock('@/services/auth', () => ({
validateResetToken: (token: string) => validateResetTokenMock(token),
resetPassword: (payload: unknown) => resetPasswordMock(payload),
}))
function renderResetPasswordPage(initialEntry: string) {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/reset-password" element={<ResetPasswordPage />} />
</Routes>
</MemoryRouter>,
)
}
describe('ResetPasswordPage', () => {
beforeEach(() => {
validateResetTokenMock.mockReset()
resetPasswordMock.mockReset()
validateResetTokenMock.mockResolvedValue({
valid: true,
email: 'user@example.com',
expires_at: '2026-03-28T12:00:00Z',
})
resetPasswordMock.mockResolvedValue(undefined)
vi.spyOn(message, 'success').mockImplementation(() => undefined as never)
vi.spyOn(message, 'error').mockImplementation(() => undefined as never)
})
it('shows an invalid-link state when the reset token is missing', async () => {
renderResetPasswordPage('/reset-password')
expect(await screen.findByText('链接无效')).toBeInTheDocument()
expect(validateResetTokenMock).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: '重新申请' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '返回登录' })).toBeInTheDocument()
})
it('shows an invalid-link state when token validation fails', async () => {
validateResetTokenMock.mockRejectedValueOnce(new Error('token invalid'))
renderResetPasswordPage('/reset-password?token=expired-token')
await waitFor(() => expect(validateResetTokenMock).toHaveBeenCalledWith('expired-token'))
expect(await screen.findByText('链接无效')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '重新申请' })).toBeInTheDocument()
})
it('renders the reset form after token validation succeeds', async () => {
renderResetPasswordPage('/reset-password?token=token-123')
await waitFor(() => expect(validateResetTokenMock).toHaveBeenCalledWith('token-123'))
expect(await screen.findByRole('heading', { name: '重置密码' })).toBeInTheDocument()
expect(screen.getByPlaceholderText('新密码')).toBeInTheDocument()
expect(screen.getByPlaceholderText('确认新密码')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '确认重置' })).toBeInTheDocument()
expect(screen.getByText('user@example.com')).toBeInTheDocument()
})
it('submits the new password and shows the success state', async () => {
const user = userEvent.setup()
const { container } = renderResetPasswordPage('/reset-password?token=token-123')
await waitFor(() => expect(validateResetTokenMock).toHaveBeenCalledWith('token-123'))
await waitFor(() => expect(container.querySelectorAll('input[type="password"]')).toHaveLength(2))
const passwordInputs = Array.from(
container.querySelectorAll('input[type="password"]'),
) as HTMLInputElement[]
await user.type(passwordInputs[0], 'NewPass123!')
await user.type(passwordInputs[1], 'NewPass123!')
await user.click(screen.getByRole('button', { name: '确认重置' }))
await waitFor(() =>
expect(resetPasswordMock).toHaveBeenCalledWith({
token: 'token-123',
new_password: 'NewPass123!',
confirm_password: 'NewPass123!',
}),
)
expect(await screen.findByText('密码已重置')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '立即登录' })).toBeInTheDocument()
expect(message.success).toHaveBeenCalledWith('密码重置成功')
})
it('surfaces backend failures when resetting the password', async () => {
const user = userEvent.setup()
const { container } = renderResetPasswordPage('/reset-password?token=token-123')
resetPasswordMock.mockRejectedValueOnce(new Error('reset failed'))
await waitFor(() => expect(validateResetTokenMock).toHaveBeenCalledWith('token-123'))
await waitFor(() => expect(container.querySelectorAll('input[type="password"]')).toHaveLength(2))
const passwordInputs = Array.from(
container.querySelectorAll('input[type="password"]'),
) as HTMLInputElement[]
await user.type(passwordInputs[0], 'NewPass123!')
await user.type(passwordInputs[1], 'NewPass123!')
await user.click(screen.getByRole('button', { name: '确认重置' }))
await waitFor(() => expect(message.error).toHaveBeenCalledWith('reset failed'))
})
})

View File

@@ -0,0 +1,218 @@
/**
* 重置密码页
*
* 用户通过邮件中的链接访问此页面,输入新密码完成重置
*/
import { useState, useCallback, useEffect } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import {
Form,
Input,
Button,
Typography,
message,
Result,
Spin,
} from 'antd'
import { LockOutlined, ArrowLeftOutlined } from '@ant-design/icons'
import { getErrorMessage } from '@/lib/errors'
import { AuthLayout } from '@/layouts'
import { validateResetToken, resetPassword } from '@/services/auth'
const { Title, Paragraph, Text } = Typography
type ResetPasswordFormValues = {
password: string
confirmPassword: string
}
type TokenValidation = {
valid: boolean
email?: string
expiresAt?: string
}
export function ResetPasswordPage() {
const [searchParams] = useSearchParams()
const token = searchParams.get('token') || ''
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [validation, setValidation] = useState<TokenValidation>({ valid: false })
// 校验 token
useEffect(() => {
if (!token) {
setLoading(false)
return
}
const validateToken = async () => {
try {
const result = await validateResetToken(token)
setValidation({
valid: result.valid,
email: result.email,
expiresAt: result.expires_at,
})
} catch {
setValidation({ valid: false })
} finally {
setLoading(false)
}
}
validateToken()
}, [token])
const handleSubmit = useCallback(async (values: ResetPasswordFormValues) => {
if (!token) {
message.error('无效的重置链接')
return
}
setSubmitting(true)
try {
await resetPassword({
token,
new_password: values.password,
confirm_password: values.confirmPassword,
})
setSubmitted(true)
message.success('密码重置成功')
} catch (error) {
message.error(getErrorMessage(error, '重置失败,请稍后重试'))
} finally {
setSubmitting(false)
}
}, [token])
// 加载中
if (loading) {
return (
<AuthLayout>
<div style={{ textAlign: 'center', padding: '48px 0' }}>
<Spin size="large" />
<Paragraph type="secondary" style={{ marginTop: 16 }}>
...
</Paragraph>
</div>
</AuthLayout>
)
}
// 没有 token 或 token 无效
if (!token || !validation.valid) {
return (
<AuthLayout>
<Result
status="error"
title="链接无效"
subTitle="该密码重置链接已失效或已过期,请重新申请重置密码。"
extra={[
<Link key="forgot" to="/forgot-password">
<Button type="primary"></Button>
</Link>,
<Link key="login" to="/login">
<Button></Button>
</Link>,
]}
/>
</AuthLayout>
)
}
// 重置成功
if (submitted) {
return (
<AuthLayout>
<Result
status="success"
title="密码已重置"
subTitle="您的密码已成功重置,请使用新密码登录。"
extra={[
<Link key="login" to="/login">
<Button type="primary"></Button>
</Link>,
]}
/>
</AuthLayout>
)
}
return (
<AuthLayout>
<Title level={3} style={{ marginBottom: 8 }}>
</Title>
<Paragraph type="secondary" style={{ marginBottom: 24 }}>
<Text strong>{validation.email}</Text>
</Paragraph>
<Form<ResetPasswordFormValues>
layout="vertical"
onFinish={handleSubmit}
autoComplete="off"
>
<Form.Item
name="password"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 8, message: '密码至少 8 位' },
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="新密码"
size="large"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item
name="confirmPassword"
dependencies={['password']}
rules={[
{ required: true, message: '请确认新密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve()
}
return Promise.reject(new Error('两次输入的密码不一致'))
},
}),
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="确认新密码"
size="large"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
size="large"
block
loading={submitting}
>
</Button>
</Form.Item>
</Form>
<div style={{ textAlign: 'center', marginTop: 16 }}>
<Link to="/login">
<Text type="secondary">
<ArrowLeftOutlined style={{ marginRight: 4 }} />
</Text>
</Link>
</div>
</AuthLayout>
)
}

View File

@@ -0,0 +1 @@
export { ResetPasswordPage } from './ResetPasswordPage'

View File

@@ -0,0 +1,7 @@
export { LoginPage } from './LoginPage'
export { RegisterPage } from './RegisterPage'
export { BootstrapAdminPage } from './BootstrapAdminPage'
export { ActivateAccountPage } from './ActivateAccountPage'
export { ForgotPasswordPage } from './ForgotPasswordPage'
export { ResetPasswordPage } from './ResetPasswordPage'
export { OAuthCallbackPage } from './OAuthCallbackPage'

View File

@@ -0,0 +1,3 @@
export * from './auth'
export * from './admin'
export { NotFoundPage } from './NotFoundPage'