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 63ba34365..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 @@ -2,7 +2,7 @@ ## 需求落点 - 后台“总览”页的表统计仍保留,只把每张表的表名改成可点击跳转到 `#tables?table=`。 -- 新增独立 `#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。数组中的 `eq` / `ne` 和其他标量运算符一样必须提供 `value`;显式空值判断使用 `isEmpty` / `isNotEmpty` - 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` 通过。 \ No newline at end of file +- `npm run check:encoding` 通过。 diff --git a/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx b/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx index d6bf2e71f..d246812c6 100644 --- a/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx +++ b/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx @@ -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; }) => ( @@ -68,6 +72,7 @@ const referralRows = [ ]; beforeEach(() => { + vi.clearAllMocks(); window.location.hash = '#tables?table=profile_referral_relation'; vi.mocked(getAdminDatabaseTables).mockResolvedValue({ fetchErrors: [], @@ -124,7 +129,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 +149,53 @@ test('后台表查询页通过页面级固定栏翻页并提示扫描结果可 }); }); +test('后台表查询页不会让旧表请求覆盖新表结果', async () => { + const user = userEvent.setup(); + let resolveFirstRequest!: (response: AdminDatabaseTableRowsResponse) => void; + const firstRequest = new Promise( + (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( + , + ); + 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 +220,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 +276,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( + , + ); + + 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,11 +366,28 @@ test('数据库用户字段显示查看按钮且点击不会打开行详情', as , ); - 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(); }); +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'), @@ -250,13 +406,254 @@ 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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(); + 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( + , + ); + + 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( + , + ); + + 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); +}); + +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 f0d114373..22478cf32 100644 --- a/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx +++ b/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx @@ -4,13 +4,16 @@ import { ArrowUpDown, ChevronLeft, ChevronRight, - Eye, + Filter, + Plus, RefreshCcw, Search, X, } from 'lucide-react'; import { + ClipboardEvent, FormEvent, + KeyboardEvent, useEffect, useLayoutEffect, useMemo, @@ -36,6 +39,11 @@ interface AdminDatabaseTablesPageProps { type SortDirection = 'asc' | 'desc'; +type AdminCopyToast = { + message: string; + tone: 'success' | 'error'; +}; + export function AdminDatabaseTablesPage({ token, onUnauthorized, @@ -48,10 +56,11 @@ export function AdminDatabaseTablesPage({ sortColumn: '', sortDirection: 'asc' as SortDirection, }); + const rowsRequestIdRef = useRef(0); const [tables, setTables] = useState([]); const [tableName, setTableName] = useState(() => readHashTableName()); const [search, setSearch] = useState(''); - const [filters, setFilters] = useState(''); + const [conditions, setConditions] = useState([]); const [limit, setLimit] = useState('100'); const [result, setResult] = useState( null, @@ -59,7 +68,7 @@ export function AdminDatabaseTablesPage({ const [detailRow, setDetailRow] = useState(null); const [errorMessage, setErrorMessage] = useState(''); - const [copyMessage, setCopyMessage] = useState(''); + const [copyToast, setCopyToast] = useState(null); const [sortColumn, setSortColumn] = useState(''); const [sortDirection, setSortDirection] = useState('asc'); const [isLoadingTables, setIsLoadingTables] = useState(false); @@ -99,6 +108,14 @@ export function AdminDatabaseTablesPage({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [tableName]); + useEffect(() => { + if (!copyToast) { + return; + } + const timer = window.setTimeout(() => setCopyToast(null), 2200); + return () => window.clearTimeout(timer); + }, [copyToast]); + useLayoutEffect(() => { const pageElement = pageRef.current; if (!pageElement) { @@ -147,6 +164,26 @@ export function AdminDatabaseTablesPage({ return firstRow ? Object.keys(firstRow.cells) : []; }, [result]); + const detailFields = useMemo(() => { + if (!detailRow) { + return []; + } + const cells = detailRow.cells; + if (typeof cells !== 'object' || cells === null || Array.isArray(cells)) { + return []; + } + return Object.entries(cells).map(([column, value]) => { + const columnHeader = getDatabaseTableColumnHeader(tableName, column); + return { + cellValue: formatCellValue(value, column), + column, + description: columnHeader.description, + jsonPreview: getJsonPreviewText(value), + label: columnHeader.label, + }; + }); + }, [detailRow, tableName]); + const tableOptions = useMemo(() => { const optionNames = tableName && !tables.includes(tableName) @@ -211,10 +248,12 @@ export function AdminDatabaseTablesPage({ return; } const querySearch = options.search ?? search; - const queryFilters = options.filters ?? filters; + const queryFilters = + options.filters ?? buildDatabaseFiltersJson(conditions); const queryLimit = options.limit ?? limit; const querySortColumn = options.sortColumn ?? sortColumn; const querySortDirection = options.sortDirection ?? sortDirection; + const requestId = ++rowsRequestIdRef.current; setIsLoadingRows(true); setErrorMessage(''); try { @@ -230,6 +269,9 @@ export function AdminDatabaseTablesPage({ sortDirection: querySortColumn ? querySortDirection : undefined, }, ); + if (requestId !== rowsRequestIdRef.current) { + return; + } appliedQueryRef.current = { search: querySearch, filters: queryFilters, @@ -240,22 +282,42 @@ export function AdminDatabaseTablesPage({ setSortColumn(querySortColumn); setSortDirection(querySortDirection); setResult(response); - setCopyMessage(''); + setCopyToast(null); } catch (error: unknown) { + if (requestId !== rowsRequestIdRef.current) { + return; + } handlePageError(error, onUnauthorized, setErrorMessage); } finally { - setIsLoadingRows(false); + if (requestId === rowsRequestIdRef.current) { + setIsLoadingRows(false); + } } } function handleSearch(event: FormEvent) { event.preventDefault(); + const validationMessage = getDatabaseFilterValidationMessage(conditions); + if (validationMessage) { + setErrorMessage(validationMessage); + return; + } + 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'); + setConditions([]); setTableName(nextTableName); const nextHash = `#tables?table=${encodeURIComponent(nextTableName)}`; if (window.location.hash !== nextHash) { @@ -265,7 +327,7 @@ export function AdminDatabaseTablesPage({ function handleResetQuery() { setSearch(''); - setFilters(''); + setConditions([]); setLimit('100'); setSortColumn(''); setSortDirection('asc'); @@ -279,36 +341,194 @@ export function AdminDatabaseTablesPage({ }); } + function handleAddCondition(column = '') { + const nextConditions = [ + ...conditions, + { + column, + op: 'eq' as DatabaseFilterOperator, + value: '', + values: [], + enabled: true, + }, + ]; + setConditions(nextConditions); + focusConditionControl( + nextConditions.length - 1, + column ? 'value' : 'column', + ); + } + + function handleUpdateCondition( + index: number, + patch: Partial, + ) { + setErrorMessage(''); + setConditions((current) => + current.map((condition, conditionIndex) => + conditionIndex === index ? { ...condition, ...patch } : condition, + ), + ); + } + + function handleConditionOperatorChange( + index: number, + op: DatabaseFilterOperator, + ) { + setErrorMessage(''); + setConditions((current) => + current.map((condition, conditionIndex) => { + if (conditionIndex !== index) { + return condition; + } + const nextOption = getDatabaseFilterOperatorOption(op); + const previousOption = getDatabaseFilterOperatorOption(condition.op); + const nextValues = nextOption?.listValue + ? condition.values.length + ? condition.values + : condition.value.trim() + ? [condition.value.trim()] + : [] + : condition.values; + return { + ...condition, + op, + value: + nextOption?.listValue && !previousOption?.listValue + ? '' + : condition.value, + values: nextValues, + }; + }), + ); + } + + function handleAddConditionListValue(index: number) { + setConditions((current) => + current.map((condition, conditionIndex) => { + if (conditionIndex !== index) { + return condition; + } + const value = condition.value.trim(); + return value + ? { ...condition, value: '', values: [...condition.values, value] } + : condition; + }), + ); + setErrorMessage(''); + } + + function handleRemoveConditionListValue(index: number, valueIndex: number) { + setConditions((current) => + current.map((condition, conditionIndex) => + conditionIndex === index + ? { + ...condition, + values: condition.values.filter( + (_, currentValueIndex) => currentValueIndex !== valueIndex, + ), + } + : condition, + ), + ); + setErrorMessage(''); + } + + function handlePasteConditionListValues( + index: number, + event: ClipboardEvent, + ) { + const pastedText = event.clipboardData.getData('text'); + if (!pastedText.includes('\n')) { + return; + } + event.preventDefault(); + const pastedValues = pastedText + .split(/\r?\n/) + .map((value) => value.trim()) + .filter(Boolean); + if (!pastedValues.length) { + return; + } + setConditions((current) => + current.map((condition, conditionIndex) => + conditionIndex === index + ? { + ...condition, + values: [...condition.values, ...pastedValues], + } + : condition, + ), + ); + setErrorMessage(''); + } + + function handleRemoveCondition(index: number) { + setConditions((current) => + current.filter((_, conditionIndex) => conditionIndex !== index), + ); + } + function handlePageChange(page: number) { void refreshRows(tableName, { ...appliedQueryRef.current, page }); } + function handleRowKeyDown( + event: KeyboardEvent, + row: AdminDatabaseTableRowPayload, + ) { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + if (isInteractiveFormControl(event.target)) { + return; + } + event.preventDefault(); + setDetailRow(row); + } + function handleSortColumn(column: string) { - const nextDirection = - sortColumn === column && sortDirection === 'asc' ? 'desc' : 'asc'; + let nextSortColumn = column; + let nextDirection: SortDirection = 'asc'; + if (sortColumn === column) { + if (sortDirection === 'asc') { + nextDirection = 'desc'; + } else { + nextSortColumn = ''; + } + } void refreshRows(tableName, { ...appliedQueryRef.current, page: 1, - sortColumn: column, + sortColumn: nextSortColumn, sortDirection: nextDirection, }); } - async function handleCopyDetailJson() { - if (!detailRow) { + async function handleCopyDetailField(column: string, value: unknown) { + const copiedText = stringifyDetailValue(value); + if ( + value === null || + typeof value === 'undefined' || + copiedText.trim() === '' + ) { + setCopyToast({ + message: '该字段为空,无法复制', + tone: 'error', + }); return; } - - const copiedText = JSON.stringify( - detailRow.raw ?? detailRow.cells, - null, - 2, - ); try { await navigator.clipboard.writeText(copiedText); - setCopyMessage('已复制 JSON'); + setCopyToast({ + message: `已复制 ${getDatabaseTableColumnLabel(column)}`, + tone: 'success', + }); } catch { - setCopyMessage('复制失败,请手动选中后复制'); + setCopyToast({ + message: '复制失败,请手动选中后复制', + tone: 'error', + }); } } @@ -336,7 +556,7 @@ export function AdminDatabaseTablesPage({ className="admin-primary-button" disabled={!tableName || isLoadingRows} type="button" - onClick={() => void refreshRows(tableName, { page: 1 })} + onClick={handleQueryRows} >