修复后台表查询筛选与分页
使用单次哨兵查询统一计算扫描范围、匹配总数和截断状态 增加完整候选集服务端排序与越界页码钳制 增加固定分页栏、扫描上限提示和后端排序交互 补充大表响应边界文档与前后端回归测试
This commit is contained in:
@@ -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}` : '';
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<HTMLElement>(null);
|
||||
const appliedQueryRef = useRef({
|
||||
search: '',
|
||||
filters: '',
|
||||
limit: '100',
|
||||
sortColumn: '',
|
||||
sortDirection: 'asc' as SortDirection,
|
||||
});
|
||||
const [tables, setTables] = useState<string[]>([]);
|
||||
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<HTMLElement>('.admin-bottom-nav');
|
||||
const pagination = pageElement.querySelector<HTMLElement>(
|
||||
'.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<HTMLFormElement>) {
|
||||
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 (
|
||||
<section className="admin-page admin-page-wide">
|
||||
<section
|
||||
ref={pageRef}
|
||||
className="admin-page admin-page-wide admin-database-tables-page"
|
||||
>
|
||||
<div className="admin-page-heading">
|
||||
<div>
|
||||
<h2>表查询</h2>
|
||||
@@ -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 })}
|
||||
>
|
||||
<Search size={17} aria-hidden="true" />
|
||||
<span>{isLoadingRows ? '查询中' : '查询'}</span>
|
||||
@@ -302,7 +375,7 @@ export function AdminDatabaseTablesPage({
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-field admin-field-compact">
|
||||
<span>条数</span>
|
||||
<span>每页条数</span>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
value={limit}
|
||||
@@ -342,12 +415,21 @@ export function AdminDatabaseTablesPage({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{result?.scanLimitReached ? (
|
||||
<section className="admin-panel admin-panel-warning" role="status">
|
||||
当前表超过 {result.scanLimit} 条扫描与浏览上限,本次已扫描{' '}
|
||||
{result.scannedCount} 条;当前分页结果和匹配总数可能不完整。
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="admin-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<h3 title={resultTableHeader.description}>
|
||||
{resultTableHeader.label}
|
||||
</h3>
|
||||
<span>{result?.totalReturned ?? 0} 条</span>
|
||||
<span>
|
||||
匹配 {totalMatched} 条,本页 {result?.totalReturned ?? 0} 条
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table admin-table-wide admin-database-table">
|
||||
@@ -371,6 +453,7 @@ export function AdminDatabaseTablesPage({
|
||||
data-active={isSorted ? 'true' : 'false'}
|
||||
title={description}
|
||||
type="button"
|
||||
disabled={isLoadingRows}
|
||||
onClick={() => handleSortColumn(column)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
@@ -391,8 +474,8 @@ export function AdminDatabaseTablesPage({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedRows.length ? (
|
||||
sortedRows.map((row, rowIndex) => (
|
||||
{result?.rows.length ? (
|
||||
result.rows.map((row, rowIndex) => (
|
||||
<tr
|
||||
key={buildRowKey(row, rowIndex)}
|
||||
data-clickable="true"
|
||||
@@ -441,6 +524,41 @@ export function AdminDatabaseTablesPage({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{result ? (
|
||||
<nav
|
||||
className="admin-action-row admin-database-pagination"
|
||||
aria-label="表查询分页"
|
||||
>
|
||||
<span className="admin-database-pagination-info">
|
||||
第 {currentPage} / {totalPages} 页,共 {totalMatched} 条
|
||||
</span>
|
||||
<div className="admin-action-row">
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
aria-label="上一页"
|
||||
disabled={isLoadingRows || currentPage <= 1}
|
||||
title="上一页"
|
||||
type="button"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
>
|
||||
<ChevronLeft size={17} aria-hidden="true" />
|
||||
<span>上一页</span>
|
||||
</button>
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
aria-label="下一页"
|
||||
disabled={isLoadingRows || currentPage >= totalPages}
|
||||
title="下一页"
|
||||
type="button"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
>
|
||||
<span>下一页</span>
|
||||
<ChevronRight size={17} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
{detailRow ? (
|
||||
<div className="admin-confirm-backdrop" role="presentation">
|
||||
<section
|
||||
@@ -580,74 +698,6 @@ function getDatabaseTableColumnDescription(
|
||||
return `原始字段名:${column}。${description}。点击可按此列排序。`;
|
||||
}
|
||||
|
||||
function compareTableCellValues(
|
||||
leftValue: unknown,
|
||||
rightValue: unknown,
|
||||
sortDirection: SortDirection,
|
||||
) {
|
||||
const direction = sortDirection === 'asc' ? 1 : -1;
|
||||
const left = normalizeTableCellSortValue(leftValue);
|
||||
const right = normalizeTableCellSortValue(rightValue);
|
||||
|
||||
if (left.kind === 'empty' && right.kind === 'empty') {
|
||||
return 0;
|
||||
}
|
||||
if (left.kind === 'empty') {
|
||||
return 1;
|
||||
}
|
||||
if (right.kind === 'empty') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (left.kind !== right.kind) {
|
||||
return (
|
||||
direction * (getSortKindOrder(left.kind) - getSortKindOrder(right.kind))
|
||||
);
|
||||
}
|
||||
|
||||
let comparison = 0;
|
||||
switch (left.kind) {
|
||||
case 'number':
|
||||
comparison = left.value - getSortableNumberValue(right);
|
||||
break;
|
||||
case 'boolean':
|
||||
comparison = Number(left.value) - Number(getSortableBooleanValue(right));
|
||||
break;
|
||||
case 'text':
|
||||
comparison = tableSortCollator.compare(
|
||||
left.value,
|
||||
getSortableTextValue(right),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return direction * comparison;
|
||||
}
|
||||
|
||||
function normalizeTableCellSortValue(value: unknown): SortableTableCellValue {
|
||||
if (value === null || typeof value === 'undefined' || value === '') {
|
||||
return { kind: 'empty' };
|
||||
}
|
||||
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return { kind: 'number', value };
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return { kind: 'boolean', value };
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return { kind: 'empty' };
|
||||
}
|
||||
return { kind: 'text', value: trimmed };
|
||||
}
|
||||
|
||||
return { kind: 'text', value: stringifyUnknownValue(value) };
|
||||
}
|
||||
|
||||
function buildRowKey(row: AdminDatabaseTableRowPayload, rowIndex: number) {
|
||||
const firstValue = Object.values(row.cells)[0];
|
||||
return `${rowIndex}-${String(firstValue ?? '')}`;
|
||||
@@ -797,27 +847,6 @@ function stringifyUnknownValue(value: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
function getSortKindOrder(kind: SortableTableCellValue['kind']): number {
|
||||
switch (kind) {
|
||||
case 'number':
|
||||
return 0;
|
||||
case 'boolean':
|
||||
return 1;
|
||||
case 'text':
|
||||
return 2;
|
||||
case 'empty':
|
||||
return 3;
|
||||
default:
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
type SortableTableCellValue =
|
||||
| { kind: 'empty' }
|
||||
| { kind: 'number'; value: number }
|
||||
| { kind: 'boolean'; value: boolean }
|
||||
| { kind: 'text'; value: string };
|
||||
|
||||
interface DatabaseTableHeader {
|
||||
name: string;
|
||||
label: string;
|
||||
@@ -830,11 +859,6 @@ interface FormattedTableCellValue {
|
||||
fullText: string;
|
||||
}
|
||||
|
||||
const tableSortCollator = new Intl.Collator('zh-CN', {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
|
||||
const databaseTableColumnLabelMap: Record<string, string> = {
|
||||
id: 'ID',
|
||||
table_name: '表名',
|
||||
@@ -1489,35 +1513,3 @@ const databaseTableDescriptionMap: Record<string, string> = {
|
||||
ai_result_reference: 'AI 结果引用表',
|
||||
ai_task_event: 'AI 任务事件表',
|
||||
};
|
||||
|
||||
function getSortableNumberValue(value: SortableTableCellValue) {
|
||||
return isSortableNumberValue(value) ? value.value : 0;
|
||||
}
|
||||
|
||||
function getSortableBooleanValue(value: SortableTableCellValue) {
|
||||
return isSortableBooleanValue(value) ? value.value : false;
|
||||
}
|
||||
|
||||
function getSortableTextValue(value: SortableTableCellValue) {
|
||||
return isSortableTextValue(value)
|
||||
? value.value
|
||||
: stringifyUnknownValue(value);
|
||||
}
|
||||
|
||||
function isSortableNumberValue(
|
||||
value: SortableTableCellValue,
|
||||
): value is Extract<SortableTableCellValue, { kind: 'number' }> {
|
||||
return value.kind === 'number';
|
||||
}
|
||||
|
||||
function isSortableBooleanValue(
|
||||
value: SortableTableCellValue,
|
||||
): value is Extract<SortableTableCellValue, { kind: 'boolean' }> {
|
||||
return value.kind === 'boolean';
|
||||
}
|
||||
|
||||
function isSortableTextValue(
|
||||
value: SortableTableCellValue,
|
||||
): value is Extract<SortableTableCellValue, { kind: 'text' }> {
|
||||
return value.kind === 'text';
|
||||
}
|
||||
|
||||
@@ -1220,6 +1220,30 @@ button:disabled {
|
||||
max-width: 112px;
|
||||
}
|
||||
|
||||
.admin-database-tables-page {
|
||||
padding-bottom: calc(var(--admin-database-pagination-height, 68px) + 16px);
|
||||
}
|
||||
|
||||
.admin-database-pagination {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 232px;
|
||||
z-index: 18;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid #eaded2;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 -8px 24px rgba(112, 57, 30, 0.08);
|
||||
padding: 12px 24px calc(12px + env(safe-area-inset-bottom));
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.admin-database-pagination-info {
|
||||
color: #755a49;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-table-sort-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1588,6 +1612,20 @@ button:disabled {
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.admin-database-pagination {
|
||||
right: 0;
|
||||
bottom: var(--admin-bottom-nav-height, 64px);
|
||||
left: 0;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.admin-database-tables-page {
|
||||
padding-bottom: calc(
|
||||
var(--admin-bottom-nav-height, 64px) +
|
||||
var(--admin-database-pagination-height, 58px) + 16px
|
||||
);
|
||||
}
|
||||
|
||||
.admin-bottom-nav-button {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
@@ -1604,6 +1642,18 @@ button:disabled {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.admin-database-pagination .admin-secondary-button {
|
||||
width: 38px;
|
||||
min-width: 38px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.admin-database-pagination .admin-secondary-button span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.admin-login-panel,
|
||||
.admin-panel {
|
||||
@@ -1660,4 +1710,29 @@ button:disabled {
|
||||
.admin-icon-button span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-database-pagination,
|
||||
.admin-database-pagination .admin-action-row {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.admin-database-pagination {
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.admin-database-pagination .admin-action-row {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.admin-database-pagination .admin-secondary-button {
|
||||
gap: 4px;
|
||||
min-height: 38px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.admin-database-pagination-info {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2097,6 +2097,14 @@
|
||||
- 验证:执行 `cargo test -p api-server admin_database -- --nocapture`,并确认后台详情弹层的 `raw` 与表格 `cells` 都显示业务字符串。
|
||||
- 关联:`server-rs/crates/api-server/src/admin.rs`、`docs/technical/ADMIN_DATABASE_TABLE_QUERY_2026-05-08.md`。
|
||||
|
||||
## 后台通用表查询不能先按每页条数截断再筛选
|
||||
|
||||
- 现象:后台“表查询”填写关键词或 JSON 条件后查不到确定存在的记录;把“条数”从 100 调到 500 只能偶尔缓解,而且页面没有继续翻页的入口。
|
||||
- 原因:旧实现先执行 `SELECT * FROM <table> LIMIT <limit>`,再对这批行做内存过滤;目标记录不在首批结果时永远无法命中,同时响应没有页码、匹配总数或扫描上限状态。
|
||||
- 处理:用户输入继续不进入通用 SQL。API Server 通过单次 `SELECT * ... LIMIT 50001` 读取哨兵行,最多保留前 50,000 条候选,先过滤,再按后端接收的列名 / 方向对完整候选集稳定排序,最后分页;`totalMatched`、`scannedCount` 和 `scanLimitReached` 都从同一份 SQL 结果计算。请求页码超过实际总页数时钳制到末页,零结果固定为第 1 页。响应统一返回 `page`、`totalMatched`、`scannedCount`、`scanLimit` 和 `scanLimitReached`,后台翻页栏固定在视口底部;达到扫描上限时明确提示结果可能不完整。32 MiB 是候选 SQL 响应体硬上限,宽表即使每页条数很小也可能整次拒绝,不返回部分结果。实时写入可能改变相邻请求之间的候选快照,精确审计使用专用业务查询。
|
||||
- 验证:执行 `cargo test -p api-server admin_database -- --nocapture`,覆盖第 101 行才命中、完整候选集排序后分页、哨兵截断、越界页码和响应体硬上限;前端测试覆盖下一页沿用已应用条件、后端排序参数 / 返回结果与扫描警告,并运行后台类型检查。
|
||||
- 关联:`server-rs/crates/api-server/src/admin.rs`、`server-rs/crates/shared-contracts/src/admin.rs`、`apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx`。
|
||||
|
||||
## 充值订单过期补偿不要放进外部生成 worker
|
||||
|
||||
- 现象:外部生成 worker/controller 扩容后,微信充值过期查单和关单流量也被同步放大;排查时还会误去外部生成 worker 日志里找支付过期任务。
|
||||
|
||||
@@ -627,6 +627,8 @@ SELECT * FROM profile_recharge_product_config ORDER BY sort_order ASC;
|
||||
|
||||
后台通用表查询已经处理 SpacetimeDB 无载荷枚举的 SATS 形态。新增后台表展示时,枚举列优先按表名和列名做业务映射,再落回通用解码。
|
||||
|
||||
后台通用表查询的“每页条数”不是筛选前的 SQL 截断量。API Server 通过单次 `SELECT * ... LIMIT 50001` 读取哨兵行,最多保留前 50,000 条候选;关键词 / JSON 过滤、所选列的完整候选集稳定排序和 1-based `page` 分页都基于这一次 SQL 结果,`totalMatched` 不再依赖另一份 `COUNT(*)` 快照。请求页码超过实际总页数时钳制到末页,零结果固定返回第 1 页。存在第 50,001 条哨兵行时响应必须返回 `scanLimitReached=true`,后台固定分页栏上方明确提示匹配总数和分页结果可能不完整,不得把扫描范围外的数据误报为不存在。候选 SQL 响应体仍受 32 MiB 和 20 秒硬限制;宽表即使每页条数很小也可能整次拒绝,不会返回部分结果。实时写入仍可能改变相邻请求的候选快照,精确审计应使用对应业务表的专用查询而不是通用浏览页。
|
||||
|
||||
## Issue 与交接
|
||||
|
||||
- Issue tracker:自托管 Gitea。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -522,8 +522,11 @@ pub struct AdminDatabaseTableListResponse {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminDatabaseTableRowsQuery {
|
||||
pub limit: Option<u32>,
|
||||
pub page: Option<u32>,
|
||||
pub search: Option<String>,
|
||||
pub filters: Option<String>,
|
||||
pub sort_column: Option<String>,
|
||||
pub sort_direction: Option<String>,
|
||||
}
|
||||
|
||||
// 后台通用表查询响应,cells 使用列名映射,raw 保留原始行便于详情排障。
|
||||
@@ -534,7 +537,12 @@ pub struct AdminDatabaseTableRowsResponse {
|
||||
pub columns: Vec<String>,
|
||||
pub rows: Vec<AdminDatabaseTableRowPayload>,
|
||||
pub total_returned: usize,
|
||||
pub total_matched: usize,
|
||||
pub limit: u32,
|
||||
pub page: u32,
|
||||
pub scanned_count: usize,
|
||||
pub scan_limit: u32,
|
||||
pub scan_limit_reached: bool,
|
||||
}
|
||||
|
||||
// 单行查询结果,值统一用 JSON 承载以兼容不同表字段类型。
|
||||
|
||||
Reference in New Issue
Block a user