diff --git a/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx b/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx new file mode 100644 index 00000000..645127aa --- /dev/null +++ b/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx @@ -0,0 +1,107 @@ +/* @vitest-environment jsdom */ + +import {render, screen, waitFor} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {beforeEach, expect, test, vi} from 'vitest'; + +import { + getAdminDatabaseTableRows, + getAdminDatabaseTables, +} from '../api/adminApiClient'; +import {AdminDatabaseTablesPage} from './AdminDatabaseTablesPage'; + +vi.mock('../api/adminApiClient', () => ({ + formatAdminApiError: vi.fn((error: unknown) => + error instanceof Error ? error.message : '请求失败', + ), + getAdminDatabaseTableRows: vi.fn(), + getAdminDatabaseTables: vi.fn(), + isAdminApiError: vi.fn(() => false), +})); + +beforeEach(() => { + window.location.hash = '#tables?table=profile_referral_relation'; + vi.mocked(getAdminDatabaseTables).mockResolvedValue({ + fetchErrors: [], + tables: ['profile_referral_relation'], + }); + 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'], + }, + ], + tableName: 'profile_referral_relation', + totalReturned: 3, + }); +}); + +test('后台表查询页支持宽表滚动容器和表头排序', async () => { + const user = userEvent.setup(); + const {container} = render( + , + ); + + await screen.findByText('u-b'); + + const tableWrap = container.querySelector('.admin-table-wrap'); + expect(tableWrap?.querySelector('.admin-database-table')).not.toBeNull(); + expect(screen.getByRole('option', {name: '邀请关系(profile_referral_relation)'}).getAttribute('title')).toBe( + '原始表名:profile_referral_relation。邀请关系记录表。', + ); + expect(screen.getByText('已选表:邀请关系(profile_referral_relation)')).toBeTruthy(); + expect(screen.getByRole('heading', {name: '邀请关系'}).getAttribute('title')).toBe( + '原始表名:profile_referral_relation。邀请关系记录表。', + ); + expect(screen.getByRole('button', {name: '被邀请人ID'}).getAttribute('title')).toBe( + '原始字段名:invitee_user_id。被邀请人的用户标识。点击可按此列排序。', + ); + expect(readFirstColumnValues(container)).toEqual(['u-b', 'u-a', 'u-c']); + + await user.click(screen.getByRole('button', {name: '邀请人ID'})); + await waitFor(() => { + expect(readFirstColumnValues(container)).toEqual(['u-b', 'u-c', 'u-a']); + }); + + await user.click(screen.getByRole('button', {name: '邀请人ID'})); + await waitFor(() => { + expect(readFirstColumnValues(container)).toEqual(['u-a', 'u-b', 'u-c']); + }); +}); + +function readFirstColumnValues(container: HTMLElement) { + return Array.from(container.querySelectorAll('tbody tr')).map( + (row) => row.querySelector('td')?.textContent?.trim() ?? '', + ); +} diff --git a/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx b/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx index 62e5eacc..781c2d23 100644 --- a/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx +++ b/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx @@ -1,4 +1,12 @@ -import {Eye, RefreshCcw, Search, X} from 'lucide-react'; +import { + ArrowDown, + ArrowUp, + ArrowUpDown, + Eye, + RefreshCcw, + Search, + X, +} from 'lucide-react'; import {FormEvent, useEffect, useMemo, useState} from 'react'; import { @@ -16,6 +24,8 @@ interface AdminDatabaseTablesPageProps { onUnauthorized: (message?: string) => void; } +type SortDirection = 'asc' | 'desc'; + export function AdminDatabaseTablesPage({ token, onUnauthorized, @@ -29,6 +39,8 @@ export function AdminDatabaseTablesPage({ const [detailRow, setDetailRow] = useState(null); const [errorMessage, setErrorMessage] = useState(''); const [copyMessage, setCopyMessage] = useState(''); + const [sortColumn, setSortColumn] = useState(''); + const [sortDirection, setSortDirection] = useState('asc'); const [isLoadingTables, setIsLoadingTables] = useState(false); const [isLoadingRows, setIsLoadingRows] = useState(false); @@ -76,6 +88,52 @@ export function AdminDatabaseTablesPage({ return firstRow ? Object.keys(firstRow.cells) : []; }, [result]); + const tableOptions = useMemo(() => { + const optionNames = + tableName && !tables.includes(tableName) ? [tableName, ...tables] : tables; + return optionNames.map(getDatabaseTableHeader); + }, [tableName, tables]); + + const selectedTableHeader = useMemo( + () => getDatabaseTableHeader(tableName), + [tableName], + ); + + const resultTableHeader = useMemo( + () => getDatabaseTableHeader(result?.tableName || tableName), + [result?.tableName, tableName], + ); + + const columnHeaders = useMemo( + () => + visibleColumns.map((column) => + getDatabaseTableColumnHeader(tableName, column), + ), + [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]); + async function loadTables() { setIsLoadingTables(true); setErrorMessage(''); @@ -144,6 +202,18 @@ export function AdminDatabaseTablesPage({ void refreshRows(tableName, {search: '', filters: '', limit: '100'}); } + function handleSortColumn(column: string) { + if (sortColumn === column) { + setSortDirection((currentDirection) => + currentDirection === 'asc' ? 'desc' : 'asc', + ); + return; + } + + setSortColumn(column); + setSortDirection('asc'); + } + async function handleCopyDetailJson() { if (!detailRow) { return; @@ -195,12 +265,9 @@ export function AdminDatabaseTablesPage({ value={tableName} onChange={(event) => handleTableChange(event.target.value)} > - {tableName && !tables.includes(tableName) ? ( - - ) : null} - {tables.map((name) => ( - ))} @@ -244,7 +311,9 @@ export function AdminDatabaseTablesPage({ 重置条件
- 已选表:{tableName || '-'} + + 已选表:{selectedTableHeader.optionLabel} + 显示列:{visibleColumns.length}
@@ -258,30 +327,71 @@ export function AdminDatabaseTablesPage({
-

{result?.tableName || tableName || '数据行'}

+

{resultTableHeader.label}

{result?.totalReturned ?? 0} 条
- +
- {visibleColumns.map((column) => ( - - ))} + {columnHeaders.map(({column, label, description}) => { + const isSorted = sortColumn === column; + return ( + + ); + })} - {result?.rows.length ? ( - result.rows.map((row, rowIndex) => ( + {sortedRows.length ? ( + sortedRows.map((row, rowIndex) => ( setDetailRow(row)} > - {visibleColumns.map((column) => ( - - ))} + {visibleColumns.map((column) => { + const cellValue = formatCellValue(row.cells[column]); + return ( + + ); + })}
{column} + + 详情
{formatCellValue(row.cells[column])} + + {cellValue.content} + +