diff --git a/.codex/skills/spacetimedb-cli/SKILL.md b/.codex/skills/spacetimedb-cli/SKILL.md index 68132d822..a3d892458 100644 --- a/.codex/skills/spacetimedb-cli/SKILL.md +++ b/.codex/skills/spacetimedb-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: spacetimedb-cli -description: SpacetimeDB 2.5 CLI reference for Genarrative. Use for spacetime build, publish, generate, call, sql, logs, server management, local dev, explicit server targeting, version checks, and remote runtime verification. +description: SpacetimeDB 2.6 CLI reference for Genarrative. Use for spacetime build, publish, generate, call, sql, logs, server management, local dev, explicit server targeting, version checks, and remote runtime verification. --- # SpacetimeDB CLI @@ -61,7 +61,7 @@ spacetime describe my-db table users --server http://127.0.0.1:3101 --json # Reducer/procedure calls. Arguments are positional JSON values. spacetime call --server http://127.0.0.1:3101 my-db my_reducer '"value"' '123' -# 2.5 accepts hex strings for Identity arguments without full JSON tuple syntax. +# 2.5+ accepts hex strings for Identity arguments without full JSON tuple syntax. spacetime call --server http://127.0.0.1:3101 my-db reducer_needing_identity 0xabc123... # Subscribe from CLI @@ -102,7 +102,7 @@ curl -fsS http://127.0.0.1:3101/v1/ping | Flag | Description | |------|-------------| | `--server`, `-s` | Target server nickname, host, or URL | -| `--yes`, `-y` | Non-interactive prompt skipping; in 2.5 prefer scoped values | +| `--yes`, `-y` | Non-interactive prompt skipping; in 2.6 use scoped values | | `--delete-data`, `-c` | Publish data policy: `always`, `on-conflict`, or `never` | | `--module-path`, `-p` | Module project path | | `--bin-path`, `-b` | Publish/generate from compiled wasm | @@ -146,6 +146,6 @@ pid="$(systemctl show spacetimedb.service -p MainPID --value)" ## Notes -- Procedure calls are stable in 2.5; module HTTP handlers/webhooks, unstable view features, and RLS remain behind unstable gates per release notes. -- 2.5 fixes `publish --delete-data` config fallback so `spacetime.json` can provide the database name. +- Procedure calls remain stable in 2.6; module HTTP handlers/webhooks and RLS capabilities still require their documented gates. +- 2.5 fixed `publish --delete-data` config fallback; 2.6 keeps that behavior and improves CLI binary distribution. - Genarrative scripts should pass `--server` or `--server-url` explicitly instead of relying on CLI defaults. diff --git a/.codex/skills/spacetimedb-concepts/SKILL.md b/.codex/skills/spacetimedb-concepts/SKILL.md index abb665f9b..f43b06519 100644 --- a/.codex/skills/spacetimedb-concepts/SKILL.md +++ b/.codex/skills/spacetimedb-concepts/SKILL.md @@ -1,6 +1,6 @@ --- name: spacetimedb-concepts -description: Understand SpacetimeDB 2.5 architecture, reducer/procedure/table/view semantics, schema evolution, subscriptions, identity, and Genarrative-specific backend boundaries. Use when designing or reviewing SpacetimeDB-backed features. +description: Understand SpacetimeDB 2.6 architecture, reducer/procedure/table/view semantics, schema evolution, subscriptions, identity, and Genarrative-specific backend boundaries. Use when designing or reviewing SpacetimeDB-backed features. --- # SpacetimeDB Core Concepts @@ -20,7 +20,7 @@ SpacetimeDB is a relational database that also executes application logic in upl 1. **Reducers are transactional**: they do not return data to callers. Read through subscriptions, read models, views, or BFF endpoints. 2. **Reducers are deterministic**: no filesystem, network, wall-clock, or external RNG. Use `ctx.timestamp`, `ctx.rng()` / `ctx.random()`, and tables. -3. **Procedures are stable in 2.5**: they can use explicit transactions and outgoing HTTP via `ctx.http`. +3. **Procedures are stable in 2.6**: they can use explicit transactions and outgoing HTTP via `ctx.http`. 4. **Identity comes from context**: use `ctx.sender()` or language equivalent for authorization. Never trust identity passed as an argument. 5. **Auto-increment IDs are not ordering guarantees**: gaps are normal. Use timestamps or explicit sequence columns for ordering. 6. **Schema changes need migration discipline**: existing Genarrative table fields must be appended with defaults; update migration code, table catalog, generated bindings, and run `npm run check:spacetime-schema`. @@ -44,25 +44,25 @@ Reducers are deterministic transactional functions. They are the primary client- ## Procedures -Procedures are stable in 2.5. They can be scheduled, can open explicit transactions with `with_tx` / `try_with_tx`, and can use outgoing HTTP (`ctx.http`). +Procedures are stable in 2.6. They can be scheduled, can open explicit transactions with `with_tx` / `try_with_tx`, and can use outgoing HTTP (`ctx.http`). Genarrative default: keep external provider protocols in `platform-*` and orchestration in `api-server` unless a task explicitly moves a workflow into a module procedure. -Module HTTP handlers/webhooks, unstable view features, and RLS `client_visibility_filter` remain gated behind unstable according to the 2.5 release notes. +Module HTTP handlers/webhooks and RLS `client_visibility_filter` remain subject to their documented gates in 2.6. ## Views -Views expose computed read-only data. In 2.4.1 Rust and TypeScript gained primary key support for procedural views; in 2.5 C# gained the same. Clients can receive `OnUpdate` events when subscribed to such views with primary keys. Ensure the view never returns duplicate primary keys, because that can fail view refresh and roll back the triggering transaction. +Views expose computed read-only data. SpacetimeDB 2.6 supports primary keys on procedural views in Rust, TypeScript, and C#. Clients can receive `OnUpdate` events when subscribed to such views with primary keys. Ensure the view never returns duplicate primary keys, because that can fail view refresh and roll back the triggering transaction. ## Event Tables Event tables broadcast reducer/procedure-specific facts to subscribers and must be subscribed explicitly. They are excluded from `subscribe_to_all_tables()`. -2.5 adds broader layout-altering automigrations for event tables, including column removal, reordering, and type changes that regular tables reject. This relaxed migration behavior is for event-only tables, not persistent tables. +2.6 supports broader layout-altering automigrations for event tables, including column removal, reordering, and type changes that regular tables reject. This relaxed migration behavior is for event-only tables, not persistent tables. Event-table primary keys and constraints are transaction-scoped. They can reject duplicate event rows within one transaction, but event rows are not retained in client cache, so clients observe event tables through insert callbacks only. Do not design Genarrative event tables around `OnUpdate` / `on_update` / `onUpdate`; use a persistent table or a primary-keyed procedural view when update callbacks are required. -Official 2.4.1/2.5 release notes document primary-key-backed update callbacks for procedural views, not event tables. +Official 2.4.1 through 2.6 release notes document primary-key-backed update callbacks for procedural views, not event tables. ## Subscriptions @@ -78,7 +78,7 @@ Best practices: - Avoid overlapping queries that duplicate row delivery. - Use indexes for subscribed filters. -## 2.2.0 to 2.5.0 Delta +## 2.2.0 to 2.6.0 Delta Genarrative introduced SpacetimeDB around 2.2.0. Important changes since then: @@ -87,6 +87,7 @@ Genarrative introduced SpacetimeDB around 2.2.0. Important changes since then: - **2.4.0**: unstable module HTTP handlers/webhooks, faster synchronous WASM reducer runtime, commitlog resume truncation fix for silent data loss risk, better commitlog decode context, V8 heap metrics for procedure workers, JS execution-time billing regression reverted. - **2.4.1**: Rust and TypeScript procedural views can declare primary keys, enabling `OnUpdate` events for subscribed views; fixed index schema from ST tables. - **2.5.0**: procedures are stable, C# procedural views gain primary keys, event tables allow broader layout-altering automigrations, BTreeSet storage makes row insertion deterministic and avoids accidentally quadratic bulk insert behavior, `wasm_memory_bytes` billing metric semantics changed, template version constraints unified, `publish --delete-data` config fallback fixed, CLI `call` accepts hex Identity arguments. +- **2.6.0**: procedural-view primary keys are available across Rust, TypeScript, and C#, commitlog gains `max_segment_size` / `write_buffer_size` / `preallocate_segments`, the default write buffer increases for throughput, event-table automigrations improve, and CLI binary distribution expands. ## Debugging Checklist diff --git a/.codex/skills/spacetimedb-rust/SKILL.md b/.codex/skills/spacetimedb-rust/SKILL.md index 889226ebb..5750ada65 100644 --- a/.codex/skills/spacetimedb-rust/SKILL.md +++ b/.codex/skills/spacetimedb-rust/SKILL.md @@ -1,6 +1,6 @@ --- name: spacetimedb-rust -description: Develop SpacetimeDB 2.5 server modules in Rust for Genarrative. Use when writing or reviewing tables, reducers, procedures, views, migrations, row mappers, schema changes, and module logic. +description: Develop SpacetimeDB 2.6 server modules in Rust for Genarrative. Use when writing or reviewing tables, reducers, procedures, views, migrations, row mappers, schema changes, and module logic. --- # SpacetimeDB Rust Module Development @@ -28,7 +28,7 @@ ctx.db.player.find(id) // Use ctx.db.player().id().find(&id) ctx.sender // Use ctx.sender() ctx.db.user().name().update(..) // Update by primary key only -spacetimedb = { version = "...", features = ["unstable"] } // Not needed for procedures in 2.5 +spacetimedb = { version = "...", features = ["unstable"] } // Not needed for procedures since 2.5 ``` ## Required Patterns @@ -181,11 +181,11 @@ fn deal_damage(ctx: &ReducerContext, target: Identity, amount: u32) { Event tables must be subscribed explicitly and are excluded from `subscribe_to_all_tables()`. -In 2.5, event tables support broader layout-altering automigrations than regular tables, including column removal, reordering, and type changes. This relaxed migration policy does not apply to persistent tables. +In 2.6, event tables support broader layout-altering automigrations than regular tables, including column removal, reordering, and type changes. This relaxed migration policy does not apply to persistent tables. Event-table primary keys and constraints are enforced only within the current transaction. They do not make event rows persistent, and client SDKs expose event tables as insert-only event streams. Do not rely on `OnUpdate` / `on_update` / `onUpdate` for event tables; use a persistent table or a primary-keyed procedural view when update callbacks are required. -Official 2.4.1/2.5 release notes tie primary-key-backed update callbacks to procedural views, not event tables. +Official 2.4.1 through 2.6 release notes tie primary-key-backed update callbacks to procedural views, not event tables. ## Views @@ -228,7 +228,7 @@ For scheduled reducers, check `ctx.sender_auth().is_internal()` when the reducer ## Procedures -Procedures are stable in 2.5 and no longer require the `unstable` feature. +Procedures remain stable in 2.6 and no longer require the `unstable` feature. ```rust use spacetimedb::{procedure, ProcedureContext}; diff --git a/.env.example b/.env.example index 6b611bebd..f9a6f0423 100644 --- a/.env.example +++ b/.env.example @@ -104,6 +104,8 @@ WECHAT_ACCESS_TOKEN_ENDPOINT="https://api.weixin.qq.com/sns/oauth2/access_token" WECHAT_USER_INFO_ENDPOINT="https://api.weixin.qq.com/sns/userinfo" WECHAT_JS_CODE_SESSION_ENDPOINT="https://api.weixin.qq.com/sns/jscode2session" WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT="https://api.weixin.qq.com/cgi-bin/stable_token" +WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT="https://api.weixin.qq.com/xpay/query_order" +WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_NOTIFY_PROVIDE_GOODS_ENDPOINT="https://api.weixin.qq.com/xpay/notify_provide_goods" WECHAT_PHONE_NUMBER_ENDPOINT="https://api.weixin.qq.com/wxa/business/getuserphonenumber" WECHAT_STATE_TTL_MINUTES="15" WECHAT_MOCK_USER_ID="wx-mock-user" diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 89cb391db..5d17b64d6 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -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}` : ''; } diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index caed03606..0322d9e9d 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -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 { diff --git a/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx b/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx index 3c2c04cbd..f6ef68b2e 100644 --- a/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx +++ b/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx @@ -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( + , + ); + + 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( , @@ -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']); }); }); diff --git a/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx b/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx index 88192e2b8..330406477 100644 --- a/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx +++ b/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx @@ -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(null); + const appliedQueryRef = useRef({ + search: '', + filters: '', + limit: '100', + sortColumn: '', + sortDirection: 'asc' as SortDirection, + }); const [tables, setTables] = useState([]); 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('.admin-bottom-nav'); + const pagination = pageElement.querySelector( + '.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) { 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 ( -
+

表查询

@@ -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 })} >