diff --git a/docs/superpowers/plans/2026-05-10-prelaunch-navigation-and-batch-delete.md b/docs/superpowers/plans/2026-05-10-prelaunch-navigation-and-batch-delete.md new file mode 100644 index 0000000..9e42b77 --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-prelaunch-navigation-and-batch-delete.md @@ -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. + diff --git a/docs/superpowers/specs/2026-05-10-prelaunch-navigation-and-batch-delete-design.md b/docs/superpowers/specs/2026-05-10-prelaunch-navigation-and-batch-delete-design.md new file mode 100644 index 0000000..910a968 --- /dev/null +++ b/docs/superpowers/specs/2026-05-10-prelaunch-navigation-and-batch-delete-design.md @@ -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` + diff --git a/frontend/admin/scripts/run-playwright-cdp-e2e.mjs b/frontend/admin/scripts/run-playwright-cdp-e2e.mjs index aa65e25..ae981bb 100644 --- a/frontend/admin/scripts/run-playwright-cdp-e2e.mjs +++ b/frontend/admin/scripts/run-playwright-cdp-e2e.mjs @@ -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') diff --git a/frontend/admin/src/layouts/AdminLayout/AdminLayout.test.tsx b/frontend/admin/src/layouts/AdminLayout/AdminLayout.test.tsx index c517577..e044f84 100644 --- a/frontend/admin/src/layouts/AdminLayout/AdminLayout.test.tsx +++ b/frontend/admin/src/layouts/AdminLayout/AdminLayout.test.tsx @@ -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( { diff --git a/frontend/admin/src/layouts/AdminLayout/AdminLayout.tsx b/frontend/admin/src/layouts/AdminLayout/AdminLayout.tsx index f60f020..74a9ca6 100644 --- a/frontend/admin/src/layouts/AdminLayout/AdminLayout.tsx +++ b/frontend/admin/src/layouts/AdminLayout/AdminLayout.tsx @@ -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) => ( + {text} +) -// 管理员菜单配置 const adminMenuItems: MenuProps['items'] = [ { key: '/dashboard', icon: , - label: '总览', + label: menuLabel('nav-dashboard', '总览'), }, { key: 'access-control', icon: , - 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: , - 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: , - 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: , - 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: , - label: 'Webhooks', + label: menuLabel('nav-webhooks', 'Webhooks'), }, { key: 'profile', icon: , - 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 (
@@ -196,12 +198,10 @@ export function AdminLayout({ children }: AdminLayoutProps) { return ( - {/* 跳过链接 - 便于键盘用户快速跳转到主要内容 */} 跳转到主要内容 - {/* 侧边栏 */} - {/* Logo 区域 */}
{collapsed ? 'UMS' : '用户管理系统'}
- {/* 导航菜单 */} - {/* 右侧主体 */} - {/* 顶栏 */}
- {/* 折叠/菜单按钮 - 移动端显示菜单图标,桌面端显示折叠图标 */} {isMobile ? ( )} - {/* 面包屑 */} - {breadcrumbItems && breadcrumbItems.length > 0 && ( + {breadcrumbItems && breadcrumbItems.length > 0 ? ( - )} + ) : null}
- {/* 用户信息 */}
- } src={user?.avatar || null} style={{ backgroundColor: user?.avatar ? undefined : 'var(--color-primary)' }} @@ -294,21 +288,15 @@ export function AdminLayout({ children }: AdminLayoutProps) {
- {/* 内容区 */} {children || }
- {/* 移动端抽屉式导航 */} - {collapsed ? 'UMS' : '用户管理系统'} -
- } + title={
{collapsed ? 'UMS' : '用户管理系统'}
} placement="left" - onClose={toggleMobileDrawer} + onClose={closeMobileDrawer} open={mobileDrawerOpen} size="default" className={styles.mobileDrawer} diff --git a/frontend/admin/src/pages/admin/UsersPage/UsersPage.test.tsx b/frontend/admin/src/pages/admin/UsersPage/UsersPage.test.tsx index 09bf912..b74fa20 100644 --- a/frontend/admin/src/pages/admin/UsersPage/UsersPage.test.tsx +++ b/frontend/admin/src/pages/admin/UsersPage/UsersPage.test.tsx @@ -14,6 +14,8 @@ const useAuthMock = vi.fn() const listUsersMock = vi.fn<(params: UserListParams) => Promise>>() const deleteUserMock = vi.fn<(id: number) => Promise>() const updateUserStatusMock = vi.fn<(id: number, payload: { status: UserStatus }) => Promise>() +const batchUpdateStatusMock = vi.fn<(ids: number[], status: UserStatus) => Promise>() +const batchDeleteMock = vi.fn<(ids: number[]) => Promise>() const getUserRolesMock = vi.fn<(id: number) => Promise>() const listRolesMock = vi.fn<() => Promise>>() @@ -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>( + 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 ? ( +
+
{title}
+
{children}
+ + +
+ ) : 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> rowKey?: string | ((row: Record) => string | number) + rowSelection?: { + selectedRowKeys?: Array + onChange?: (keys: Array) => void + } locale?: { emptyText?: ReactNode } }) => { const rows = dataSource ?? [] @@ -78,6 +123,7 @@ vi.mock('antd', async () => { + {rowSelection ? : null} {columns.map((column, index) => ( ))} @@ -89,6 +135,23 @@ vi.mock('antd', async () => { key={resolveRowKey(record, rowKey, rowIndex)} data-testid={`table-row-${resolveRowKey(record, rowKey, rowIndex)}`} > + {rowSelection ? ( + + ) : 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() + + 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') + }) }) diff --git a/frontend/admin/src/pages/admin/UsersPage/UsersPage.tsx b/frontend/admin/src/pages/admin/UsersPage/UsersPage.tsx index 530c198..a359ec4 100644 --- a/frontend/admin/src/pages/admin/UsersPage/UsersPage.tsx +++ b/frontend/admin/src/pages/admin/UsersPage/UsersPage.tsx @@ -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(null) const [users, setUsers] = useState([]) @@ -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() const [createdFrom, setCreatedFrom] = useState() @@ -75,11 +75,9 @@ export function UsersPage() { const [sortBy, setSortBy] = useState() const [sortOrder, setSortOrder] = useState<'asc' | 'desc' | undefined>() - // 角色列表(用于筛选和分配) const [roles, setRoles] = useState([]) const [roleFilter, setRoleFilter] = useState() - // 抽屉/弹窗 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(null) const [selectedUserRoles, setSelectedUserRoles] = useState([]) - // 批量选择 const [selectedRowKeys, setSelectedRowKeys] = useState([]) + 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 = [ { title: '用户名', @@ -350,7 +358,7 @@ export function UsersPage() { type="link" size="small" icon={} - onClick={() => handleViewDetail(record)} + onClick={() => void handleViewDetail(record)} > 详情 @@ -358,7 +366,7 @@ export function UsersPage() { type="link" size="small" icon={} - onClick={() => handleEdit(record)} + onClick={() => void handleEdit(record)} > 编辑 @@ -366,14 +374,14 @@ export function UsersPage() { type="link" size="small" icon={} - onClick={() => handleAssignRoles(record)} + onClick={() => void handleAssignRoles(record)} > 角色 {record.status === 1 ? ( handleToggleStatus(record)} + onConfirm={() => void handleToggleStatus(record)} > - - } + )} /> - {/* 批量操作工具栏 */} - {selectedRowKeys.length > 0 && ( + {selectedRowKeys.length > 0 ? (
已选择 {selectedRowKeys.length} 个用户: - - - - - + + +
- )} + ) : null} - {/* 筛选区域 */} } 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 }))} /> - - {/* 用户列表 */}
Select{column.title} + { + 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) + }} + /> +
+ ), }} /> - {/* 详情抽屉 */} setDetailVisible(false)} /> - {/* 编辑抽屉 */} setEditVisible(false)} /> - {/* 创建用户弹窗 */} setCreateVisible(false)} /> - {/* 角色分配弹窗 */} setAssignRolesVisible(false)} /> + + void handleBatchDelete()} + onCancel={() => setBatchDeleteConfirmOpen(false)} + okText="确认批量删除" + cancelText="取消" + okButtonProps={{ danger: true }} + confirmLoading={batchDeleteSubmitting} + > + + 已选 {selectedRowKeys.length} 个用户,此操作不可恢复。 + {selectedUsers.length > 0 ? ( + + 用户: + {selectedUsers.slice(0, 3).map((user) => user.username).join('、')} + {selectedUsers.length > 3 ? ` 等 ${selectedUsers.length} 个` : ''} + + ) : null} + + ) } diff --git a/internal/domain/login_log.go b/internal/domain/login_log.go index 30f4894..5c8c5d0 100644 --- a/internal/domain/login_log.go +++ b/internal/domain/login_log.go @@ -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 指定表名 diff --git a/internal/domain/operation_log.go b/internal/domain/operation_log.go index a5a1cb0..fe7a1c8 100644 --- a/internal/domain/operation_log.go +++ b/internal/domain/operation_log.go @@ -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 指定表名 diff --git a/internal/repository/login_log.go b/internal/repository/login_log.go index 8d9bff1..20cae9d 100644 --- a/internal/repository/login_log.go +++ b/internal/repository/login_log.go @@ -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 diff --git a/internal/repository/operation_log.go b/internal/repository/operation_log.go index 14558a2..5d8ac1f 100644 --- a/internal/repository/operation_log.go +++ b/internal/repository/operation_log.go @@ -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