From a2b70d92b802d792a8e34e8c031a160651005d1b Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 1 Oct 2026 16:30:40 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BD=9C=E5=93=81=E7=AE=A1=E7=90=86=E4=B8=8EPh?= =?UTF-8?q?aser4=E5=AE=A2=E6=88=B7=E7=AB=AF=E5=8F=91=E5=B8=83=E9=97=AD?= =?UTF-8?q?=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐 Phaser4 客户端发布前校验、阶段进度与原生幂等账本 增加 Web 作者私有作品管理、资料编辑、版本历史与软删除 增加后台作品筛选、游标分页与删除治理 同步 SpacetimeDB schema、bindings、DTO、文档与定向测试 --- apps/admin-web/src/api/adminApiClient.test.ts | 28 +- apps/admin-web/src/api/adminApiClient.ts | 26 +- apps/admin-web/src/api/adminApiTypes.ts | 13 +- .../pages/AdminGameManagementPage.test.tsx | 110 +++++ .../src/pages/AdminGameManagementPage.tsx | 287 ++++++++++-- .../src-tauri/src/commands.rs | 20 + .../src/game_distribution_publish.rs | 127 ++++- .../src-tauri/src/game_package_upload.rs | 41 +- .../src-tauri/src/game_publish_attempt.rs | 390 +++++++++++++++ .../src-tauri/src/main.rs | 1 + .../src-tauri/src/project/export.rs | 199 ++++++++ .../src-tauri/src/tests/project.rs | 142 ++++++ apps/ai-game-creator-shell/src/App.tsx | 1 + .../GameDistributionPublishPanel.tsx | 65 ++- .../GamePublishPhaseSteps.tsx | 70 +++ .../GamePublishProgressDialog.tsx | 13 + .../src/services/gameDistributionPublish.ts | 19 +- .../src/services/gamePackageUploadProgress.ts | 116 +++++ apps/ai-game-creator-shell/src/styles.css | 96 ++++ .../tests/gameDistributionPublish.test.ts | 25 + .../tests/gameDistributionPublishLive.test.ts | 43 +- .../gameDistributionPublishPanel.test.tsx | 120 ++++- .../tests/gamePackageUploadProgress.test.ts | 136 ++++++ ...划】游戏作品管理与客户端发布收口-2026-09-30.md | 49 +- ...„】server-rs与SpacetimeDB数据契约-2026-05-15.md | 17 +- ...�玩法创作】平台入口与玩法链路-2026-05-15.md | 7 +- .../shared/src/contracts/gameDistribution.ts | 25 +- scripts/admin-web-fake-api.mjs | 60 ++- .../check-game-distribution-dto-parity.mjs | 4 + .../src/modules/game_distribution.rs | 443 +++++++++++++++++- .../shared-contracts/src/game_distribution.rs | 50 ++ .../crates/spacetime-client/src/active.rs | 11 +- .../src/active/mapper/game_distribution.rs | 12 +- .../spacetime-client/src/game_distribution.rs | 143 +++++- .../spacetime-client/src/module_bindings.rs | 8 + ...stribution_version_and_return_procedure.rs | 16 +- ...stribution_version_and_return_procedure.rs | 16 +- ...stribution_package_and_return_procedure.rs | 16 +- ..._distribution_game_and_return_procedure.rs | 16 +- ...stribution_version_and_return_procedure.rs | 16 +- ..._distribution_game_and_return_procedure.rs | 59 +++ ...istribution_upload_and_return_procedure.rs | 16 +- ...distribution_admin_game_list_input_type.rs | 4 + ...istribution_admin_game_list_result_type.rs | 1 + ...e_distribution_admin_game_snapshot_type.rs | 1 + ...ame_distribution_delete_game_input_type.rs | 20 + .../game_distribution_game_type.rs | 3 + ...distribution_update_metadata_input_type.rs | 33 ++ ..._distribution_game_and_return_procedure.rs | 16 +- ...stribution_version_and_return_procedure.rs | 16 +- ...stribution_version_and_return_procedure.rs | 16 +- ..._distribution_game_and_return_procedure.rs | 16 +- ...distribution_games_and_return_procedure.rs | 16 +- ...distribution_games_and_return_procedure.rs | 16 +- ...stribution_reviews_and_return_procedure.rs | 16 +- ...distribution_games_and_return_procedure.rs | 16 +- ...distribution_games_and_return_procedure.rs | 16 +- ...stribution_version_and_return_procedure.rs | 16 +- ..._distribution_game_and_return_procedure.rs | 16 +- ...version_for_review_and_return_procedure.rs | 16 +- ..._distribution_game_and_return_procedure.rs | 16 +- ..._distribution_game_and_return_procedure.rs | 16 +- ...tion_game_metadata_and_return_procedure.rs | 62 +++ .../spacetime-module/src/game_distribution.rs | 410 +++++++++++++++- .../crates/spacetime-module/src/migration.rs | 2 + .../game-distribution/GameDetailPage.tsx | 176 ++++++- .../GameDistributionPages.test.tsx | 117 ++++- .../GamePublishMediaFieldset.tsx | 234 +++++++++ .../game-distribution/GamePublishPage.tsx | 328 +------------ .../game-distribution/GameVersionHistory.tsx | 79 ++++ .../GameWorkMetadataEditor.tsx | 337 +++++++++++++ .../game-distribution/MyGamesPage.test.tsx | 344 +++++++++++++- .../game-distribution/MyGamesPage.tsx | 417 ++++++++++++++++- .../game-distribution/gameDistribution.css | 133 ++++++ .../game-distribution/gameMetadataOptions.ts | 55 +++ .../gamePublishMediaDefaults.ts | 80 ++++ .../gameVersionHistoryModel.ts | 60 +++ .../PlatformEntryActiveFlowShell.test.tsx | 160 +++++++ .../PlatformEntryActiveFlowShell.tsx | 17 +- src/services/gameDistributionClient.ts | 143 +++++- 80 files changed, 5836 insertions(+), 676 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/game_publish_attempt.rs create mode 100644 apps/ai-game-creator-shell/src/components/game-distribution/GamePublishPhaseSteps.tsx create mode 100644 apps/ai-game-creator-shell/src/services/gamePackageUploadProgress.ts create mode 100644 apps/ai-game-creator-shell/tests/gamePackageUploadProgress.test.ts create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/delete_game_distribution_game_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_delete_game_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/game_distribution_update_metadata_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/update_game_distribution_game_metadata_and_return_procedure.rs create mode 100644 src/components/game-distribution/GamePublishMediaFieldset.tsx create mode 100644 src/components/game-distribution/GameVersionHistory.tsx create mode 100644 src/components/game-distribution/GameWorkMetadataEditor.tsx create mode 100644 src/components/game-distribution/gameMetadataOptions.ts create mode 100644 src/components/game-distribution/gamePublishMediaDefaults.ts create mode 100644 src/components/game-distribution/gameVersionHistoryModel.ts diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index 7c5464f06..299f2c2b4 100644 --- a/apps/admin-web/src/api/adminApiClient.test.ts +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -568,7 +568,7 @@ test('游戏管理列表与恢复动作使用约定的 URL、方法和幂等键' ); expect(fetchMock.mock.calls[0]?.[0]).toBe( - '/admin/api/game-distribution/games?limit=50', + '/admin/api/game-distribution/games?limit=80', ); expect(fetchMock.mock.calls[0]?.[1]).toEqual( expect.objectContaining({ @@ -606,6 +606,32 @@ test('游戏管理列表与恢复动作使用约定的 URL、方法和幂等键' expect(fetchMock).toHaveBeenCalledTimes(2); }); +test('游戏管理列表把筛选与游标编码进查询串', async () => { + const fetchMock = vi + .fn() + .mockImplementation(() => + Promise.resolve( + new Response( + JSON.stringify({ ok: true, data: { games: [], nextCursor: null } }), + { status: 200 }, + ), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + await listAdminGameDistributionGames('admin-token', { + limit: 20, + keyword: ' 拼图 ', + owner: 'user_9', + status: 'suspended', + cursor: '1710000000000:game_1', + }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + '/admin/api/game-distribution/games?limit=20&keyword=%E6%8B%BC%E5%9B%BE&owner=user_9&status=suspended&cursor=1710000000000%3Agame_1', + ); +}); + test('安全下架请求携带公开修订号、原因与幂等键', async () => { const fetchMock = vi.fn().mockImplementation(() => Promise.resolve( diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 8aae410af..9dd924e8f 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -33,6 +33,7 @@ import type { AdminExternalApiKeyListResponse, AdminFeatureGateConfigResponse, AdminGameDistributionGameListResponse, + AdminGameDistributionGameStatus, AdminGameDistributionRestoreRequest, AdminGameDistributionRestoreResponse, AdminGameDistributionReviewListResponse, @@ -1243,17 +1244,36 @@ export function listAdminGameDistributionReviews(token: string, limit = 48) { ); } +/** + * 后台作品管理列表。 + * + * 关键词、作者与状态过滤以及游标分页都由服务端执行:前端只透传查询参数, + * 不本地过滤,否则分页边界会与 `nextCursor` 的语义打架。 + */ export function listAdminGameDistributionGames( token: string, - options: { limit?: number } = {}, + options: { + limit?: number; + keyword?: string; + owner?: string; + status?: AdminGameDistributionGameStatus | ''; + cursor?: string | null; + } = {}, signal?: AbortSignal, ) { const requestedLimit = options.limit ?? 50; const normalizedLimit = Number.isFinite(requestedLimit) - ? Math.min(Math.max(Math.trunc(requestedLimit), 1), 50) + ? Math.min(Math.max(Math.trunc(requestedLimit), 1), 200) : 50; + const params = new URLSearchParams({ limit: String(normalizedLimit) }); + const keyword = options.keyword?.trim(); + if (keyword) params.set('keyword', keyword); + const owner = options.owner?.trim(); + if (owner) params.set('owner', owner); + if (options.status) params.set('status', options.status); + if (options.cursor) params.set('cursor', options.cursor); return request( - `/admin/api/game-distribution/games?limit=${normalizedLimit}`, + `/admin/api/game-distribution/games?${params.toString()}`, { token, signal }, ); } diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 429ce7c5a..e6efdcc19 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -1158,18 +1158,29 @@ export interface AdminGameDistributionGameEntry { name: string; avatarUrl: string | null; }; - status: string; + status: AdminGameDistributionGameStatus; versionCount: number; playCount: number; activeVersionId: string | null; publicationRevision: number; createdAt: string; updatedAt: string; + /** 作者软删除时间;为空表示未删除。 */ + deletedAt: string | null; versions: AdminGameDistributionGameVersionEntry[]; } +/** 后台作品列表的状态:游戏可见性 + 作者软删除。 */ +export type AdminGameDistributionGameStatus = + | 'published' + | 'unpublished' + | 'suspended' + | 'deleted'; + export interface AdminGameDistributionGameListResponse { games: AdminGameDistributionGameEntry[]; + /** 还有下一页时为下一页游标;旧响应没有该字段,按空值处理。 */ + nextCursor?: string | null; } export interface AdminGameDistributionRestoreRequest { diff --git a/apps/admin-web/src/pages/AdminGameManagementPage.test.tsx b/apps/admin-web/src/pages/AdminGameManagementPage.test.tsx index 18407c5bb..8836d3244 100644 --- a/apps/admin-web/src/pages/AdminGameManagementPage.test.tsx +++ b/apps/admin-web/src/pages/AdminGameManagementPage.test.tsx @@ -68,6 +68,7 @@ const publishedGame: AdminGameDistributionGameEntry = { publicationRevision: 4, createdAt: '2026-09-18T08:00:00Z', updatedAt: '2026-09-20T10:00:00Z', + deletedAt: null, versions: [version], }; @@ -86,6 +87,26 @@ const suspendedGame: AdminGameDistributionGameEntry = { publicationRevision: 7, createdAt: '2026-09-19T08:00:00Z', updatedAt: '2026-09-21T08:00:00Z', + deletedAt: null, + versions: [], +}; + +const deletedGame: AdminGameDistributionGameEntry = { + gameId: 'game_3', + title: '已删除作品', + author: { + id: 'user_3', + name: '作者丙', + avatarUrl: null, + }, + status: 'deleted', + versionCount: 1, + playCount: 3, + activeVersionId: null, + publicationRevision: 2, + createdAt: '2026-09-17T08:00:00Z', + updatedAt: '2026-09-22T08:00:00Z', + deletedAt: '2026-09-22T08:00:00Z', versions: [], }; @@ -263,3 +284,92 @@ test('401 交给 onUnauthorized 处理', async () => { ); expect(screen.queryByRole('alert')).toBeNull(); }); + +test('筛选条件按服务端口径透传,重置回到无条件第一页', async () => { + render( + , + ); + + await screen.findByText('测试游戏'); + fireEvent.change(screen.getByPlaceholderText('标题、gameId 或作者 ID'), { + target: { value: ' 拼图 ' }, + }); + fireEvent.change(screen.getByPlaceholderText('作者 user ID'), { + target: { value: 'user_9' }, + }); + fireEvent.change(screen.getByRole('combobox', { name: '状态' }), { + target: { value: 'suspended' }, + }); + fireEvent.click(screen.getByRole('button', { name: '查询' })); + + await waitFor(() => + expect(listAdminGameDistributionGames).toHaveBeenCalledTimes(2), + ); + const [, applied] = vi.mocked(listAdminGameDistributionGames).mock.calls[1]!; + expect(applied).toEqual({ + limit: 50, + cursor: null, + keyword: '拼图', + owner: 'user_9', + status: 'suspended', + }); + + fireEvent.click(screen.getByRole('button', { name: '重置' })); + await waitFor(() => + expect(listAdminGameDistributionGames).toHaveBeenCalledTimes(3), + ); + const [, reset] = vi.mocked(listAdminGameDistributionGames).mock.calls[2]!; + expect(reset).toEqual({ + limit: 50, + cursor: null, + keyword: '', + owner: '', + status: '', + }); + expect( + (screen.getByPlaceholderText('标题、gameId 或作者 ID') as HTMLInputElement) + .value, + ).toBe(''); +}); + +test('游标分页把上一页返回的 nextCursor 传给下一页', async () => { + vi.mocked(listAdminGameDistributionGames) + .mockResolvedValueOnce({ games: [publishedGame], nextCursor: 'cursor-1' }) + .mockResolvedValueOnce({ games: [suspendedGame], nextCursor: null }); + + render( + , + ); + + await screen.findByText('测试游戏'); + const nextButton = screen.getByRole('button', { name: '下一页' }); + expect((nextButton as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(nextButton); + + await screen.findByText('下架游戏'); + expect(screen.queryByText('测试游戏')).toBeNull(); + const [, secondPage] = vi.mocked(listAdminGameDistributionGames).mock + .calls[1]!; + expect(secondPage?.cursor).toBe('cursor-1'); + expect( + (screen.getByRole('button', { name: '下一页' }) as HTMLButtonElement) + .disabled, + ).toBe(true); +}); + +test('已删除作品显示删除时间且不再提供下架与恢复', async () => { + vi.mocked(listAdminGameDistributionGames).mockResolvedValue({ + games: [deletedGame], + }); + + render( + , + ); + + const row = (await screen.findByText('已删除作品')).closest('tr')!; + expect(within(row).getByText('已删除')).toBeTruthy(); + expect(within(row).getByText(/删除于/)).toBeTruthy(); + expect(within(row).queryByRole('button', { name: '下架' })).toBeNull(); + expect(within(row).queryByRole('button', { name: '恢复' })).toBeNull(); + expect(within(row).getByRole('button', { name: '版本历史' })).toBeTruthy(); +}); diff --git a/apps/admin-web/src/pages/AdminGameManagementPage.tsx b/apps/admin-web/src/pages/AdminGameManagementPage.tsx index 074a92927..043f6c3d8 100644 --- a/apps/admin-web/src/pages/AdminGameManagementPage.tsx +++ b/apps/admin-web/src/pages/AdminGameManagementPage.tsx @@ -4,15 +4,17 @@ import { AdminAlert, AdminButton, AdminDialog, + AdminField, AdminFieldGroup, AdminListPanel, AdminPage, AdminPageHeading, + AdminPagination, AdminStatusPill, formatAdminDateTime, } from '@genarrative/shared/components'; -import { RefreshCcw } from 'lucide-react'; -import { useCallback, useEffect, useState } from 'react'; +import { RefreshCcw, Search, X } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { listAdminGameDistributionGames, @@ -21,6 +23,7 @@ import { } from '../api/adminApiClient'; import type { AdminGameDistributionGameEntry, + AdminGameDistributionGameStatus, AdminGameDistributionGameVersionEntry, } from '../api/adminApiTypes'; import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; @@ -38,8 +41,32 @@ const GAME_STATUS_META: Record< published: { label: '已公开', tone: 'ok' }, suspended: { label: '已下架', tone: 'error' }, unpublished: { label: '未公开', tone: 'pending' }, + // 作者软删除:保留在后台可见(默认视图之外),但不再公开可玩。 + deleted: { label: '已删除', tone: 'error' }, }; +const STATUS_FILTER_OPTIONS: { + value: AdminGameDistributionGameStatus | ''; + label: string; +}[] = [ + { value: '', label: '全部状态' }, + { value: 'published', label: '已公开' }, + { value: 'unpublished', label: '未公开' }, + { value: 'suspended', label: '已下架' }, + { value: 'deleted', label: '已删除' }, +]; + +const DEFAULT_PAGE_SIZE = 50; +const PAGE_SIZE_OPTIONS = [20, 50, 100]; + +interface GameFilters { + keyword: string; + owner: string; + status: AdminGameDistributionGameStatus | ''; +} + +const EMPTY_FILTERS: GameFilters = { keyword: '', owner: '', status: '' }; + function gameStatusMeta(status: string) { return ( GAME_STATUS_META[status] ?? { @@ -89,7 +116,13 @@ export function AdminGameManagementPage({ onUnauthorized, }: AdminGameManagementPageProps) { const [games, setGames] = useState([]); + const [nextCursor, setNextCursor] = useState(null); + const [pageIndex, setPageIndex] = useState(1); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + const [draftFilters, setDraftFilters] = useState(EMPTY_FILTERS); + const [filters, setFilters] = useState(EMPTY_FILTERS); const [isLoading, setIsLoading] = useState(false); + const [hasLoaded, setHasLoaded] = useState(false); const [busyGameId, setBusyGameId] = useState(''); const [errorMessage, setErrorMessage] = useState(''); const [statusMessage, setStatusMessage] = useState(''); @@ -99,26 +132,109 @@ export function AdminGameManagementPage({ const [versionGame, setVersionGame] = useState(null); const writeConfirm = useAdminWriteConfirm(); + const listController = useRef(null); + // 远端按游标分页且不给总数:第 N 页的起始游标只能由前 N-1 页依次返回, + // 因此按页记录已取得的游标,翻页只在这些游标之间移动。 + const pageCursors = useRef<(string | null)[]>([null]); - const loadGames = useCallback(async () => { - setIsLoading(true); - setErrorMessage(''); - try { - const response = await listAdminGameDistributionGames(token, { - limit: 50, - }); - setGames(response.games); - } catch (error) { - handlePageError(error, onUnauthorized, setErrorMessage); - } finally { - setIsLoading(false); - } - }, [token, onUnauthorized]); + const loadGames = useCallback( + async (cursor: string | null, page: number) => { + listController.current?.abort(); + const controller = new AbortController(); + listController.current = controller; + setIsLoading(true); + setErrorMessage(''); + try { + const response = await listAdminGameDistributionGames( + token, + { + limit: pageSize, + cursor, + keyword: filters.keyword, + owner: filters.owner, + status: filters.status, + }, + controller.signal, + ); + if (controller.signal.aborted) return; + setGames(response.games); + setNextCursor(response.nextCursor ?? null); + setPageIndex(page); + setHasLoaded(true); + } catch (error: unknown) { + // 翻页或刷新失败时保留当前页,不把已看到的列表换成空表。 + if (!controller.signal.aborted) + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + if (listController.current === controller) { + listController.current = null; + setIsLoading(false); + } + } + }, + [ + token, + onUnauthorized, + pageSize, + filters.keyword, + filters.owner, + filters.status, + ], + ); + // 条件或每页条数变化时回到第一页;这里是唯一的"重置游标"入口。 useEffect(() => { - void loadGames(); + pageCursors.current = [null]; + setPageIndex(1); + void loadGames(null, 1); }, [loadGames]); + useEffect( + () => () => { + listController.current?.abort(); + }, + [], + ); + + function applyFilters() { + setFilters({ + keyword: draftFilters.keyword.trim(), + owner: draftFilters.owner.trim(), + status: draftFilters.status, + }); + } + + function resetFilters() { + setDraftFilters(EMPTY_FILTERS); + setFilters(EMPTY_FILTERS); + } + + function refreshCurrentPage() { + void loadGames(pageCursors.current[pageIndex - 1] ?? null, pageIndex); + } + + function goToNextPage() { + if (!nextCursor) return; + const nextPage = pageIndex + 1; + pageCursors.current[nextPage - 1] = nextCursor; + void loadGames(nextCursor, nextPage); + } + + function goToPreviousPage() { + if (pageIndex <= 1) return; + const previousPage = pageIndex - 1; + void loadGames(pageCursors.current[previousPage - 1] ?? null, previousPage); + } + + function changePageSize(size: number) { + pageCursors.current = [null]; + setPageSize(size); + } + + const filtersActive = Boolean( + filters.keyword || filters.owner || filters.status, + ); + async function suspendGame(entry: AdminGameDistributionGameEntry) { const reason = (suspendReasonByGame[entry.gameId] ?? '').trim(); if (!reason) { @@ -151,7 +267,7 @@ export function AdminGameManagementPage({ ...current, [entry.gameId]: '', })); - await loadGames(); + refreshCurrentPage(); } catch (error) { handlePageError(error, onUnauthorized, setErrorMessage); } finally { @@ -177,7 +293,7 @@ export function AdminGameManagementPage({ { expectedPublicationRevision: entry.publicationRevision }, ); setStatusMessage(`游戏《${entry.title || entry.gameId}》已恢复`); - await loadGames(); + refreshCurrentPage(); } catch (error) { handlePageError(error, onUnauthorized, setErrorMessage); } finally { @@ -193,7 +309,7 @@ export function AdminGameManagementPage({ void loadGames()} + onClick={refreshCurrentPage} disabled={isLoading} >