Fix prelaunch navigation and log scale regressions
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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()) {
|
||||
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) {
|
||||
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}`)
|
||||
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}`)
|
||||
await expect(page.locator('tbody tr').filter({ hasText: username }).first()).toBeVisible({ timeout: 20 * 1000 })
|
||||
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')
|
||||
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -4,88 +4,92 @@
|
||||
* 布局:侧栏 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,18 +226,16 @@ 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
|
||||
@@ -250,8 +246,7 @@ 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}>
|
||||
@@ -267,17 +262,16 @@ 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
|
||||
@@ -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}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 指定表名
|
||||
|
||||
@@ -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 指定表名
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user