From 06aa99e3eb7a70b18757b6ddcb38faae60fed400 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 13 Jul 2026 13:09:53 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=90=8E=E5=8F=B0=E8=A1=A8?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E7=AD=9B=E9=80=89=E4=B8=8E=E5=88=86=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用单次哨兵查询统一计算扫描范围、匹配总数和截断状态 增加完整候选集服务端排序与越界页码钳制 增加固定分页栏、扫描上限提示和后端排序交互 补充大表响应边界文档与前后端回归测试 --- apps/admin-web/src/api/adminApiClient.ts | 5 + apps/admin-web/src/api/adminApiTypes.ts | 8 + .../pages/AdminDatabaseTablesPage.test.tsx | 163 ++++-- .../src/pages/AdminDatabaseTablesPage.tsx | 324 ++++++------ apps/admin-web/src/styles/admin.css | 75 +++ docs/project-memory/shared-memory/pitfalls.md | 8 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 + server-rs/crates/api-server/src/admin.rs | 494 +++++++++++++++++- .../crates/shared-contracts/src/admin.rs | 8 + 9 files changed, 866 insertions(+), 221 deletions(-) diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 89cb391db..5d17b64d6 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -764,6 +764,11 @@ function buildDatabaseTableRowsQuery(query: AdminDatabaseTableRowsQuery) { if (typeof query.limit === 'number' && Number.isFinite(query.limit)) { params.set('limit', String(query.limit)); } + if (typeof query.page === 'number' && Number.isFinite(query.page)) { + params.set('page', String(query.page)); + } + appendQueryParam(params, 'sortColumn', query.sortColumn); + appendQueryParam(params, 'sortDirection', query.sortDirection); const queryString = params.toString(); return queryString ? `?${queryString}` : ''; } diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index caed03606..0322d9e9d 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -150,8 +150,11 @@ export interface AdminDatabaseTableListResponse { export interface AdminDatabaseTableRowsQuery { limit?: number; + page?: number; search?: string; filters?: string; + sortColumn?: string; + sortDirection?: 'asc' | 'desc'; } export interface AdminDatabaseTableRowPayload { @@ -165,6 +168,11 @@ export interface AdminDatabaseTableRowsResponse { rows: AdminDatabaseTableRowPayload[]; totalReturned: number; limit: number; + page: number; + totalMatched: number; + scannedCount: number; + scanLimit: number; + scanLimitReached: boolean; } export interface AdminDatabaseTableStatPayload { diff --git a/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx b/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx index 3c2c04cbd..f6ef68b2e 100644 --- a/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx +++ b/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx @@ -19,6 +19,36 @@ vi.mock('../api/adminApiClient', () => ({ isAdminApiError: vi.fn(() => false), })); +const referralRows = [ + { + cells: { + bound_at: '2026-05-02T00:00:00Z', + invitee_user_id: 'u-b', + invite_code: 'INV-1001', + inviter_user_id: 'u-a', + }, + raw: ['u-b', 'u-a', 'INV-1001', '2026-05-02T00:00:00Z'], + }, + { + cells: { + bound_at: '2026-05-01T00:00:00Z', + invitee_user_id: 'u-a', + invite_code: 'INV-1002', + inviter_user_id: 'u-c', + }, + raw: ['u-a', 'u-c', 'INV-1002', '2026-05-01T00:00:00Z'], + }, + { + cells: { + bound_at: '2026-05-03T00:00:00Z', + invitee_user_id: 'u-c', + invite_code: 'INV-1003', + inviter_user_id: 'u-a', + }, + raw: ['u-c', 'u-a', 'INV-1003', '2026-05-03T00:00:00Z'], + }, +]; + beforeEach(() => { window.location.hash = '#tables?table=profile_referral_relation'; vi.mocked(getAdminDatabaseTables).mockResolvedValue({ @@ -28,41 +58,72 @@ beforeEach(() => { vi.mocked(getAdminDatabaseTableRows).mockResolvedValue({ columns: ['invitee_user_id', 'inviter_user_id', 'invite_code', 'bound_at'], limit: 100, - rows: [ - { - cells: { - bound_at: '2026-05-02T00:00:00Z', - invitee_user_id: 'u-b', - invite_code: 'INV-1001', - inviter_user_id: 'u-a', - }, - raw: ['u-b', 'u-a', 'INV-1001', '2026-05-02T00:00:00Z'], - }, - { - cells: { - bound_at: '2026-05-01T00:00:00Z', - invitee_user_id: 'u-a', - invite_code: 'INV-1002', - inviter_user_id: 'u-c', - }, - raw: ['u-a', 'u-c', 'INV-1002', '2026-05-01T00:00:00Z'], - }, - { - cells: { - bound_at: '2026-05-03T00:00:00Z', - invitee_user_id: 'u-c', - invite_code: 'INV-1003', - inviter_user_id: 'u-a', - }, - raw: ['u-c', 'u-a', 'INV-1003', '2026-05-03T00:00:00Z'], - }, - ], + rows: referralRows, + page: 1, + scannedCount: 3, + scanLimit: 50000, + scanLimitReached: false, tableName: 'profile_referral_relation', + totalMatched: 3, totalReturned: 3, }); }); -test('后台表查询页支持宽表滚动容器和表头排序', async () => { +test('后台表查询页通过页面级固定栏翻页并提示扫描结果可能不完整', async () => { + const user = userEvent.setup(); + vi.mocked(getAdminDatabaseTableRows).mockResolvedValue({ + columns: ['invitee_user_id'], + limit: 100, + page: 1, + rows: [ + { + cells: { invitee_user_id: 'u-b' }, + raw: ['u-b'], + }, + ], + scannedCount: 50000, + scanLimit: 50000, + scanLimitReached: true, + tableName: 'profile_referral_relation', + totalMatched: 250, + totalReturned: 1, + }); + + const { container } = render( + , + ); + + expect(await screen.findByText('第 1 / 3 页,共 250 条')).toBeTruthy(); + const pagination = screen.getByRole('navigation', { name: '表查询分页' }); + expect(pagination.classList.contains('admin-database-pagination')).toBe(true); + expect(pagination.closest('.admin-panel')).toBeNull(); + expect( + container.querySelector('.admin-database-tables-page')?.lastElementChild, + ).toBe(pagination); + expect( + screen.getByText( + '当前表超过 50000 条扫描与浏览上限,本次已扫描 50000 条;当前分页结果和匹配总数可能不完整。', + ), + ).toBeTruthy(); + + await user.type(screen.getByRole('textbox', { name: '关键词' }), '未执行条件'); + await user.click(screen.getByRole('button', { name: '下一页' })); + + await waitFor(() => { + expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith( + 'admin-token', + 'profile_referral_relation', + expect.objectContaining({ + filters: '', + limit: 100, + page: 2, + search: '', + }), + ); + }); +}); + +test('后台表查询页把表头排序交给后端并从第一页展示排序结果', async () => { const user = userEvent.setup(); const { container } = render( , @@ -89,13 +150,55 @@ test('后台表查询页支持宽表滚动容器和表头排序', async () => { ).toBe('原始字段名:invitee_user_id。被邀请人的用户标识。点击可按此列排序。'); expect(readFirstColumnValues(container)).toEqual(['u-b', 'u-a', 'u-c']); + vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({ + columns: ['invitee_user_id', 'inviter_user_id', 'invite_code', 'bound_at'], + limit: 100, + page: 1, + rows: [referralRows[0]!, referralRows[2]!, referralRows[1]!], + scannedCount: 3, + scanLimit: 50000, + scanLimitReached: false, + tableName: 'profile_referral_relation', + totalMatched: 3, + totalReturned: 3, + }); await user.click(screen.getByRole('button', { name: '邀请人ID' })); await waitFor(() => { + expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith( + 'admin-token', + 'profile_referral_relation', + expect.objectContaining({ + page: 1, + sortColumn: 'inviter_user_id', + sortDirection: 'asc', + }), + ); expect(readFirstColumnValues(container)).toEqual(['u-b', 'u-c', 'u-a']); }); + vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({ + columns: ['invitee_user_id', 'inviter_user_id', 'invite_code', 'bound_at'], + limit: 100, + page: 1, + rows: [referralRows[1]!, referralRows[0]!, referralRows[2]!], + scannedCount: 3, + scanLimit: 50000, + scanLimitReached: false, + tableName: 'profile_referral_relation', + totalMatched: 3, + totalReturned: 3, + }); await user.click(screen.getByRole('button', { name: '邀请人ID' })); await waitFor(() => { + expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith( + 'admin-token', + 'profile_referral_relation', + expect.objectContaining({ + page: 1, + sortColumn: 'inviter_user_id', + sortDirection: 'desc', + }), + ); expect(readFirstColumnValues(container)).toEqual(['u-a', 'u-b', 'u-c']); }); }); diff --git a/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx b/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx index 88192e2b8..330406477 100644 --- a/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx +++ b/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx @@ -2,12 +2,21 @@ import { ArrowDown, ArrowUp, ArrowUpDown, + ChevronLeft, + ChevronRight, Eye, RefreshCcw, Search, X, } from 'lucide-react'; -import { FormEvent, useEffect, useMemo, useState } from 'react'; +import { + FormEvent, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import { getAdminDatabaseTableRows, @@ -30,6 +39,14 @@ export function AdminDatabaseTablesPage({ token, onUnauthorized, }: AdminDatabaseTablesPageProps) { + const pageRef = useRef(null); + const appliedQueryRef = useRef({ + search: '', + filters: '', + limit: '100', + sortColumn: '', + sortDirection: 'asc' as SortDirection, + }); const [tables, setTables] = useState([]); const [tableName, setTableName] = useState(() => readHashTableName()); const [search, setSearch] = useState(''); @@ -61,7 +78,6 @@ export function AdminDatabaseTablesPage({ const tableFromHash = readHashTableName(); if (tableFromHash) { setTableName(tableFromHash); - void refreshRows(tableFromHash); } }; window.addEventListener('hashchange', handleHashChange); @@ -82,6 +98,45 @@ export function AdminDatabaseTablesPage({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [tableName]); + useLayoutEffect(() => { + const pageElement = pageRef.current; + if (!pageElement) { + return; + } + const bottomNav = document.querySelector('.admin-bottom-nav'); + const pagination = pageElement.querySelector( + '.admin-database-pagination', + ); + const updateFixedBarHeights = () => { + pageElement.style.setProperty( + '--admin-bottom-nav-height', + `${bottomNav?.getBoundingClientRect().height ?? 0}px`, + ); + pageElement.style.setProperty( + '--admin-database-pagination-height', + `${pagination?.getBoundingClientRect().height ?? 0}px`, + ); + }; + updateFixedBarHeights(); + window.addEventListener('resize', updateFixedBarHeights); + const resizeObserver = + typeof ResizeObserver !== 'undefined' + ? new ResizeObserver(updateFixedBarHeights) + : null; + if (resizeObserver) { + if (bottomNav) { + resizeObserver.observe(bottomNav); + } + if (pagination) { + resizeObserver.observe(pagination); + } + } + return () => { + resizeObserver?.disconnect(); + window.removeEventListener('resize', updateFixedBarHeights); + }; + }, [result]); + const visibleColumns = useMemo(() => { const columns = result?.columns ?? []; if (columns.length) { @@ -117,27 +172,11 @@ export function AdminDatabaseTablesPage({ [tableName, visibleColumns], ); - const sortedRows = useMemo(() => { - const rows = result?.rows ?? []; - if (!sortColumn || !visibleColumns.includes(sortColumn)) { - return rows; - } - - return [...rows] - .map((row, index) => ({ index, row })) - .sort((left, right) => { - const comparison = compareTableCellValues( - left.row.cells[sortColumn], - right.row.cells[sortColumn], - sortDirection, - ); - if (comparison !== 0) { - return comparison; - } - return left.index - right.index; - }) - .map(({ row }) => row); - }, [result, sortColumn, sortDirection, visibleColumns]); + const currentPage = result?.page ?? 1; + const totalMatched = result?.totalMatched ?? result?.totalReturned ?? 0; + const totalPages = result + ? Math.max(1, Math.ceil(totalMatched / Math.max(1, result.limit))) + : 1; async function loadTables() { setIsLoadingTables(true); @@ -161,6 +200,9 @@ export function AdminDatabaseTablesPage({ search?: string; filters?: string; limit?: string; + page?: number; + sortColumn?: string; + sortDirection?: SortDirection; } = {}, ) { const normalizedTableName = nextTableName.trim(); @@ -170,6 +212,8 @@ export function AdminDatabaseTablesPage({ const querySearch = options.search ?? search; const queryFilters = options.filters ?? filters; const queryLimit = options.limit ?? limit; + const querySortColumn = options.sortColumn ?? sortColumn; + const querySortDirection = options.sortDirection ?? sortDirection; setIsLoadingRows(true); setErrorMessage(''); try { @@ -180,8 +224,20 @@ export function AdminDatabaseTablesPage({ search: querySearch, filters: queryFilters, limit: parseLimit(queryLimit), + page: options.page ?? 1, + sortColumn: querySortColumn || undefined, + sortDirection: querySortColumn ? querySortDirection : undefined, }, ); + appliedQueryRef.current = { + search: querySearch, + filters: queryFilters, + limit: queryLimit, + sortColumn: querySortColumn, + sortDirection: querySortDirection, + }; + setSortColumn(querySortColumn); + setSortDirection(querySortDirection); setResult(response); setCopyMessage(''); } catch (error: unknown) { @@ -193,10 +249,12 @@ export function AdminDatabaseTablesPage({ function handleSearch(event: FormEvent) { event.preventDefault(); - void refreshRows(); + void refreshRows(tableName, { page: 1 }); } function handleTableChange(nextTableName: string) { + setSortColumn(''); + setSortDirection('asc'); setTableName(nextTableName); const nextHash = `#tables?table=${encodeURIComponent(nextTableName)}`; if (window.location.hash !== nextHash) { @@ -208,19 +266,31 @@ export function AdminDatabaseTablesPage({ setSearch(''); setFilters(''); setLimit('100'); - void refreshRows(tableName, { search: '', filters: '', limit: '100' }); + setSortColumn(''); + setSortDirection('asc'); + void refreshRows(tableName, { + search: '', + filters: '', + limit: '100', + page: 1, + sortColumn: '', + sortDirection: 'asc', + }); + } + + function handlePageChange(page: number) { + void refreshRows(tableName, { ...appliedQueryRef.current, page }); } function handleSortColumn(column: string) { - if (sortColumn === column) { - setSortDirection((currentDirection) => - currentDirection === 'asc' ? 'desc' : 'asc', - ); - return; - } - - setSortColumn(column); - setSortDirection('asc'); + const nextDirection = + sortColumn === column && sortDirection === 'asc' ? 'desc' : 'asc'; + void refreshRows(tableName, { + ...appliedQueryRef.current, + page: 1, + sortColumn: column, + sortDirection: nextDirection, + }); } async function handleCopyDetailJson() { @@ -242,7 +312,10 @@ export function AdminDatabaseTablesPage({ } return ( -
+

表查询

@@ -262,7 +335,7 @@ export function AdminDatabaseTablesPage({ className="admin-primary-button" disabled={!tableName || isLoadingRows} type="button" - onClick={() => void refreshRows()} + onClick={() => void refreshRows(tableName, { page: 1 })} >