diff --git a/.codex/skills/genarrative-admin-backoffice/references/admin-database-table-query-2026-05-08.md b/.codex/skills/genarrative-admin-backoffice/references/admin-database-table-query-2026-05-08.md index 96400d4d6..0f9adf8b9 100644 --- a/.codex/skills/genarrative-admin-backoffice/references/admin-database-table-query-2026-05-08.md +++ b/.codex/skills/genarrative-admin-backoffice/references/admin-database-table-query-2026-05-08.md @@ -13,13 +13,13 @@ - `search` / `filters` 不进入 SQL 字符串: - SQL 只负责 `SELECT * FROM {table_name} LIMIT {limit}` - 返回后在 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);非 object 且非数组直接 400,未知运算符或 value 形态不匹配也 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。数组中的 `eq` / `ne` 和其他标量运算符一样必须提供 `value`;显式空值判断使用 `isEmpty` / `isNotEmpty` - SpacetimeDB HTTP SQL 返回可能是 statement array + rows,解析时要兼容这一层结构。 ## 前端实现要点 - `adminRoutes` 必须新增 `tables`,`AdminShell.routeIcons` 也要同步覆盖。 - `AdminApp` 需要显式渲染 `AdminDatabaseTablesPage`。 -- 预览表格数据行直接点击(或聚焦后按 Enter / Space)打开详情,详情按钮不单独占列;详情字段仅提供复制操作,复制反馈使用右下角自动消失的 Toast,JSON 预览与平台亮色 / 暗色主题保持一致,表头保持单行并在空间不足时省略显示。 +- 预览表格数据行直接点击(或行自身聚焦后按 Enter / Space)打开详情,行内按钮 / 输入控件的键盘操作不冒泡打开详情;详情按钮不单独占列。详情字段仅提供复制操作,成功、剪贴板失败和空字段复制都使用右下角自动消失的 Toast,JSON 预览与平台亮色 / 暗色主题保持一致,表头保持单行并在空间不足时省略显示。表单和标题区查询按钮共用筛选完整性校验。 - worktree 下可能没有本地 `node_modules/typescript/bin/tsc`,而根目录有依赖;在验证前可以临时把根目录 `node_modules` 软链到 worktree 再执行 `npm run admin-web:typecheck`,验证后删除软链,避免污染 git 状态。 ## 验证结果 diff --git a/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx b/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx index 923e01609..d246812c6 100644 --- a/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx +++ b/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx @@ -72,6 +72,7 @@ const referralRows = [ ]; beforeEach(() => { + vi.clearAllMocks(); window.location.hash = '#tables?table=profile_referral_relation'; vi.mocked(getAdminDatabaseTables).mockResolvedValue({ fetchErrors: [], @@ -372,6 +373,21 @@ test('数据库用户字段显示查看按钮且点击不会打开行详情', as expect(screen.queryByRole('dialog')).toBeNull(); }); +test('数据库用户查看按钮上的键盘操作不会同时打开行详情', async () => { + const user = userEvent.setup(); + render( + , + ); + + const userButton = await screen.findByRole('button', { + name: '查看用户 u-b', + }); + userButton.focus(); + await user.keyboard('{Enter}'); + + expect(screen.queryByRole('dialog')).toBeNull(); +}); + test('数据库用户字段识别会排除后台操作者与合成邀请码字段', () => { expect( resolveAdminDatabaseUserReference('profile_wallet', 'owner_user_id', 'u-1'), @@ -528,6 +544,22 @@ test('后台表查询页会提示未完成的启用条件而不是静默忽略', expect(rowsRequest).not.toHaveBeenCalled(); }); +test('后台表查询页顶部查询按钮会提示未完成的启用条件而不是静默忽略', async () => { + const user = userEvent.setup(); + const rowsRequest = vi.mocked(getAdminDatabaseTableRows); + render( + , + ); + + await screen.findByText('u-b'); + rowsRequest.mockClear(); + await user.click(screen.getByRole('button', { name: '添加条件' })); + await user.click(getHeadingQueryButton()); + + expect(await screen.findByText('条件 1 未完成,请补充后再查询')).toBeTruthy(); + expect(rowsRequest).not.toHaveBeenCalled(); +}); + test('后台表查询页列头筛选按钮会带字段添加条件', async () => { const user = userEvent.setup(); render( @@ -573,11 +605,55 @@ test('后台表查询页行详情仅保留复制操作', async () => { expect(screen.getAllByRole('button', { name: /^复制/ })).toHaveLength(4); }); +test('后台表查询页空字段详情复制会明确提示无法复制空值', async () => { + const user = userEvent.setup(); + const writeText = vi.fn(); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({ + columns: ['id', 'deleted_at'], + limit: 100, + page: 1, + rows: [ + { + cells: { + id: 'row-null', + deleted_at: null, + }, + raw: ['row-null', null], + }, + ], + scannedCount: 1, + scanLimit: 50000, + scanLimitReached: false, + tableName: 'profile_referral_relation', + totalMatched: 1, + totalReturned: 1, + }); + render( + , + ); + + await screen.findByText('row-null'); + await user.click(screen.getByText('row-null').closest('tr')!); + await user.click(screen.getByRole('button', { name: '复制删除时间' })); + + expect(screen.getByRole('alert').textContent).toBe('该字段为空,无法复制'); + expect(writeText).not.toHaveBeenCalled(); +}); + function getFormSubmitQueryButton() { const buttons = screen.getAllByRole('button', { name: '查询' }); return buttons[buttons.length - 1]!; } +function getHeadingQueryButton() { + const buttons = screen.getAllByRole('button', { name: '查询' }); + return buttons[0]!; +} + 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 46ca5c9a8..22478cf32 100644 --- a/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx +++ b/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx @@ -305,6 +305,15 @@ export function AdminDatabaseTablesPage({ void refreshRows(tableName, { page: 1 }); } + function handleQueryRows() { + const validationMessage = getDatabaseFilterValidationMessage(conditions); + if (validationMessage) { + setErrorMessage(validationMessage); + return; + } + void refreshRows(tableName, { page: 1 }); + } + function handleTableChange(nextTableName: string) { setSortColumn(''); setSortDirection('asc'); @@ -471,6 +480,9 @@ export function AdminDatabaseTablesPage({ if (event.key !== 'Enter' && event.key !== ' ') { return; } + if (isInteractiveFormControl(event.target)) { + return; + } event.preventDefault(); setDetailRow(row); } @@ -495,7 +507,15 @@ export function AdminDatabaseTablesPage({ async function handleCopyDetailField(column: string, value: unknown) { const copiedText = stringifyDetailValue(value); - if (!copiedText) { + if ( + value === null || + typeof value === 'undefined' || + copiedText.trim() === '' + ) { + setCopyToast({ + message: '该字段为空,无法复制', + tone: 'error', + }); return; } try { @@ -536,7 +556,7 @@ export function AdminDatabaseTablesPage({ className="admin-primary-button" disabled={!tableName || isLoadingRows} type="button" - onClick={() => void refreshRows(tableName, { page: 1 })} + onClick={handleQueryRows} >