后台数据库表查询支持结构化筛选与详情优化
替换手写筛选 JSON 为结构化条件编辑器 支持条件勾选、列表值编辑和三态排序 优化行详情、字段复制 Toast 与主题 JSON 预览 修复数值范围筛选和异步请求竞态 补充后台查询接口校验、回归测试与文档
This commit is contained in:
+4
-3
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user