后台数据库表查询支持结构化筛选与详情优化
Project CI / Repository checks (pull_request) Successful in 3m47s
Project CI / Frontend tests (pull_request) Successful in 4m22s
Project CI / Backend tests (pull_request) Successful in 8m17s
Project CI / Native shell tests (pull_request) Successful in 17m58s

替换手写筛选 JSON 为结构化条件编辑器

支持条件勾选、列表值编辑和三态排序

优化行详情、字段复制 Toast 与主题 JSON 预览

修复数值范围筛选和异步请求竞态

补充后台查询接口校验、回归测试与文档
This commit is contained in:
2026-08-31 19:23:59 +08:00
parent 6d2c275d32
commit 768e25be1d
8 changed files with 2296 additions and 148 deletions
@@ -2,7 +2,7 @@
## 需求落点
- 后台“总览”页的表统计仍保留,只把每张表的表名改成可点击跳转到 `#tables?table=<name>`
- 新增独立 `#tables` 页承载表选择、关键词搜索、JSON filters、limit、行详情弹窗
- 新增独立 `#tables` 页承载表选择、关键词搜索、结构化字段筛选、limit、行详情弹窗(详情内保留字段复制,列头漏斗按钮可按列添加条件;每条条件可勾选启用或停用,停用时保留字段和值;`in` / `notIn` 使用逐项值标签,支持粘贴多行值,逗号不再作为隐式分隔符)
## 后端实现要点
- 新增只读接口:
@@ -13,12 +13,13 @@
- `search` / `filters` 不进入 SQL 字符串:
- SQL 只负责 `SELECT * FROM {table_name} LIMIT {limit}`
- 返回后在 api-server 内存中过滤
- `filters` 接受 JSON object,按列名匹配;非 object 直接 400
- `filters` 接受两种 JSON 形式:object(列名到等值,如 `{"user_id":"u1"}`)与条件数组(如 `[{"column":"points","op":"gt","value":"5"}]`,运算符含 `eq``ne``gt``gte``lt``lte``contains``notContains``startsWith``endsWith``in``notIn``isEmpty``isNotEmpty`,允许同列多条件,条件间为 AND);非 object 且非数组直接 400,未知运算符或 value 形态不匹配也 400
- SpacetimeDB HTTP SQL 返回可能是 statement array + rows,解析时要兼容这一层结构。
## 前端实现要点
- `adminRoutes` 必须新增 `tables``AdminShell.routeIcons` 也要同步覆盖。
- `AdminApp` 需要显式渲染 `AdminDatabaseTablesPage`
- 预览表格数据行直接点击(或聚焦后按 Enter / Space)打开详情,详情按钮不单独占列;详情字段仅提供复制操作,复制反馈使用右下角自动消失的 Toast,JSON 预览与平台亮色 / 暗色主题保持一致,表头保持单行并在空间不足时省略显示。
- worktree 下可能没有本地 `node_modules/typescript/bin/tsc`,而根目录有依赖;在验证前可以临时把根目录 `node_modules` 软链到 worktree 再执行 `npm run admin-web:typecheck`,验证后删除软链,避免污染 git 状态。
## 验证结果
@@ -26,4 +27,4 @@
- `cargo fmt --manifest-path Cargo.toml -p api-server -p shared-contracts --check` 通过。
- `npm run admin-web:typecheck` 通过。
- `npm run admin-web:build` 通过。
- `npm run check:encoding` 通过。
- `npm run check:encoding` 通过。
@@ -8,6 +8,7 @@ import {
getAdminDatabaseTableRows,
getAdminDatabaseTables,
} from '../api/adminApiClient';
import type { AdminDatabaseTableRowsResponse } from '../api/adminApiTypes';
import {
AdminDatabaseTablesPage,
resolveAdminDatabaseUserReference,
@@ -23,7 +24,10 @@ vi.mock('../api/adminApiClient', () => ({
}));
vi.mock('../components/AdminUserReferenceButton', () => ({
AdminUserReferenceButton: ({ userId, publicUserCode }: {
AdminUserReferenceButton: ({
userId,
publicUserCode,
}: {
userId?: string;
publicUserCode?: string;
}) => (
@@ -124,7 +128,10 @@ test('后台表查询页通过页面级固定栏翻页并提示扫描结果可
),
).toBeTruthy();
await user.type(screen.getByRole('textbox', { name: '关键词' }), '未执行条件');
await user.type(
screen.getByRole('textbox', { name: '关键词' }),
'未执行条件',
);
await user.click(screen.getByRole('button', { name: '下一页' }));
await waitFor(() => {
@@ -141,6 +148,53 @@ test('后台表查询页通过页面级固定栏翻页并提示扫描结果可
});
});
test('后台表查询页不会让旧表请求覆盖新表结果', async () => {
const user = userEvent.setup();
let resolveFirstRequest!: (response: AdminDatabaseTableRowsResponse) => void;
const firstRequest = new Promise<AdminDatabaseTableRowsResponse>(
(resolve) => {
resolveFirstRequest = resolve;
},
);
const oldTableResponse = {
columns: ['id'],
limit: 100,
page: 1,
rows: [{ cells: { id: 'old-row' }, raw: ['old-row'] }],
scannedCount: 1,
scanLimit: 50000,
scanLimitReached: false,
tableName: 'profile_referral_relation',
totalMatched: 1,
totalReturned: 1,
} satisfies AdminDatabaseTableRowsResponse;
const newTableResponse = {
...oldTableResponse,
rows: [{ cells: { id: 'new-row' }, raw: ['new-row'] }],
tableName: 'profile_wallet',
} satisfies AdminDatabaseTableRowsResponse;
vi.mocked(getAdminDatabaseTables).mockResolvedValueOnce({
fetchErrors: [],
tables: ['profile_referral_relation', 'profile_wallet'],
});
vi.mocked(getAdminDatabaseTableRows)
.mockImplementationOnce(() => firstRequest)
.mockResolvedValueOnce(newTableResponse);
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
const tableSelect = await screen.findByRole('combobox');
await user.selectOptions(tableSelect, 'profile_wallet');
expect(await screen.findByText('new-row')).toBeTruthy();
resolveFirstRequest(oldTableResponse);
await waitFor(() => {
expect(screen.queryByText('old-row')).toBeNull();
});
expect(screen.getByText('new-row')).toBeTruthy();
});
test('后台表查询页把表头排序交给后端并从第一页展示排序结果', async () => {
const user = userEvent.setup();
const { container } = render(
@@ -165,7 +219,9 @@ test('后台表查询页把表头排序交给后端并从第一页展示排序
).toBe('原始表名:profile_referral_relation。邀请关系记录表。');
expect(
screen.getByRole('button', { name: '被邀请人ID' }).getAttribute('title'),
).toBe('原始字段名:invitee_user_id。被邀请人的用户标识。点击可按此列排序。');
).toBe(
'原始字段名:invitee_user_id。被邀请人的用户标识。点击列名可在正序、倒序和不排序之间循环切换。 当前状态:不排序。',
);
expect(readFirstColumnValues(container)).toEqual(['u-b', 'u-a', 'u-c']);
vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({
@@ -219,6 +275,88 @@ test('后台表查询页把表头排序交给后端并从第一页展示排序
);
expect(readFirstColumnValues(container)).toEqual(['u-a', 'u-b', 'u-c']);
});
vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({
columns: ['invitee_user_id', 'inviter_user_id', 'invite_code', 'bound_at'],
limit: 100,
page: 1,
rows: referralRows,
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: undefined,
sortDirection: undefined,
}),
);
expect(
screen
.getAllByRole('columnheader', { name: '邀请人ID' })
.filter((header) => header.textContent?.trim() === '邀请人ID')
.some((header) => header.getAttribute('aria-sort') === 'none'),
).toBe(true);
});
});
test('后台表查询页行详情将对象值以语法高亮 JSON 预览', async () => {
const user = userEvent.setup();
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({
columns: ['id', 'metadata'],
limit: 100,
page: 1,
rows: [
{
cells: {
id: 'row-1',
metadata: { enabled: true, count: 2, label: 'demo' },
},
raw: ['row-1', { enabled: true, count: 2, label: 'demo' }],
},
],
scannedCount: 1,
scanLimit: 50000,
scanLimitReached: false,
tableName: 'profile_referral_relation',
totalMatched: 1,
totalReturned: 1,
});
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('row-1');
expect(screen.queryByRole('button', { name: /^详情$/ })).toBeNull();
await user.click(screen.getByRole('row', { name: /row-1/ }));
const jsonPreview = document.querySelector('pre.admin-json-preview');
expect(jsonPreview).toBeTruthy();
expect(jsonPreview?.querySelector('.admin-json-token-key')).toBeTruthy();
expect(jsonPreview?.querySelector('.admin-json-token-boolean')).toBeTruthy();
expect(jsonPreview?.querySelector('.admin-json-token-number')).toBeTruthy();
expect(jsonPreview?.querySelector('.admin-json-token-string')).toBeTruthy();
expect(screen.getAllByRole('button', { name: /^复制/ })).toHaveLength(2);
await user.click(screen.getByRole('button', { name: '复制元数据' }));
await waitFor(() => {
expect(writeText).toHaveBeenCalledWith(
JSON.stringify({ enabled: true, count: 2, label: 'demo' }, null, 2),
);
});
expect(screen.getByRole('status').textContent).toBe('已复制 元数据');
});
test('数据库用户字段显示查看按钮且点击不会打开行详情', async () => {
@@ -227,7 +365,9 @@ test('数据库用户字段显示查看按钮且点击不会打开行详情', as
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
const userButton = await screen.findByRole('button', { name: '查看用户 u-b' });
const userButton = await screen.findByRole('button', {
name: '查看用户 u-b',
});
await user.click(userButton);
expect(screen.queryByRole('dialog')).toBeNull();
});
@@ -250,13 +390,194 @@ test('数据库用户字段识别会排除后台操作者与合成邀请码字
resolveAdminDatabaseUserReference('audit_log', 'admin_user_id', 'u-1'),
).toBeNull();
expect(
resolveAdminDatabaseUserReference('profile_wallet', 'user_id', 'admin:root'),
resolveAdminDatabaseUserReference(
'profile_wallet',
'user_id',
'admin:root',
),
).toBeNull();
expect(
resolveAdminDatabaseUserReference('profile_invite_code', 'user_id', 'u-1'),
).toBeNull();
});
test('后台表查询页字段条件构建后透传 filters 并支持列表值', async () => {
const user = userEvent.setup();
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('u-b');
await user.click(screen.getByRole('button', { name: '添加条件' }));
expect(screen.getAllByRole('columnheader', { name: '字段' })).toHaveLength(1);
expect(screen.getAllByRole('columnheader', { name: '条件' })).toHaveLength(1);
expect(screen.getAllByRole('columnheader', { name: '值' })).toHaveLength(1);
await user.selectOptions(
screen.getByRole('combobox', { name: '条件 1 字段' }),
'invite_code',
);
await user.type(
screen.getByRole('textbox', { name: '条件 1 值' }),
'INV-1001',
);
await user.click(screen.getByRole('button', { name: '添加条件' }));
await user.selectOptions(
screen.getByRole('combobox', { name: '条件 2 字段' }),
'inviter_user_id',
);
await user.selectOptions(
screen.getByRole('combobox', { name: '条件 2 运算符' }),
'in',
);
expect(screen.getByLabelText('条件 2 值列表').className).toContain(
'admin-database-filter-list-editor',
);
await user.type(
screen.getByRole('textbox', { name: '条件 2 值输入' }),
'u-a,b',
);
await user.click(screen.getByRole('button', { name: '添加条件 2 的值' }));
await user.type(
screen.getByRole('textbox', { name: '条件 2 值输入' }),
'u-c',
);
await user.click(screen.getByRole('button', { name: '添加条件 2 的值' }));
expect(
screen.getByText('u-a,b').closest('.admin-database-filter-values-row'),
).toBeTruthy();
expect(
screen
.getAllByText('u-c')
.some((element) => element.closest('.admin-database-filter-values-row')),
).toBe(true);
await user.click(getFormSubmitQueryButton());
await waitFor(() => {
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
'admin-token',
'profile_referral_relation',
expect.objectContaining({
filters: JSON.stringify([
{ column: 'invite_code', op: 'eq', value: 'INV-1001' },
{
column: 'inviter_user_id',
op: 'in',
value: ['u-a,b', 'u-c'],
},
]),
page: 1,
}),
);
});
});
test('后台表查询页可以勾选或取消勾选条件来切换筛选组合', async () => {
const user = userEvent.setup();
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('u-b');
await user.click(screen.getByRole('button', { name: '添加条件' }));
await user.selectOptions(
screen.getByRole('combobox', { name: '条件 1 字段' }),
'invite_code',
);
await user.type(
screen.getByRole('textbox', { name: '条件 1 值' }),
'INV-1001',
);
await user.click(screen.getByRole('button', { name: '添加条件' }));
await user.selectOptions(
screen.getByRole('combobox', { name: '条件 2 字段' }),
'inviter_user_id',
);
await user.type(screen.getByRole('textbox', { name: '条件 2 值' }), 'u-a');
await user.click(screen.getByRole('checkbox', { name: '启用条件 2' }));
await user.click(getFormSubmitQueryButton());
await waitFor(() => {
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
'admin-token',
'profile_referral_relation',
expect.objectContaining({
filters: JSON.stringify([
{ column: 'invite_code', op: 'eq', value: 'INV-1001' },
]),
page: 1,
}),
);
});
});
test('后台表查询页会提示未完成的启用条件而不是静默忽略', async () => {
const user = userEvent.setup();
const rowsRequest = vi.mocked(getAdminDatabaseTableRows);
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('u-b');
rowsRequest.mockClear();
await user.click(screen.getByRole('button', { name: '添加条件' }));
await user.click(getFormSubmitQueryButton());
expect(await screen.findByText('条件 1 未完成,请补充后再查询')).toBeTruthy();
expect(rowsRequest).not.toHaveBeenCalled();
});
test('后台表查询页列头筛选按钮会带字段添加条件', async () => {
const user = userEvent.setup();
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('u-b');
await user.click(
screen.getByRole('button', { name: '按被邀请人ID添加条件' }),
);
const columnSelect = screen.getByRole('combobox', {
name: '条件 1 字段',
}) as HTMLSelectElement;
expect(columnSelect.value).toBe('invitee_user_id');
await user.type(screen.getByRole('textbox', { name: '条件 1 值' }), 'u-b');
await user.click(getFormSubmitQueryButton());
await waitFor(() => {
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
'admin-token',
'profile_referral_relation',
expect.objectContaining({
filters: JSON.stringify([
{ column: 'invitee_user_id', op: 'eq', value: 'u-b' },
]),
page: 1,
}),
);
});
});
test('后台表查询页行详情仅保留复制操作', async () => {
const user = userEvent.setup();
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('u-b');
await user.click(screen.getByText('INV-1001').closest('tr')!);
expect(await screen.findByRole('dialog')).toBeTruthy();
expect(screen.queryByRole('button', { name: '按邀请码筛选' })).toBeNull();
expect(screen.getAllByRole('button', { name: /^复制/ })).toHaveLength(4);
});
function getFormSubmitQueryButton() {
const buttons = screen.getAllByRole('button', { name: '查询' });
return buttons[buttons.length - 1]!;
}
function readFirstColumnValues(container: HTMLElement) {
return Array.from(container.querySelectorAll('tbody tr')).map(
(row) => row.querySelector('td')?.textContent?.trim() ?? '',
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+34 -1
View File
@@ -21,7 +21,9 @@ describe('admin shell scrolling contract', () => {
});
test('mobile navigation stays in one horizontally scrollable row', () => {
const mobileStyles = stylesheet.slice(stylesheet.indexOf('@media (max-width: 980px)'));
const mobileStyles = stylesheet.slice(
stylesheet.indexOf('@media (max-width: 980px)'),
);
expect(mobileStyles).toContain('.admin-bottom-nav {');
expect(mobileStyles).toContain('display: flex');
expect(mobileStyles).toContain('overflow-x: auto');
@@ -30,6 +32,37 @@ describe('admin shell scrolling contract', () => {
);
expect(mobileStyles).toContain('flex: 0 0 76px');
});
test('database preview headers stay on one line and expose ellipsis', () => {
const sortButton = ruleFor('.admin-table-sort-button');
const sortLabel = ruleFor('.admin-table-sort-button span');
expect(sortButton).toContain('white-space: nowrap');
expect(sortButton).toContain('overflow: hidden');
expect(sortLabel).toContain('text-overflow: ellipsis');
expect(sortLabel).toContain('white-space: nowrap');
});
test('copy feedback uses an auto-dismissing toast surface', () => {
const toast = ruleFor('.admin-toast');
expect(toast).toContain('position: fixed');
expect(toast).toContain('z-index: 120');
expect(toast).toContain('pointer-events: none');
expect(toast).toContain('animation: admin-toast-in');
expect(stylesheet).toContain(".admin-toast[data-tone='error']");
});
test('detail JSON keeps only a slim scrollbar thumb', () => {
const json = ruleFor('.admin-json-preview');
expect(json).toContain('scrollbar-width: thin');
expect(json).toContain('scrollbar-color: #d1b09a transparent');
expect(stylesheet).toContain(
'.admin-json-preview::-webkit-scrollbar-track',
);
expect(stylesheet).toContain('background: transparent');
expect(stylesheet).toContain(
'.admin-json-preview::-webkit-scrollbar-thumb',
);
});
});
function ruleFor(selector: string) {
@@ -901,7 +901,9 @@ 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 秒硬限制;宽表即使每页条数很小也可能整次拒绝,不会返回部分结果。实时写入仍可能改变相邻请求的候选快照,精确审计应使用对应业务表的专用查询而不是通用浏览页。
后台通用表查询的“每页条数”不是筛选前的 SQL 截断量。API Server 通过单次 `SELECT * ... LIMIT 50001` 读取哨兵行,最多保留前 50,000 条候选;关键词 / 字段条件过滤、所选列的完整候选集稳定排序和 1-based `page` 分页都基于这一次 SQL 结果,`totalMatched` 不再依赖另一份 `COUNT(*)` 快照。`filters` 支持两种 JSON 形式:object(列名到等值,如 `{"user_id":"u1"}`,兼容旧入口)与条件数组(如 `[{"column":"points","op":"gt","value":"5"}]`,运算符覆盖 `eq``ne``gt``gte``lt``lte``contains``notContains``startsWith``endsWith``in``notIn``isEmpty``isNotEmpty`,允许同列多条件,条件间为 AND);两种形式的用户输入都不进入 SQL,只在 API Server 内存中过滤。请求页码超过实际总页数时钳制到末页,零结果固定返回第 1 页。存在第 50,001 条哨兵行时响应必须返回 `scanLimitReached=true`,后台固定分页栏上方明确提示匹配总数和分页结果可能不完整,不得把扫描范围外的数据误报为不存在。候选 SQL 响应体仍受 32 MiB 和 20 秒硬限制;宽表即使每页条数很小也可能整次拒绝,不会返回部分结果。实时写入仍可能改变相邻请求的候选快照,精确审计应使用对应业务表的专用查询而不是通用浏览页。
后台表查询页的结构化筛选条件支持逐条勾选启用或停用;停用条件不会进入请求,但会保留当前字段和值。`in` / `notIn` 使用逐项值标签编辑,支持粘贴多行值,避免把字符串中的逗号误拆成多个条件。
## Issue 与交接
File diff suppressed because it is too large Load Diff
@@ -682,6 +682,10 @@ pub struct AdminDatabaseTableListResponse {
}
// 后台通用表查询参数,用户输入不进入 SQL,只在 API Server 内存中过滤。
// filters 支持两种 JSON 形式:object(列名到等值,如 {"user_id":"u1"},兼容旧入口)
// 与条件数组(如 [{"column":"points","op":"gt","value":"5"}])。条件数组运算符包含
// eq、ne、gt、gte、lt、lte、contains、notContains、startsWith、endsWith、in、notIn、
// isEmpty、isNotEmpty;允许同列多条件,全部条件之间为 AND。
#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDatabaseTableRowsQuery {