feat: admin frontend - React + Vite, auth pages, user management, roles, permissions, webhooks, devices, logs
This commit is contained in:
30
frontend/admin/src/pages/NotFoundPage/NotFoundPage.test.tsx
Normal file
30
frontend/admin/src/pages/NotFoundPage/NotFoundPage.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
31
frontend/admin/src/pages/NotFoundPage/NotFoundPage.tsx
Normal file
31
frontend/admin/src/pages/NotFoundPage/NotFoundPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
frontend/admin/src/pages/NotFoundPage/index.ts
Normal file
1
frontend/admin/src/pages/NotFoundPage/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { NotFoundPage } from './NotFoundPage'
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
224
frontend/admin/src/pages/admin/DashboardPage/DashboardPage.tsx
Normal file
224
frontend/admin/src/pages/admin/DashboardPage/DashboardPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
frontend/admin/src/pages/admin/DashboardPage/index.ts
Normal file
1
frontend/admin/src/pages/admin/DashboardPage/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { DashboardPage } from './DashboardPage'
|
||||
352
frontend/admin/src/pages/admin/DevicesPage/DevicesPage.tsx
Normal file
352
frontend/admin/src/pages/admin/DevicesPage/DevicesPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
frontend/admin/src/pages/admin/DevicesPage/index.ts
Normal file
1
frontend/admin/src/pages/admin/DevicesPage/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { DevicesPage } from './DevicesPage'
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
1
frontend/admin/src/pages/admin/ImportExportPage/index.ts
Normal file
1
frontend/admin/src/pages/admin/ImportExportPage/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ImportExportPage } from './ImportExportPage'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
272
frontend/admin/src/pages/admin/LoginLogsPage/LoginLogsPage.tsx
Normal file
272
frontend/admin/src/pages/admin/LoginLogsPage/LoginLogsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
frontend/admin/src/pages/admin/LoginLogsPage/index.ts
Normal file
1
frontend/admin/src/pages/admin/LoginLogsPage/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { LoginLogsPage } from './LoginLogsPage'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { OperationLogsPage } from './OperationLogsPage'
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
1
frontend/admin/src/pages/admin/PermissionsPage/index.ts
Normal file
1
frontend/admin/src/pages/admin/PermissionsPage/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PermissionsPage } from './PermissionsPage'
|
||||
330
frontend/admin/src/pages/admin/ProfilePage/ProfilePage.test.tsx
Normal file
330
frontend/admin/src/pages/admin/ProfilePage/ProfilePage.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
265
frontend/admin/src/pages/admin/ProfilePage/ProfilePage.tsx
Normal file
265
frontend/admin/src/pages/admin/ProfilePage/ProfilePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
frontend/admin/src/pages/admin/ProfilePage/index.ts
Normal file
1
frontend/admin/src/pages/admin/ProfilePage/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ProfilePage } from './ProfilePage'
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ProfileSecurityPage } from './ProfileSecurityPage'
|
||||
225
frontend/admin/src/pages/admin/RolesPage/RoleFormModal.test.tsx
Normal file
225
frontend/admin/src/pages/admin/RolesPage/RoleFormModal.test.tsx
Normal 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'))
|
||||
})
|
||||
})
|
||||
141
frontend/admin/src/pages/admin/RolesPage/RoleFormModal.tsx
Normal file
141
frontend/admin/src/pages/admin/RolesPage/RoleFormModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
.filterCard {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tableCard {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tagSystem {
|
||||
margin-left: 8px;
|
||||
}
|
||||
514
frontend/admin/src/pages/admin/RolesPage/RolesPage.test.tsx
Normal file
514
frontend/admin/src/pages/admin/RolesPage/RolesPage.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
300
frontend/admin/src/pages/admin/RolesPage/RolesPage.tsx
Normal file
300
frontend/admin/src/pages/admin/RolesPage/RolesPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
frontend/admin/src/pages/admin/RolesPage/index.ts
Normal file
1
frontend/admin/src/pages/admin/RolesPage/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { RolesPage } from './RolesPage'
|
||||
179
frontend/admin/src/pages/admin/SettingsPage/SettingsPage.tsx
Normal file
179
frontend/admin/src/pages/admin/SettingsPage/SettingsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
frontend/admin/src/pages/admin/SettingsPage/index.ts
Normal file
1
frontend/admin/src/pages/admin/SettingsPage/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SettingsPage } from './SettingsPage'
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
107
frontend/admin/src/pages/admin/UsersPage/AssignRolesModal.tsx
Normal file
107
frontend/admin/src/pages/admin/UsersPage/AssignRolesModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
174
frontend/admin/src/pages/admin/UsersPage/CreateUserModal.tsx
Normal file
174
frontend/admin/src/pages/admin/UsersPage/CreateUserModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
130
frontend/admin/src/pages/admin/UsersPage/UserDetailDrawer.tsx
Normal file
130
frontend/admin/src/pages/admin/UsersPage/UserDetailDrawer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
288
frontend/admin/src/pages/admin/UsersPage/UserEditDrawer.test.tsx
Normal file
288
frontend/admin/src/pages/admin/UsersPage/UserEditDrawer.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
152
frontend/admin/src/pages/admin/UsersPage/UserEditDrawer.tsx
Normal file
152
frontend/admin/src/pages/admin/UsersPage/UserEditDrawer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
504
frontend/admin/src/pages/admin/UsersPage/UsersPage.test.tsx
Normal file
504
frontend/admin/src/pages/admin/UsersPage/UsersPage.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
518
frontend/admin/src/pages/admin/UsersPage/UsersPage.tsx
Normal file
518
frontend/admin/src/pages/admin/UsersPage/UsersPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
frontend/admin/src/pages/admin/UsersPage/index.ts
Normal file
1
frontend/admin/src/pages/admin/UsersPage/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { UsersPage } from './UsersPage'
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
122
frontend/admin/src/pages/admin/WebhooksPage/WebhookFormModal.tsx
Normal file
122
frontend/admin/src/pages/admin/WebhooksPage/WebhookFormModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.filterCard {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tableCard {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
304
frontend/admin/src/pages/admin/WebhooksPage/WebhooksPage.tsx
Normal file
304
frontend/admin/src/pages/admin/WebhooksPage/WebhooksPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
4
frontend/admin/src/pages/admin/WebhooksPage/index.ts
Normal file
4
frontend/admin/src/pages/admin/WebhooksPage/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { WebhooksPage } from './WebhooksPage'
|
||||
export { WebhookFormModal } from './WebhookFormModal'
|
||||
export { WebhookDeliveriesDrawer } from './WebhookDeliveriesDrawer'
|
||||
export { DeliveryDetailModal } from './DeliveryDetailModal'
|
||||
10
frontend/admin/src/pages/admin/index.ts
Normal file
10
frontend/admin/src/pages/admin/index.ts
Normal 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'
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ActivateAccountPage } from './ActivateAccountPage'
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { BootstrapAdminPage } from './BootstrapAdminPage'
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ForgotPasswordPage } from './ForgotPasswordPage'
|
||||
586
frontend/admin/src/pages/auth/LoginPage/LoginPage.test.tsx
Normal file
586
frontend/admin/src/pages/auth/LoginPage/LoginPage.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
501
frontend/admin/src/pages/auth/LoginPage/LoginPage.tsx
Normal file
501
frontend/admin/src/pages/auth/LoginPage/LoginPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
frontend/admin/src/pages/auth/LoginPage/index.ts
Normal file
1
frontend/admin/src/pages/auth/LoginPage/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { LoginPage } from './LoginPage'
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
1
frontend/admin/src/pages/auth/OAuthCallbackPage/index.ts
Normal file
1
frontend/admin/src/pages/auth/OAuthCallbackPage/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { OAuthCallbackPage } from './OAuthCallbackPage'
|
||||
391
frontend/admin/src/pages/auth/RegisterPage/RegisterPage.test.tsx
Normal file
391
frontend/admin/src/pages/auth/RegisterPage/RegisterPage.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
342
frontend/admin/src/pages/auth/RegisterPage/RegisterPage.tsx
Normal file
342
frontend/admin/src/pages/auth/RegisterPage/RegisterPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
frontend/admin/src/pages/auth/RegisterPage/index.ts
Normal file
1
frontend/admin/src/pages/auth/RegisterPage/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { RegisterPage } from './RegisterPage'
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
1
frontend/admin/src/pages/auth/ResetPasswordPage/index.ts
Normal file
1
frontend/admin/src/pages/auth/ResetPasswordPage/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ResetPasswordPage } from './ResetPasswordPage'
|
||||
7
frontend/admin/src/pages/auth/index.ts
Normal file
7
frontend/admin/src/pages/auth/index.ts
Normal 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'
|
||||
3
frontend/admin/src/pages/index.ts
Normal file
3
frontend/admin/src/pages/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './auth'
|
||||
export * from './admin'
|
||||
export { NotFoundPage } from './NotFoundPage'
|
||||
Reference in New Issue
Block a user