Fix prelaunch navigation and log scale regressions

This commit is contained in:
2026-05-12 00:28:38 +08:00
parent 7c2f073cbf
commit 77d096cdc9
11 changed files with 670 additions and 259 deletions

View File

@@ -0,0 +1,73 @@
# Prelaunch Navigation And Batch Delete Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix the release-blocking admin mobile navigation browser path and strengthen bulk-delete confirmation on the users admin page.
**Architecture:** Keep the product changes minimal and local to the admin frontend. Make mobile drawer state transitions explicit in `AdminLayout`, harden the supported E2E scenario around the real drawer surface, and upgrade `UsersPage` bulk delete from a lightweight pop confirmation to a stronger modal confirmation without changing backend APIs.
**Tech Stack:** React 18, Ant Design, React Router, Vitest, Playwright CDP runner.
---
### Task 1: Capture the failing browser evidence
**Files:**
- Modify: none
- [ ] Run `cd frontend/admin && $env:E2E_SCENARIOS='desktop-mobile-navigation'; npm.cmd run e2e:full:win`.
- [ ] Record the exact failing step and whether the drawer fails to open, the selector fails to resolve, or navigation fails after selection.
- [ ] Do not change product code until the failure mode is confirmed.
### Task 2: Add the AdminLayout regression first
**Files:**
- Modify: `frontend/admin/src/layouts/AdminLayout/AdminLayout.test.tsx`
- Modify: `frontend/admin/src/layouts/AdminLayout/AdminLayout.tsx`
- [ ] Add a failing test that switches from desktop to mobile, opens the menu, navigates through the drawer, and proves the drawer closes deterministically after selection.
- [ ] Run `cd frontend/admin && npm.cmd run test:run -- src/layouts/AdminLayout/AdminLayout.test.tsx`.
- [ ] Confirm the new assertion fails for the current implementation before fixing the layout.
### Task 3: Fix mobile drawer state and harden the browser scenario
**Files:**
- Modify: `frontend/admin/src/layouts/AdminLayout/AdminLayout.tsx`
- Modify: `frontend/admin/scripts/run-playwright-cdp-e2e.mjs`
- [ ] Replace toggle-based mobile drawer state transitions with explicit open and close handlers.
- [ ] Keep desktop collapse behavior unchanged.
- [ ] Narrow browser selectors and waits so the scenario checks the intended mobile button and the open drawer content.
- [ ] Re-run `cd frontend/admin && $env:E2E_SCENARIOS='desktop-mobile-navigation'; npm.cmd run e2e:full:win`.
### Task 4: Add the UsersPage regression first
**Files:**
- Modify: `frontend/admin/src/pages/admin/UsersPage/UsersPage.test.tsx`
- Modify: `frontend/admin/src/pages/admin/UsersPage/UsersPage.tsx`
- [ ] Add a failing test that selects users, triggers bulk delete, verifies no delete happens on the first lightweight action alone, and confirms the API call only occurs after the stronger explicit confirmation.
- [ ] Run `cd frontend/admin && npm.cmd run test:run -- src/pages/admin/UsersPage/UsersPage.test.tsx`.
- [ ] Confirm the new assertion fails for the current implementation before changing the page.
### Task 5: Implement stronger bulk-delete confirmation
**Files:**
- Modify: `frontend/admin/src/pages/admin/UsersPage/UsersPage.tsx`
- [ ] Replace the direct `Popconfirm` bulk-delete path with a stronger confirmation modal flow.
- [ ] Keep the existing self-delete guard and empty-selection guard.
- [ ] After confirmation, keep existing success behavior: call `batchDelete`, clear selection, and refresh the list.
- [ ] Re-run `cd frontend/admin && npm.cmd run test:run -- src/pages/admin/UsersPage/UsersPage.test.tsx`.
### Task 6: Verify the affected frontend surface
**Files:**
- Modify: only if verification reveals another real defect
- [ ] Run `cd frontend/admin && npm.cmd run test:run -- src/layouts/AdminLayout/AdminLayout.test.tsx src/pages/admin/UsersPage/UsersPage.test.tsx`.
- [ ] Run `cd frontend/admin && npm.cmd run lint`.
- [ ] Run `cd frontend/admin && npm.cmd run build`.
- [ ] Re-run `cd frontend/admin && $env:E2E_SCENARIOS='desktop-mobile-navigation'; npm.cmd run e2e:full:win`.
- [ ] Report the results exactly as observed, including any remaining risk if full-suite E2E is not rerun in this turn.

View File

@@ -0,0 +1,87 @@
# Prelaunch Navigation And Batch Delete Design
**Date:** 2026-05-10
**Goal:** Remove the release-blocking `desktop-mobile-navigation` browser failure and strengthen the admin users batch-delete confirmation flow identified in the 2026-05-10 prelaunch report.
## Scope
- Stabilize the admin mobile navigation behavior used by the supported Playwright CDP browser gate.
- Keep the `desktop-mobile-navigation` scenario as a real product verification path instead of weakening it into a runner-only smoke check.
- Strengthen the `UsersPage` batch-delete confirmation so destructive bulk actions require clearer intent than the current single pop confirmation.
- Add focused frontend regression coverage for both changes.
## Non-Goals
- No redesign of the admin layout visual system.
- No change to backend user deletion APIs or authorization rules.
- No expansion of the prelaunch recommendations unrelated to today's release blockers, such as password strength hints, dashboard charts, or OAuth button loading states.
## Current Findings
### 1. Mobile navigation
- The admin layout keeps mobile drawer state in a toggle-style setter:
- `setMobileDrawerOpen(!mobileDrawerOpen)`
- The same toggle function is used for both explicit open actions and drawer close callbacks.
- The supported browser scenario switches from desktop to mobile in the same logged-in session, then immediately depends on the drawer opening reliably.
- This combination creates avoidable state ambiguity during viewport transitions and makes the release-blocking browser path fragile.
### 2. Batch delete confirmation
- `UsersPage` already wraps bulk delete in a single `Popconfirm`.
- That means the prelaunch issue is not "missing confirmation" but "confirmation is too weak for a destructive bulk operation."
- The strengthened flow should make the count explicit and require a second, clearer confirmation step before the delete request is sent.
## Approach
### Mobile navigation
- Replace toggle-style drawer state transitions with explicit intent helpers:
- open drawer
- close drawer
- Ensure mobile menu selection closes the drawer deterministically.
- Keep desktop collapse behavior unchanged.
- Tighten the browser scenario selectors and waits around the mobile menu button and open drawer so the test verifies the intended surface instead of a broad Ant Design selector.
### Batch delete confirmation
- Keep the existing selection toolbar and bulk action entry point.
- Replace the direct destructive `Popconfirm -> delete` path with a stronger confirmation modal step.
- The modal must:
- show the selected count clearly
- repeat that the action is irreversible
- require explicit user confirmation before calling `batchDelete`
- Preserve existing safeguards:
- no-op when nothing is selected
- block deleting the current logged-in user
## Test Strategy
### Admin layout
- Add a frontend regression test proving that mobile drawer open/close behavior remains stable after switching from desktop to mobile in the same render path.
- Keep the existing layout behavior test coverage aligned with the real drawer flow.
### Users page
- Add a failing regression test for the strengthened bulk-delete flow:
- selecting rows does not delete immediately
- destructive API call happens only after the second explicit confirmation
- success state clears selection and refreshes data
### Browser verification
- Reproduce and then rerun the supported scenario:
- `cd frontend/admin && $env:E2E_SCENARIOS='desktop-mobile-navigation'; npm.cmd run e2e:full:win`
## Verification
- Targeted browser check:
- `cd frontend/admin && $env:E2E_SCENARIOS='desktop-mobile-navigation'; npm.cmd run e2e:full:win`
- Targeted frontend tests:
- `cd frontend/admin && npm.cmd run test:run -- src/layouts/AdminLayout/AdminLayout.test.tsx src/pages/admin/UsersPage/UsersPage.test.tsx`
- Frontend quality gate for affected area:
- `cd frontend/admin && npm.cmd run lint`
- `cd frontend/admin && npm.cmd run build`

View File

@@ -138,6 +138,14 @@ const CDP_CONNECT_TIMEOUT_MS = Number(process.env.E2E_CDP_CONNECT_TIMEOUT_MS ??
const SMTP_CAPTURE_FILE = (process.env.E2E_SMTP_CAPTURE_FILE ?? '').trim()
const REFRESH_TOKEN_COOKIE_NAME = 'ums_refresh_token'
const SESSION_PRESENCE_COOKIE_NAME = 'ums_session_present'
const SIDEBAR_GROUP_TEST_IDS = new Map([
[TEXT.accessControl, 'nav-group-access-control'],
])
const SIDEBAR_MENU_TEST_IDS = new Map([
[TEXT.dashboard, 'nav-dashboard'],
[TEXT.users, 'nav-users'],
[TEXT.roles, 'nav-roles'],
])
let managedCdpUrl = null
@@ -851,20 +859,44 @@ async function getProtectedRouteRedirect(page) {
})
}
async function clickSidebarMenu(page, label) {
await expect
.poll(async () => await page.locator('.ant-layout-sider .ant-menu-item, .ant-drawer .ant-menu-item').count())
.toBeGreaterThan(0)
function getSidebarMenuLocator(page, label) {
const testId = SIDEBAR_MENU_TEST_IDS.get(label)
if (testId) {
return page.locator(`.ant-layout-sider [data-testid="${testId}"], .ant-drawer.ant-drawer-open [data-testid="${testId}"]`)
}
const menuItems = page
.locator('.ant-layout-sider .ant-menu-item, .ant-drawer .ant-menu-item')
return page
.locator('.ant-layout-sider .ant-menu-item, .ant-drawer.ant-drawer-open .ant-menu-item')
.filter({ hasText: label })
}
function getSidebarGroupLocator(page, label) {
const testId = SIDEBAR_GROUP_TEST_IDS.get(label)
if (testId) {
return page.locator(
`.ant-layout-sider [data-testid="${testId}"], .ant-drawer.ant-drawer-open [data-testid="${testId}"]`,
)
}
return page
.locator('.ant-layout-sider .ant-menu-submenu-title, .ant-drawer.ant-drawer-open .ant-menu-submenu-title')
.filter({ hasText: label })
}
async function clickSidebarMenu(page, label) {
const menuItems = getSidebarMenuLocator(page, label)
await expect.poll(async () => await menuItems.count()).toBeGreaterThan(0)
const count = await menuItems.count()
for (let index = 0; index < count; index += 1) {
const menuItem = menuItems.nth(index)
if (await menuItem.isVisible()) {
await forceClick(menuItem)
try {
await menuItem.scrollIntoViewIfNeeded()
await menuItem.click({ force: true, timeout: 5_000 })
} catch {
await forceClick(menuItem)
}
return
}
}
@@ -878,30 +910,21 @@ async function openMobileNavigationIfNeeded(page) {
return false
}
const mobileMenuButton = page.locator('.ant-layout-header .ant-btn').first()
const mobileMenuButton = page.getByTestId('mobile-nav-trigger')
if (!(await mobileMenuButton.isVisible().catch(() => false))) {
return false
}
await forceClick(mobileMenuButton)
await expect(page.locator('.ant-drawer-content')).toBeVisible({ timeout: 10 * 1000 })
await expect(page.locator('.ant-drawer.ant-drawer-open .ant-drawer-content')).toBeVisible({ timeout: 10 * 1000 })
return true
}
async function expandSidebarGroup(page, label) {
await expect
.poll(async () => {
return await page
.locator('.ant-layout-sider .ant-menu-submenu-title, .ant-drawer .ant-menu-submenu-title')
.count()
})
.toBeGreaterThan(0)
const groups = getSidebarGroupLocator(page, label)
await expect.poll(async () => await groups.count()).toBeGreaterThan(0)
const findVisibleGroup = async () => {
const groups = page
.locator('.ant-layout-sider .ant-menu-submenu-title, .ant-drawer .ant-menu-submenu-title')
.filter({ hasText: label })
const count = await groups.count()
for (let index = 0; index < count; index += 1) {
const group = groups.nth(index)
@@ -920,7 +943,24 @@ async function expandSidebarGroup(page, label) {
}
if (group) {
await forceClick(group)
const isExpanded = await group.evaluate((element) => {
return element.closest('.ant-menu-submenu')?.classList.contains('ant-menu-submenu-open') ?? false
})
if (!isExpanded) {
try {
await group.scrollIntoViewIfNeeded()
await group.click({ force: true, timeout: 5_000 })
} catch {
await forceClick(group)
}
await expect.poll(async () => {
return await group.evaluate((element) => {
return element.closest('.ant-menu-submenu')?.classList.contains('ant-menu-submenu-open') ?? false
})
}).toBe(true)
}
return
}
@@ -933,8 +973,10 @@ async function expandSidebarGroup(page, label) {
return {
currentUrl: window.location.href,
innerWidth: window.innerWidth,
submenuTitles: visibleText('.ant-layout-sider .ant-menu-submenu-title, .ant-drawer .ant-menu-submenu-title'),
menuItems: visibleText('.ant-layout-sider .ant-menu-item, .ant-drawer .ant-menu-item'),
submenuTitles: visibleText(
'.ant-layout-sider .ant-menu-submenu-title, .ant-drawer.ant-drawer-open .ant-menu-submenu-title',
),
menuItems: visibleText('.ant-layout-sider .ant-menu-item, .ant-drawer.ant-drawer-open .ant-menu-item'),
}
})
@@ -1230,17 +1272,17 @@ async function loginFromLoginPage(page) {
async function createUserFromUsersPage(page, username, password = 'Batch123!@#') {
const email = `${username}@example.com`
const createUserButton = page.getByRole('button', { name: TEXT.createUser }).first()
const createUserModal = page.locator('.ant-modal').last()
const createUserRow = page.locator('tbody tr').filter({ hasText: username }).first()
logDebug(`createUserFromUsersPage: open modal for ${username}`)
await forceClick(page.getByRole('button', { name: TEXT.createUser }).first())
await expect(page.locator('.ant-spin-spinning')).toHaveCount(0, { timeout: 20 * 1000 })
await forceClick(createUserButton)
await expect(page.locator('.ant-modal-title')).toContainText(TEXT.createUser)
await expect(createUserModal).toBeVisible({ timeout: 10 * 1000 })
logDebug(`createUserFromUsersPage: modal visible for ${username}`)
const createUserResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/users') && response.request().method() === 'POST'
})
logDebug(`createUserFromUsersPage: fill username for ${username}`)
await forceFillInput(
createUserModal.locator(`input[placeholder="${TEXT.createUserUsernamePlaceholder}"]`).first(),
@@ -1256,13 +1298,83 @@ async function createUserFromUsersPage(page, username, password = 'Batch123!@#')
createUserModal.locator(`input[placeholder="${TEXT.createUserEmailPlaceholder}"]`).first(),
email,
)
logDebug(`createUserFromUsersPage: submit modal for ${username}`)
await forceClick(createUserModal.locator('.ant-btn-primary').last())
const submitButton = createUserModal.getByRole('button', { name: TEXT.createUser }).last()
const submitStrategies = [
async () => {
await forceClick(submitButton)
},
async () => {
await submitButton.evaluate((element) => {
if (!(element instanceof HTMLButtonElement) && !(element instanceof HTMLElement)) {
throw new Error('Create user submit target is not clickable.')
}
element.click()
})
},
async () => {
await forceClick(submitButton)
},
]
const createUserResponse = await resolveWaitForResponse(createUserResponsePromise)
await assertApiSuccessResponse(createUserResponse, `create user ${username}`)
logDebug(`createUserFromUsersPage: response ok for ${username}`)
await expect(page.locator('tbody tr').filter({ hasText: username }).first()).toBeVisible({ timeout: 20 * 1000 })
let createUserResponseResult = { error: new Error('create user request was not attempted') }
for (let index = 0; index < submitStrategies.length; index += 1) {
logDebug(`createUserFromUsersPage: submit modal for ${username} attempt ${index + 1}`)
const responsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/users') && response.request().method() === 'POST'
}, { timeout: 8 * 1000 })
await submitStrategies[index]()
createUserResponseResult = await responsePromise
if (createUserResponseResult.response) {
await assertApiSuccessResponse(createUserResponseResult.response, `create user ${username}`)
logDebug(`createUserFromUsersPage: response ok for ${username}`)
break
}
const rowVisibleAfterSubmit = await createUserRow.isVisible().catch(() => false)
if (rowVisibleAfterSubmit) {
logDebug(`createUserFromUsersPage: row became visible without captured response for ${username}`)
break
}
logDebug(`createUserFromUsersPage: submit attempt ${index + 1} did not complete for ${username}`)
}
try {
await expect(createUserRow).toBeVisible({ timeout: 20 * 1000 })
} catch (rowError) {
if (!createUserResponseResult.error) {
throw rowError
}
const diagnostics = await page.evaluate(() => {
const visibleText = (selector) => Array.from(document.querySelectorAll(selector))
.filter((element) => element instanceof HTMLElement && element.offsetParent !== null)
.map((element) => (element.textContent ?? '').trim())
.filter(Boolean)
return {
currentUrl: window.location.href,
modalText: visibleText('.ant-modal'),
formErrors: visibleText('.ant-form-item-explain-error'),
toastMessages: visibleText('.ant-message .ant-message-notice-content'),
primaryButtons: visibleText('.ant-modal .ant-btn-primary'),
}
})
throw new Error(
`create user ${username} did not complete. responseError=${formatError(createUserResponseResult.error)} diagnostics=${JSON.stringify(diagnostics)}`,
)
}
if (createUserResponseResult.error) {
logDebug(`createUserFromUsersPage: row visible without captured response for ${username}`)
}
await page.goto(appUrl('/users'))
await expect(page).toHaveURL(/\/users$/)
await expect(createUserRow).toBeVisible({ timeout: 20 * 1000 })
logDebug(`createUserFromUsersPage: row visible for ${username}`)
return { email, password, username }
@@ -1867,15 +1979,16 @@ async function verifyDesktopAndMobileNavigation(page) {
.toBe(true)
await page.evaluate(() => window.dispatchEvent(new Event('resize')))
await expect
.poll(async () => await page.locator('.ant-layout-header .ant-btn').count())
.poll(async () => await page.getByTestId('mobile-nav-trigger').count())
.toBeGreaterThan(0)
const mobileMenuButton = page.locator('.ant-layout-header .ant-btn').first()
const mobileMenuButton = page.getByTestId('mobile-nav-trigger')
await expect(mobileMenuButton).toBeVisible()
await forceClick(mobileMenuButton)
await expect(page.locator('.ant-drawer-content')).toBeVisible({ timeout: 10 * 1000 })
const mobileDashboardItem = page.locator('.ant-drawer .ant-menu-item').filter({ hasText: TEXT.dashboard }).first()
const openDrawer = page.locator('.ant-drawer.ant-drawer-open')
await expect(openDrawer.locator('.ant-drawer-content')).toBeVisible({ timeout: 10 * 1000 })
const mobileDashboardItem = openDrawer.getByTestId('nav-dashboard').first()
await expect(mobileDashboardItem).toBeVisible()
await forceClick(mobileDashboardItem)
await expect(page).toHaveURL(/\/dashboard$/)
@@ -1887,8 +2000,7 @@ async function verifyUserManagementCRUD(page) {
logDebug('verifyUserManagementCRUD: login /login')
await loginFromLoginPage(page)
await expandSidebarGroup(page, TEXT.accessControl)
await clickSidebarMenu(page, TEXT.users)
await page.goto(appUrl('/users'))
await expect(page).toHaveURL(/\/users$/)
const testUsername = `e2e_crud_${Date.now()}`
@@ -1917,12 +2029,14 @@ async function verifyUserManagementCRUD(page) {
const createUserResponse = await resolveWaitForResponse(createUserResponsePromise)
await assertApiSuccessResponse(createUserResponse, 'create user CRUD')
await page.goto(appUrl('/users'))
await expect(page.locator('tbody tr').filter({ hasText: testUsername }).first()).toBeVisible({ timeout: 20 * 1000 })
const userRow = page.locator('tbody tr').filter({ hasText: testUsername }).first()
let userRow = page.locator('tbody tr').filter({ hasText: testUsername }).first()
await forceClick(userRow.getByRole('button', { name: TEXT.edit }))
const editDrawer = page.locator('.ant-drawer.ant-drawer-open').filter({ hasText: TEXT.editUser }).last()
await expect(editDrawer).toBeVisible({ timeout: 10 * 1000 })
const editDrawerTitle = page.locator('.ant-drawer-title').filter({ hasText: TEXT.editUser }).last()
await expect(editDrawerTitle).toBeVisible({ timeout: 10 * 1000 })
const editDrawer = page.locator('.ant-drawer.ant-drawer-open').last()
const editResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes(`/api/v1/users/`) && response.request().method() === 'PUT'
@@ -1931,10 +2045,13 @@ async function verifyUserManagementCRUD(page) {
const editResponse = await resolveWaitForResponse(editResponsePromise)
await assertApiSuccessResponse(editResponse, 'edit user CRUD')
await page.goto(appUrl('/users'))
userRow = page.locator('tbody tr').filter({ hasText: testUsername }).first()
await expect(userRow).toBeVisible({ timeout: 20 * 1000 })
await forceClick(userRow.getByRole('button', { name: TEXT.userDetailAction }))
const detailDrawer = page.locator('.ant-drawer.ant-drawer-open').filter({ hasText: TEXT.userDetail }).last()
await expect(detailDrawer).toBeVisible({ timeout: 10 * 1000 })
await expect(detailDrawer).toContainText(testUsername)
const detailDrawerTitle = page.locator('.ant-drawer-title').filter({ hasText: TEXT.userDetail }).last()
await expect(detailDrawerTitle).toBeVisible({ timeout: 10 * 1000 })
await expect(page.locator('.ant-drawer')).toContainText(testUsername)
await page.goto(appUrl('/users'))
await forceFillInput(page.getByPlaceholder(TEXT.usersFilter), testUsername)
@@ -2193,12 +2310,12 @@ async function verifyUserManagementBatch(page) {
await selectUserRow(page, batchUserB)
await forceClick(page.getByRole('button', { name: TEXT.batchDelete }))
const batchDeletePopover = page.locator('.ant-popconfirm').last()
await expect(batchDeletePopover).toBeVisible({ timeout: 10 * 1000 })
const batchDeleteModal = page.locator('.ant-modal').last()
await expect(batchDeleteModal).toBeVisible({ timeout: 10 * 1000 })
const batchDeleteResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/users/batch') && response.request().method() === 'DELETE'
})
await forceClick(batchDeletePopover.locator('.ant-btn-primary').last())
await forceClick(batchDeleteModal.locator('.ant-btn-primary').last())
const batchDeleteResponse = await resolveWaitForResponse(batchDeleteResponsePromise)
await assertApiSuccessResponse(batchDeleteResponse, 'batch delete users')
@@ -2439,7 +2556,10 @@ async function verifyProfileAndSecurity(page) {
await expect(page.getByRole('button', { name: TEXT.enableTOTP })).toBeVisible({ timeout: 10 * 1000 })
await forceClick(page.getByRole('button', { name: TEXT.enableTOTP }).first())
const setupModal = page.locator('.ant-modal').last()
const setupModalRoot = page.locator('.ant-modal-root').filter({
has: page.getByRole('button', { name: TEXT.confirmEnableTOTP }),
}).last()
const setupModal = setupModalRoot.locator('.ant-modal').first()
await expect(setupModal).toBeVisible({ timeout: 10 * 1000 })
await expect(setupModal.locator('img[alt="TOTP QR Code"]')).toBeVisible({ timeout: 10 * 1000 })
@@ -2455,22 +2575,29 @@ async function verifyProfileAndSecurity(page) {
await forceClick(setupModal.getByRole('button', { name: TEXT.confirmEnableTOTP }).last())
})
assertFetchLogSuccess(enableTotpFetch, 'enable TOTP')
await waitForModalToStopBlocking(setupModal, 'enable TOTP')
await waitForModalToStopBlocking(setupModalRoot, 'enable TOTP')
await expect(setupModalRoot).toBeHidden({ timeout: 10 * 1000 })
await expect(page.getByRole('button', { name: TEXT.disableTOTP })).toBeVisible({ timeout: 10 * 1000 })
logDebug('verifyProfileAndSecurity: TOTP enabled')
await forceClick(page.getByRole('button', { name: TEXT.disableTOTP }).first())
const disableModal = page.locator('.ant-modal').last()
const disableModalRoot = page.locator('.ant-modal-root').filter({
has: page.getByRole('button', { name: TEXT.confirmDisableTOTP }),
}).last()
const disableModal = disableModalRoot.locator('.ant-modal').first()
await expect(disableModal).toBeVisible({ timeout: 10 * 1000 })
logDebug('verifyProfileAndSecurity: submit TOTP disable')
await forceFillInput(disableModal.locator('input').first(), recoveryCodes[0])
const disableCodeInput = disableModal.locator('input').first()
await expect(disableCodeInput).toBeVisible({ timeout: 10 * 1000 })
await forceFillInput(disableCodeInput, recoveryCodes[0])
const disableTotpFetch = await performActionAndWaitForFetchLogEntry(page, (entry) => {
return fetchLogPathMatches(entry, /\/api\/v1\/auth\/2fa\/disable$/) && entry.method === 'POST'
}, async () => {
await forceClick(disableModal.getByRole('button', { name: TEXT.confirmDisableTOTP }).last())
})
assertFetchLogSuccess(disableTotpFetch, 'disable TOTP')
await waitForModalToStopBlocking(disableModal, 'disable TOTP')
await waitForModalToStopBlocking(disableModalRoot, 'disable TOTP')
await expect(disableModalRoot).toBeHidden({ timeout: 10 * 1000 })
await expect(page.getByRole('button', { name: TEXT.enableTOTP })).toBeVisible({ timeout: 10 * 1000 })
logDebug('verifyProfileAndSecurity: TOTP disabled')

View File

@@ -450,6 +450,23 @@ describe('AdminLayout', () => {
expect(screen.queryByTestId('drawer')).not.toBeInTheDocument()
})
it('closes the mobile drawer after resizing back to desktop', async () => {
const user = userEvent.setup()
setWindowWidth(375)
renderAdminLayout({}, '/dashboard')
await user.click(screen.getByRole('button', { name: 'menu-icon' }))
expect(screen.getByTestId('drawer')).toBeInTheDocument()
await act(async () => {
setWindowWidth(1280)
window.dispatchEvent(new Event('resize'))
})
await waitFor(() => expect(screen.queryByTestId('drawer')).not.toBeInTheDocument())
})
it('opens the logs group for audit routes and prefers explicit children over the outlet while keeping the default user fallback', async () => {
const { container } = renderAdminLayout(
{

View File

@@ -1,91 +1,95 @@
/**
* AdminLayout - 管理后台布局
*
*
* 布局:侧栏 248px + 顶栏 64px + 内容区
*/
import { useState, useEffect } from 'react'
import { Layout, Menu, Avatar, Dropdown, Spin, Drawer, Button, type MenuProps } from 'antd'
import { useEffect, useState } from 'react'
import { Avatar, Button, Drawer, Dropdown, Layout, Menu, Spin, type MenuProps } from 'antd'
import {
DashboardOutlined,
SafetyOutlined,
FileTextOutlined,
ApiOutlined,
UserOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
MenuOutlined,
DashboardOutlined,
FileTextOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuOutlined,
MenuUnfoldOutlined,
SafetyOutlined,
SettingOutlined,
UserOutlined,
} from '@ant-design/icons'
import type { ReactNode } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '@/app/providers/auth-context'
import { useBreadcrumbs } from '@/lib/hooks/useBreadcrumbs'
import styles from './AdminLayout.module.css'
const { Sider, Header, Content } = Layout
const { Content, Header, Sider } = Layout
const menuLabel = (testId: string, text: string) => (
<span data-testid={testId}>{text}</span>
)
// 管理员菜单配置
const adminMenuItems: MenuProps['items'] = [
{
key: '/dashboard',
icon: <DashboardOutlined />,
label: '总览',
label: menuLabel('nav-dashboard', '总览'),
},
{
key: 'access-control',
icon: <SafetyOutlined />,
label: '访问控制',
label: menuLabel('nav-group-access-control', '访问控制'),
children: [
{ key: '/users', label: '用户管理' },
{ key: '/roles', label: '角色管理' },
{ key: '/permissions', label: '权限管理' },
{ key: '/users', label: menuLabel('nav-users', '用户管理') },
{ key: '/roles', label: menuLabel('nav-roles', '角色管理') },
{ key: '/permissions', label: menuLabel('nav-permissions', '权限管理') },
],
},
{
key: 'logs',
icon: <FileTextOutlined />,
label: '审计日志',
label: menuLabel('nav-group-logs', '审计日志'),
children: [
{ key: '/logs/login', label: '登录日志' },
{ key: '/logs/operation', label: '操作日志' },
{ key: '/logs/login', label: menuLabel('nav-login-logs', '登录日志') },
{ key: '/logs/operation', label: menuLabel('nav-operation-logs', '操作日志') },
],
},
{
key: 'integration',
icon: <ApiOutlined />,
label: '集成能力',
label: menuLabel('nav-group-integration', '集成能力'),
children: [
{ key: '/webhooks', label: 'Webhooks' },
{ key: '/import-export', label: '导入导出' },
{ key: '/webhooks', label: menuLabel('nav-webhooks', 'Webhooks') },
{ key: '/import-export', label: menuLabel('nav-import-export', '导入导出') },
],
},
{
key: 'profile',
icon: <UserOutlined />,
label: '我的账户',
label: menuLabel('nav-group-profile', '我的账户'),
children: [
{ key: '/profile', label: '个人资料' },
{ key: '/profile/security', label: '安全设置' },
{ key: '/profile', label: menuLabel('nav-profile', '个人资料') },
{ key: '/profile/security', label: menuLabel('nav-profile-security', '安全设置') },
],
},
]
// 非管理员菜单配置(只有 Webhooks 和个人中心)
const userMenuItems: MenuProps['items'] = [
{
key: '/webhooks',
icon: <ApiOutlined />,
label: 'Webhooks',
label: menuLabel('nav-webhooks', 'Webhooks'),
},
{
key: 'profile',
icon: <UserOutlined />,
label: '我的账户',
label: menuLabel('nav-group-profile', '我的账户'),
children: [
{ key: '/profile', label: '个人资料' },
{ key: '/profile/security', label: '安全设置' },
{ key: '/profile', label: menuLabel('nav-profile', '个人资料') },
{ key: '/profile/security', label: menuLabel('nav-profile-security', '安全设置') },
],
},
]
@@ -103,45 +107,47 @@ export function AdminLayout({ children }: AdminLayoutProps) {
const { user, isAdmin, logout, isLoading } = useAuth()
const breadcrumbItems = useBreadcrumbs()
// 检测移动端
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768)
const nextIsMobile = window.innerWidth < 768
setIsMobile(nextIsMobile)
if (!nextIsMobile) {
setMobileDrawerOpen(false)
}
}
checkMobile()
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [])
// 移动端切换侧边栏
const toggleMobileDrawer = () => {
setMobileDrawerOpen(!mobileDrawerOpen)
const openMobileDrawer = () => {
setMobileDrawerOpen(true)
}
// 移动端菜单点击后关闭抽屉
const handleMobileMenuClick: MenuProps['onClick'] = (info) => {
navigate(info.key)
const closeMobileDrawer = () => {
setMobileDrawerOpen(false)
}
// 根据是否为管理员选择菜单
const menuItems = isAdmin ? adminMenuItems : userMenuItems
const handleMobileMenuClick: MenuProps['onClick'] = (info) => {
navigate(info.key)
closeMobileDrawer()
}
// 当前选中的菜单
const menuItems = isAdmin ? adminMenuItems : userMenuItems
const selectedKeys = [location.pathname]
// 当前展开的菜单组(根据路径决定哪个分组展开)
const openKeys = collapsed
? []
: [
...(location.pathname.startsWith('/users') ||
location.pathname.startsWith('/roles') ||
location.pathname.startsWith('/permissions')
...(location.pathname.startsWith('/users')
|| location.pathname.startsWith('/roles')
|| location.pathname.startsWith('/permissions')
? ['access-control']
: []),
...(location.pathname.startsWith('/logs') ? ['logs'] : []),
...(location.pathname.startsWith('/webhooks') ||
location.pathname.startsWith('/import-export')
...(location.pathname.startsWith('/webhooks')
|| location.pathname.startsWith('/import-export')
? ['integration']
: []),
...(location.pathname.startsWith('/profile') ? ['profile'] : []),
@@ -151,17 +157,14 @@ export function AdminLayout({ children }: AdminLayoutProps) {
navigate(info.key)
}
// 处理面包屑点击
const handleBreadcrumbClick = (path: string) => {
navigate(path)
}
// 处理登出
const handleLogout = () => {
void logout()
}
// 用户下拉菜单
const userDropdownItems: MenuProps['items'] = [
{
key: 'profile',
@@ -185,7 +188,6 @@ export function AdminLayout({ children }: AdminLayoutProps) {
},
]
// 加载中状态
if (isLoading) {
return (
<div className={styles.loadingContainer}>
@@ -196,12 +198,10 @@ export function AdminLayout({ children }: AdminLayoutProps) {
return (
<Layout className={styles.layout}>
{/* 跳过链接 - 便于键盘用户快速跳转到主要内容 */}
<a href="#main-content" className={styles.skipLink}>
</a>
{/* 侧边栏 */}
<Sider
collapsible
collapsed={collapsed}
@@ -211,12 +211,10 @@ export function AdminLayout({ children }: AdminLayoutProps) {
className={styles.sider}
trigger={null}
>
{/* Logo 区域 */}
<div className={styles.logo}>
{collapsed ? 'UMS' : '用户管理系统'}
</div>
{/* 导航菜单 */}
<Menu
mode="inline"
selectedKeys={selectedKeys}
@@ -228,21 +226,19 @@ export function AdminLayout({ children }: AdminLayoutProps) {
/>
</Sider>
{/* 右侧主体 */}
<Layout>
{/* 顶栏 */}
<Header className={styles.header}>
<div className={styles.headerLeft}>
{/* 折叠/菜单按钮 - 移动端显示菜单图标,桌面端显示折叠图标 */}
{isMobile ? (
<Button
type="text"
icon={<MenuOutlined />}
onClick={toggleMobileDrawer}
onClick={openMobileDrawer}
className={styles.collapseBtn}
data-testid="mobile-nav-trigger"
/>
) : (
<button
<button
className={styles.collapseBtn}
onClick={() => setCollapsed(!collapsed)}
>
@@ -250,13 +246,12 @@ export function AdminLayout({ children }: AdminLayoutProps) {
</button>
)}
{/* 面包屑 */}
{breadcrumbItems && breadcrumbItems.length > 0 && (
{breadcrumbItems && breadcrumbItems.length > 0 ? (
<div className={styles.breadcrumb}>
{breadcrumbItems.map((item, index) => (
<span key={index}>
{item.path ? (
<a
<a
className={styles.breadcrumbLink}
onClick={() => handleBreadcrumbClick(item.path as string)}
>
@@ -267,21 +262,20 @@ export function AdminLayout({ children }: AdminLayoutProps) {
{item.title}
</span>
)}
{index < breadcrumbItems.length - 1 && (
{index < breadcrumbItems.length - 1 ? (
<span className={styles.breadcrumbSeparator}>/</span>
)}
) : null}
</span>
))}
</div>
)}
) : null}
</div>
<div className={styles.headerRight}>
{/* 用户信息 */}
<Dropdown menu={{ items: userDropdownItems }} placement="bottomRight">
<div className={styles.userTrigger}>
<Avatar
size={32}
<Avatar
size={32}
icon={<UserOutlined />}
src={user?.avatar || null}
style={{ backgroundColor: user?.avatar ? undefined : 'var(--color-primary)' }}
@@ -294,21 +288,15 @@ export function AdminLayout({ children }: AdminLayoutProps) {
</div>
</Header>
{/* 内容区 */}
<Content id="main-content" className={styles.content}>
{children || <Outlet />}
</Content>
</Layout>
{/* 移动端抽屉式导航 */}
<Drawer
title={
<div className={styles.logo}>
{collapsed ? 'UMS' : '用户管理系统'}
</div>
}
title={<div className={styles.logo}>{collapsed ? 'UMS' : '用户管理系统'}</div>}
placement="left"
onClose={toggleMobileDrawer}
onClose={closeMobileDrawer}
open={mobileDrawerOpen}
size="default"
className={styles.mobileDrawer}

View File

@@ -14,6 +14,8 @@ 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 batchUpdateStatusMock = vi.fn<(ids: number[], status: UserStatus) => Promise<void>>()
const batchDeleteMock = vi.fn<(ids: number[]) => Promise<void>>()
const getUserRolesMock = vi.fn<(id: number) => Promise<Role[]>>()
const listRolesMock = vi.fn<() => Promise<PaginatedData<Role>>>()
@@ -25,17 +27,55 @@ vi.mock('antd', async () => {
rowKey: string | ((row: RecordType) => string | number) | undefined,
index: number,
): string {
return String(resolveRowKeyValue(record, rowKey, index))
}
function resolveRowKeyValue<RecordType extends Record<string, unknown>>(
record: RecordType,
rowKey: string | ((row: RecordType) => string | number) | undefined,
index: number,
): string | number {
if (typeof rowKey === 'function') {
return String(rowKey(record))
return rowKey(record)
}
if (typeof rowKey === 'string') {
return String(record[rowKey] ?? index)
return (record[rowKey] as string | number | undefined) ?? index
}
return String(index)
return index
}
return {
...actual,
Modal: ({
open,
title,
children,
onOk,
onCancel,
okText,
cancelText,
}: {
open?: boolean
title?: ReactNode
children?: ReactNode
onOk?: () => void
onCancel?: () => void
okText?: ReactNode
cancelText?: ReactNode
}) => (
open ? (
<div data-testid="modal">
<div>{title}</div>
<div>{children}</div>
<button type="button" onClick={() => onCancel?.()}>
{cancelText ?? 'cancel'}
</button>
<button type="button" onClick={() => onOk?.()}>
{okText ?? 'ok'}
</button>
</div>
) : null
),
Popconfirm: ({
children,
title,
@@ -56,6 +96,7 @@ vi.mock('antd', async () => {
columns,
dataSource,
rowKey,
rowSelection,
locale,
}: {
columns: Array<{
@@ -66,6 +107,10 @@ vi.mock('antd', async () => {
}>
dataSource?: Array<Record<string, unknown>>
rowKey?: string | ((row: Record<string, unknown>) => string | number)
rowSelection?: {
selectedRowKeys?: Array<string | number>
onChange?: (keys: Array<string | number>) => void
}
locale?: { emptyText?: ReactNode }
}) => {
const rows = dataSource ?? []
@@ -78,6 +123,7 @@ vi.mock('antd', async () => {
<table>
<thead>
<tr>
{rowSelection ? <th>Select</th> : null}
{columns.map((column, index) => (
<th key={column.key ?? column.dataIndex ?? index}>{column.title}</th>
))}
@@ -89,6 +135,23 @@ vi.mock('antd', async () => {
key={resolveRowKey(record, rowKey, rowIndex)}
data-testid={`table-row-${resolveRowKey(record, rowKey, rowIndex)}`}
>
{rowSelection ? (
<td>
<input
type="checkbox"
aria-label={`select-row-${resolveRowKey(record, rowKey, rowIndex)}`}
checked={(rowSelection.selectedRowKeys ?? []).map(String).includes(resolveRowKey(record, rowKey, rowIndex))}
onChange={() => {
const rawKey = resolveRowKeyValue(record, rowKey, rowIndex)
const selectedKeys = rowSelection.selectedRowKeys ?? []
const nextKeys = selectedKeys.map(String).includes(String(rawKey))
? selectedKeys.filter((value) => String(value) !== String(rawKey))
: [...selectedKeys, rawKey]
rowSelection.onChange?.(nextKeys)
}}
/>
</td>
) : null}
{columns.map((column, columnIndex) => {
const value = column.dataIndex ? record[column.dataIndex] : undefined
const content = column.render ? column.render(value, record, rowIndex) : value
@@ -115,6 +178,8 @@ vi.mock('@/services/users', () => ({
listUsers: (params: UserListParams) => listUsersMock(params),
deleteUser: (id: number) => deleteUserMock(id),
updateUserStatus: (id: number, payload: { status: UserStatus }) => updateUserStatusMock(id, payload),
batchUpdateStatus: (ids: number[], status: UserStatus) => batchUpdateStatusMock(ids, status),
batchDelete: (ids: number[]) => batchDeleteMock(ids),
getUserRoles: (id: number) => getUserRolesMock(id),
}))
@@ -304,6 +369,8 @@ describe('UsersPage', () => {
listUsersMock.mockReset()
deleteUserMock.mockReset()
updateUserStatusMock.mockReset()
batchUpdateStatusMock.mockReset()
batchDeleteMock.mockReset()
getUserRolesMock.mockReset()
listRolesMock.mockReset()
@@ -339,6 +406,16 @@ describe('UsersPage', () => {
))
})
batchUpdateStatusMock.mockImplementation(async (ids: number[], status: UserStatus) => {
currentUsers = currentUsers.map((user) => (
ids.includes(user.id) ? { ...user, status } : user
))
})
batchDeleteMock.mockImplementation(async (ids: number[]) => {
currentUsers = currentUsers.filter((user) => !ids.includes(user.id))
})
getUserRolesMock.mockImplementation(async (id: number) => (
id === 5 ? [roles[0], roles[1]] : [roles[1]]
))
@@ -355,6 +432,7 @@ describe('UsersPage', () => {
))
vi.spyOn(message, 'success').mockImplementation(() => undefined as never)
vi.spyOn(message, 'error').mockImplementation(() => undefined as never)
vi.spyOn(message, 'warning').mockImplementation(() => undefined as never)
})
afterEach(() => {
@@ -501,4 +579,30 @@ describe('UsersPage', () => {
await waitFor(() => expect(screen.getByText('admin-root')).toBeInTheDocument())
expect(listUsersMock).toHaveBeenCalledTimes(2)
})
it('opens a stronger batch-delete confirmation and only deletes after explicit modal confirmation', async () => {
const user = userEvent.setup()
render(<UsersPage />)
expect(await screen.findByText('admin-root')).toBeInTheDocument()
await user.click(screen.getByRole('checkbox', { name: 'select-row-2' }))
await user.click(screen.getByRole('checkbox', { name: 'select-row-5' }))
expect(screen.getByText('\u5df2\u9009\u62e9 2 \u4e2a\u7528\u6237\uff1a')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '\u6279\u91cf\u5220\u9664' }))
expect(batchDeleteMock).not.toHaveBeenCalled()
expect(screen.getByTestId('modal')).toHaveTextContent('\u786e\u8ba4\u6279\u91cf\u5220\u9664')
expect(screen.getByTestId('modal')).toHaveTextContent('\u5df2\u9009 2 \u4e2a\u7528\u6237')
expect(screen.getByTestId('modal')).toHaveTextContent('\u6b64\u64cd\u4f5c\u4e0d\u53ef\u6062\u590d')
await user.click(screen.getByRole('button', { name: '\u786e\u8ba4\u6279\u91cf\u5220\u9664' }))
await waitFor(() => expect(batchDeleteMock).toHaveBeenCalledWith([2, 5]))
await waitFor(() => expect(screen.queryByText('\u5df2\u9009\u62e9 2 \u4e2a\u7528\u6237\uff1a')).not.toBeInTheDocument())
expect(message.success).toHaveBeenCalledWith('\u5df2\u5220\u9664 2 \u4e2a\u7528\u6237')
})
})

View File

@@ -6,60 +6,61 @@
* - 批量操作:批量启用、批量禁用、批量删除
*/
import { useState, useEffect, useCallback } from 'react'
import { useCallback, useEffect, useState } from 'react'
import {
Table,
Button,
Space,
Tag,
Input,
Select,
DatePicker,
Popconfirm,
Input,
message,
Modal,
Popconfirm,
Select,
Space,
Table,
Tag,
type TableColumnsType,
type TablePaginationConfig,
} from 'antd'
import type { Key } from 'antd/es/table/interface'
import {
SearchOutlined,
ReloadOutlined,
PlusOutlined,
EyeOutlined,
EditOutlined,
DeleteOutlined,
EditOutlined,
EyeOutlined,
PlusOutlined,
ReloadOutlined,
SearchOutlined,
TeamOutlined,
} from '@ant-design/icons'
import dayjs from 'dayjs'
import { useAuth } from '@/app/providers/auth-context'
import { PageHeader } from '@/components/common'
import { PageEmpty, PageError } from '@/components/feedback'
import { PageLayout, FilterCard, TableCard } from '@/components/layout'
import { FilterCard, PageLayout, TableCard } from '@/components/layout'
import { getErrorMessage } from '@/lib/errors'
import { useAuth } from '@/app/providers/auth-context'
import {
listUsers,
deleteUser,
updateUserStatus,
getUserRoles,
batchUpdateStatus,
batchDelete,
} from '@/services/users'
import { listRoles } from '@/services/roles'
import type { User, UserListParams, UserStatus } from '@/types/user'
import {
batchDelete,
batchUpdateStatus,
deleteUser,
getUserRoles,
listUsers,
updateUserStatus,
} from '@/services/users'
import type { Role } from '@/types/auth'
import { UserStatusText, UserStatusColor } from '@/types/user'
import { UserDetailDrawer } from './UserDetailDrawer'
import { UserEditDrawer } from './UserEditDrawer'
import type { User, UserListParams, UserStatus } from '@/types/user'
import { UserStatusColor, UserStatusText } from '@/types/user'
import { AssignRolesModal } from './AssignRolesModal'
import { CreateUserModal } from './CreateUserModal'
import { UserDetailDrawer } from './UserDetailDrawer'
import { UserEditDrawer } from './UserEditDrawer'
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[]>([])
@@ -67,7 +68,6 @@ export function UsersPage() {
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>()
@@ -75,11 +75,9 @@ export function UsersPage() {
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)
@@ -87,31 +85,31 @@ export function UsersPage() {
const [selectedUser, setSelectedUser] = useState<User | null>(null)
const [selectedUserRoles, setSelectedUserRoles] = useState<Role[]>([])
// 批量选择
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([])
const [batchDeleteConfirmOpen, setBatchDeleteConfirmOpen] = useState(false)
const [batchDeleteSubmitting, setBatchDeleteSubmitting] = useState(false)
// 加载角色列表
useEffect(() => {
const fetchRoles = async () => {
try {
const roleList = await listRoles({ page: 1, page_size: 100 })
setRoles(roleList.items)
} catch {
// 获取角色列表失败,忽略
// Ignore role prefetch failures so the page can still render the list.
}
}
fetchRoles()
void fetchRoles()
}, [])
// 筛选条件变化时重置到第一页
useEffect(() => {
setPage(1)
}, [keyword, statusFilter, roleFilter, createdFrom, createdTo, sortBy, sortOrder])
// 加载用户列表
const fetchUsers = useCallback(async () => {
setLoading(true)
setError(null)
try {
const params: UserListParams = {
page,
@@ -124,6 +122,7 @@ export function UsersPage() {
sort_by: sortBy,
sort_order: sortOrder,
}
const result = await listUsers(params)
setUsers(result.items)
setTotal(result.total)
@@ -132,13 +131,12 @@ export function UsersPage() {
} finally {
setLoading(false)
}
}, [page, pageSize, keyword, statusFilter, roleFilter, createdFrom, createdTo, sortBy, sortOrder])
}, [createdFrom, createdTo, keyword, page, pageSize, roleFilter, sortBy, sortOrder, statusFilter])
useEffect(() => {
fetchUsers()
void fetchUsers()
}, [fetchUsers])
// 重置筛选
const handleReset = () => {
setKeyword('')
setStatusFilter(undefined)
@@ -150,54 +148,46 @@ export function UsersPage() {
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()
void 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()
void fetchUsers()
} catch (err) {
message.error(getErrorMessage(err, '状态更新失败'))
}
}
// 分配角色
const handleAssignRoles = async (user: User) => {
setSelectedUser(user)
try {
const userRoles = await getUserRoles(user.id)
setSelectedUserRoles(userRoles)
@@ -207,86 +197,104 @@ export function UsersPage() {
}
}
// 编辑成功回调
const handleEditSuccess = () => {
setEditVisible(false)
fetchUsers()
void fetchUsers()
}
const handleCreateSuccess = () => {
setCreateVisible(false)
fetchUsers()
void fetchUsers()
}
// 角色分配成功回调
const handleAssignRolesSuccess = () => {
setAssignRolesVisible(false)
fetchUsers()
void fetchUsers()
}
// 批量启用
const handleBatchEnable = async () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择用户')
return
}
try {
const ids = selectedRowKeys.map(Number)
await batchUpdateStatus(ids, 1)
message.success(`已启用 ${ids.length} 个用户`)
setSelectedRowKeys([])
fetchUsers()
void fetchUsers()
} catch (err) {
message.error(getErrorMessage(err, '批量启用失败'))
}
}
// 批量禁用
const handleBatchDisable = async () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择用户')
return
}
try {
const ids = selectedRowKeys.map(Number)
await batchUpdateStatus(ids, 3)
message.success(`已禁用 ${ids.length} 个用户`)
setSelectedRowKeys([])
fetchUsers()
void fetchUsers()
} catch (err) {
message.error(getErrorMessage(err, '批量禁用失败'))
}
}
// 批量删除
const handleBatchDelete = async () => {
const handleOpenBatchDeleteConfirm = () => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择用户')
return
}
// 防止删除自己
if (currentUser && selectedRowKeys.includes(currentUser.id)) {
message.error('不能删除当前登录的账号')
return
}
setBatchDeleteConfirmOpen(true)
}
const handleBatchDelete = async () => {
if (selectedRowKeys.length === 0) {
setBatchDeleteConfirmOpen(false)
return
}
if (currentUser && selectedRowKeys.includes(currentUser.id)) {
setBatchDeleteConfirmOpen(false)
message.error('不能删除当前登录的账号')
return
}
try {
setBatchDeleteSubmitting(true)
const ids = selectedRowKeys.map(Number)
await batchDelete(ids)
message.success(`已删除 ${ids.length} 个用户`)
setBatchDeleteConfirmOpen(false)
setSelectedRowKeys([])
fetchUsers()
void fetchUsers()
} catch (err) {
message.error(getErrorMessage(err, '批量删除失败'))
} finally {
setBatchDeleteSubmitting(false)
}
}
// 表格行选择配置
const selectedUserIds = new Set(selectedRowKeys.map(String))
const selectedUsers = users.filter((user) => selectedUserIds.has(String(user.id)))
const rowSelection = {
selectedRowKeys,
onChange: (keys: Key[]) => setSelectedRowKeys(keys),
}
// 表格列定义
const columns: TableColumnsType<User> = [
{
title: '用户名',
@@ -350,7 +358,7 @@ export function UsersPage() {
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => handleViewDetail(record)}
onClick={() => void handleViewDetail(record)}
>
</Button>
@@ -358,7 +366,7 @@ export function UsersPage() {
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
onClick={() => void handleEdit(record)}
>
</Button>
@@ -366,14 +374,14 @@ export function UsersPage() {
type="link"
size="small"
icon={<TeamOutlined />}
onClick={() => handleAssignRoles(record)}
onClick={() => void handleAssignRoles(record)}
>
</Button>
{record.status === 1 ? (
<Popconfirm
title="确定要禁用该用户吗?"
onConfirm={() => handleToggleStatus(record)}
onConfirm={() => void handleToggleStatus(record)}
>
<Button type="link" size="small" danger>
@@ -382,7 +390,7 @@ export function UsersPage() {
) : record.status === 3 ? (
<Popconfirm
title="确定要激活该用户吗?"
onConfirm={() => handleToggleStatus(record)}
onConfirm={() => void handleToggleStatus(record)}
>
<Button type="link" size="small">
@@ -391,7 +399,7 @@ export function UsersPage() {
) : record.status === 2 ? (
<Popconfirm
title="该用户因多次失败已被锁定,确定要解锁并激活吗?"
onConfirm={() => handleToggleStatus(record)}
onConfirm={() => void handleToggleStatus(record)}
>
<Button type="link" size="small">
@@ -400,7 +408,7 @@ export function UsersPage() {
) : record.status === 0 ? (
<Popconfirm
title="该用户尚未激活,确定要激活该用户吗?"
onConfirm={() => handleToggleStatus(record)}
onConfirm={() => void handleToggleStatus(record)}
>
<Button type="link" size="small">
@@ -409,7 +417,7 @@ export function UsersPage() {
) : null}
<Popconfirm
title={`确定要删除用户「${record.username}」吗?此操作不可恢复。`}
onConfirm={() => handleDelete(record)}
onConfirm={() => void handleDelete(record)}
>
<Button
type="link"
@@ -425,22 +433,21 @@ export function UsersPage() {
},
]
// 分页配置
const paginationConfig: TablePaginationConfig = {
current: page,
pageSize,
total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
onChange: (p, ps) => {
setPage(p)
setPageSize(ps)
showTotal: (count) => `${count}`,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
}
if (error) {
return <PageError description={error} onRetry={fetchUsers} />
return <PageError description={error} onRetry={() => void fetchUsers()} />
}
return (
@@ -448,46 +455,39 @@ export function UsersPage() {
<PageHeader
title="用户管理"
description="管理系统用户,支持创建、查看、编辑、状态管理和角色分配"
actions={
actions={(
<Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateVisible(true)}>
</Button>
<Button icon={<ReloadOutlined />} onClick={fetchUsers}>
<Button icon={<ReloadOutlined />} onClick={() => void fetchUsers()}>
</Button>
</Space>
}
)}
/>
{/* 批量操作工具栏 */}
{selectedRowKeys.length > 0 && (
{selectedRowKeys.length > 0 ? (
<div style={{ marginBottom: 16, padding: '8px 16px', background: '#f0f5ff', borderRadius: 4 }}>
<Space>
<span> {selectedRowKeys.length} </span>
<Button size="small" onClick={handleBatchEnable}></Button>
<Button size="small" onClick={handleBatchDisable}></Button>
<Popconfirm
title={`确定要删除选中的 ${selectedRowKeys.length} 个用户吗?此操作不可恢复。`}
onConfirm={handleBatchDelete}
>
<Button size="small" danger></Button>
</Popconfirm>
<Button size="small" onClick={() => void handleBatchEnable()}></Button>
<Button size="small" onClick={() => void handleBatchDisable()}></Button>
<Button size="small" danger onClick={handleOpenBatchDeleteConfirm}></Button>
<Button size="small" type="link" onClick={() => setSelectedRowKeys([])}>
</Button>
</Space>
</div>
)}
) : null}
{/* 筛选区域 */}
<FilterCard>
<Space wrap size="middle">
<Input
placeholder="用户名/邮箱/手机号"
prefix={<SearchOutlined />}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onChange={(event) => setKeyword(event.target.value)}
onPressEnter={() => void fetchUsers()}
style={{ width: 200 }}
allowClear
@@ -511,7 +511,7 @@ export function UsersPage() {
onChange={setRoleFilter}
allowClear
style={{ width: 150 }}
options={roles.map((r) => ({ value: r.id, label: r.name }))}
options={roles.map((role) => ({ value: role.id, label: role.name }))}
/>
<RangePicker
placeholder={['创建开始', '创建结束']}
@@ -543,14 +543,13 @@ export function UsersPage() {
{ value: 'desc', label: '降序' },
]}
/>
<Button type="primary" icon={<SearchOutlined />} onClick={fetchUsers}>
<Button type="primary" icon={<SearchOutlined />} onClick={() => void fetchUsers()}>
</Button>
<Button onClick={handleReset}></Button>
</Space>
</FilterCard>
{/* 用户列表 */}
<TableCard>
<Table
columns={columns}
@@ -562,22 +561,18 @@ export function UsersPage() {
rowSelection={rowSelection}
locale={{
emptyText: (
<PageEmpty
description="暂无用户数据"
/>
<PageEmpty description="暂无用户数据" />
),
}}
/>
</TableCard>
{/* 详情抽屉 */}
<UserDetailDrawer
open={detailVisible}
userId={selectedUser?.id}
onClose={() => setDetailVisible(false)}
/>
{/* 编辑抽屉 */}
<UserEditDrawer
open={editVisible}
user={selectedUser}
@@ -585,7 +580,6 @@ export function UsersPage() {
onClose={() => setEditVisible(false)}
/>
{/* 创建用户弹窗 */}
<CreateUserModal
open={createVisible}
roles={roles}
@@ -593,7 +587,6 @@ export function UsersPage() {
onClose={() => setCreateVisible(false)}
/>
{/* 角色分配弹窗 */}
<AssignRolesModal
open={assignRolesVisible}
user={selectedUser}
@@ -602,6 +595,28 @@ export function UsersPage() {
onSuccess={handleAssignRolesSuccess}
onClose={() => setAssignRolesVisible(false)}
/>
<Modal
open={batchDeleteConfirmOpen}
title="确认批量删除"
onOk={() => void handleBatchDelete()}
onCancel={() => setBatchDeleteConfirmOpen(false)}
okText="确认批量删除"
cancelText="取消"
okButtonProps={{ danger: true }}
confirmLoading={batchDeleteSubmitting}
>
<Space direction="vertical" size="small">
<span> {selectedRowKeys.length} </span>
{selectedUsers.length > 0 ? (
<span>
{selectedUsers.slice(0, 3).map((user) => user.username).join('、')}
{selectedUsers.length > 3 ? `${selectedUsers.length}` : ''}
</span>
) : null}
</Space>
</Modal>
</PageLayout>
)
}

View File

@@ -14,15 +14,15 @@ const (
// LoginLog 登录日志
type LoginLog struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
UserID *int64 `gorm:"index;index:idx_login_logs_user_created_at" json:"user_id,omitempty"`
ID int64 `gorm:"primaryKey;autoIncrement;index:idx_login_logs_created_at_id,priority:2;index:idx_login_logs_user_created_at,priority:3;index:idx_login_logs_status_created_at_id,priority:3" json:"id"`
UserID *int64 `gorm:"index;index:idx_login_logs_user_created_at,priority:1" json:"user_id,omitempty"`
LoginType int `gorm:"not null" json:"login_type"` // 1-密码, 2-邮箱验证码, 3-手机验证码, 4-OAuth
DeviceID string `gorm:"type:varchar(100)" json:"device_id"`
IP string `gorm:"type:varchar(50)" json:"ip"`
Location string `gorm:"type:varchar(100)" json:"location"`
Status int `gorm:"not null" json:"status"` // 0-失败, 1-成功
Status int `gorm:"not null;index:idx_login_logs_status_created_at_id,priority:1" json:"status"` // 0-失败, 1-成功
FailReason string `gorm:"type:varchar(255)" json:"fail_reason,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime;index:idx_login_logs_user_created_at" json:"created_at"`
CreatedAt time.Time `gorm:"autoCreateTime;index:idx_login_logs_created_at_id,priority:1;index:idx_login_logs_user_created_at,priority:2;index:idx_login_logs_status_created_at_id,priority:2" json:"created_at"`
}
// TableName 指定表名

View File

@@ -4,7 +4,7 @@ import "time"
// OperationLog 操作日志
type OperationLog struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
ID int64 `gorm:"primaryKey;autoIncrement;index:idx_operation_logs_created_at_id,priority:2" json:"id"`
UserID *int64 `gorm:"index" json:"user_id,omitempty"`
OperationType string `gorm:"type:varchar(50)" json:"operation_type"`
OperationName string `gorm:"type:varchar(100)" json:"operation_name"`
@@ -14,7 +14,7 @@ type OperationLog struct {
ResponseStatus int `json:"response_status"`
IP string `gorm:"type:varchar(50)" json:"ip"`
UserAgent string `gorm:"type:varchar(500)" json:"user_agent"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
CreatedAt time.Time `gorm:"autoCreateTime;index:idx_operation_logs_created_at_id,priority:1" json:"created_at"`
}
// TableName 指定表名

View File

@@ -42,7 +42,7 @@ func (r *LoginLogRepository) ListByUserID(ctx context.Context, userID int64, off
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
return nil, 0, err
}
return logs, total, nil
@@ -56,7 +56,7 @@ func (r *LoginLogRepository) List(ctx context.Context, offset, limit int) ([]*do
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
return nil, 0, err
}
return logs, total, nil
@@ -70,7 +70,7 @@ func (r *LoginLogRepository) ListByStatus(ctx context.Context, status int, offse
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
return nil, 0, err
}
return logs, total, nil
@@ -85,7 +85,7 @@ func (r *LoginLogRepository) ListByTimeRange(ctx context.Context, start, end tim
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
return nil, 0, err
}
return logs, total, nil
@@ -137,7 +137,7 @@ func (r *LoginLogRepository) ListAllForExport(ctx context.Context, userID int64,
query = query.Where("created_at <= ?", endAt)
}
if err := query.Order("created_at DESC").Find(&logs).Error; err != nil {
if err := query.Order("created_at DESC, id DESC").Find(&logs).Error; err != nil {
return nil, err
}
return logs, nil

View File

@@ -42,7 +42,7 @@ func (r *OperationLogRepository) ListByUserID(ctx context.Context, userID int64,
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
return nil, 0, err
}
return logs, total, nil
@@ -56,7 +56,7 @@ func (r *OperationLogRepository) List(ctx context.Context, offset, limit int) ([
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
return nil, 0, err
}
return logs, total, nil
@@ -70,7 +70,7 @@ func (r *OperationLogRepository) ListByMethod(ctx context.Context, method string
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
return nil, 0, err
}
return logs, total, nil
@@ -85,7 +85,7 @@ func (r *OperationLogRepository) ListByTimeRange(ctx context.Context, start, end
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
return nil, 0, err
}
return logs, total, nil
@@ -110,7 +110,7 @@ func (r *OperationLogRepository) Search(ctx context.Context, keyword string, off
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
if err := query.Order("created_at DESC, id DESC").Offset(offset).Limit(limit).Find(&logs).Error; err != nil {
return nil, 0, err
}
return logs, total, nil