diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index 5bde92df5..d56eba0f4 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -20,6 +20,9 @@ env: CARGO_HTTP_MULTIPLEXING: 'false' CARGO_NET_RETRY: '10' CARGO_TERM_COLOR: always + # 等价于给每条 `cargo test` 加 `-q`:通过的用例只打点号,失败的才按名字列出。 + # 对 npm 脚本、shard runner 里嵌套的 cargo 同样生效,避免逐条 `test ... ok` 刷屏。 + CARGO_TERM_QUIET: 'true' NPM_CONFIG_AUDIT: 'false' NPM_CONFIG_FETCH_RETRIES: '10' NPM_CONFIG_FETCH_RETRY_FACTOR: '2' diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index 8c6d85581..ec4781c68 100644 --- a/apps/admin-web/src/api/adminApiClient.test.ts +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -618,7 +618,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({ @@ -656,6 +656,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 e068b3adb..9c3470426 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -33,11 +33,14 @@ import type { AdminExternalApiKeyListResponse, AdminFeatureGateConfigResponse, AdminGameDistributionGameListResponse, + AdminGameDistributionGameStatus, + AdminGameDistributionPreviewSessionResponse, AdminGameDistributionRestoreRequest, AdminGameDistributionRestoreResponse, AdminGameDistributionReviewListResponse, AdminGameDistributionReviewRequest, AdminGameDistributionReviewResponse, + AdminGameDistributionVersionDetailResponse, AdminGameReviewDetailResponse, AdminGameReviewGamesQuery, AdminGameReviewGamesResponse, @@ -1257,6 +1260,39 @@ export function listAdminGameDistributionReviews(token: string, limit = 48) { ); } +export function getAdminGameDistributionVersion( + token: string, + versionId: string, +) { + return request( + `/admin/api/game-distribution/versions/${encodeURIComponent(versionId)}`, + { token }, + ); +} + +export function createAdminGameDistributionPreviewSession( + token: string, + versionId: string, +) { + return request( + `/admin/api/game-distribution/versions/${encodeURIComponent(versionId)}/preview-session`, + { token, method: 'POST' }, + ).then((response) => ({ + ...response, + previewUrl: new URL( + response.previewUrl, + ADMIN_API_BASE_URL || window.location.origin, + ).toString(), + })); +} + +/** + * 后台作品管理列表。 + * + * 关键词、作者与状态过滤以及游标分页都由服务端执行:前端只透传查询参数, + * 不本地过滤,否则分页边界会与 `nextCursor` 的语义打架。 + */ + function gameReviewQuery( query: AdminGameReviewGamesQuery | AdminGameReviewsQuery, ) { @@ -1311,18 +1347,30 @@ export function moderateAdminGameReview( }, ); } - 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 1e540beae..8fecfe485 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -1143,6 +1143,52 @@ export interface AdminGameDistributionReviewEntry { updatedAt: string; } +export interface AdminGameDistributionGameDetail { + id: string; + title: string; + summary: string; + description: string; + category: string; + tags: string[]; + coverObjectKey: string | null; + screenshots: string[]; + author: { + id: string; + name: string; + avatarUrl: string | null; + }; + deviceSupport: { + desktop: boolean; + mobile: boolean; + touch: boolean; + }; + inputModes: string[]; + orientation: string; + status: string; + publicationRevision: number; + playCount: number; + createdAt: string; +} + +export interface AdminGameDistributionVersionDetail + extends AdminGameDistributionReviewEntry { + packageFileCount: number; + entryUrl: string | null; + recoveryAction: string; + frozenMetadata: Record | null; +} + +export interface AdminGameDistributionVersionDetailResponse { + game: AdminGameDistributionGameDetail; + version: AdminGameDistributionVersionDetail; +} + +export interface AdminGameDistributionPreviewSessionResponse { + previewUrl: string; + expiresAt: string; + versionId: string; +} + export interface AdminGameDistributionReviewListResponse { entries: AdminGameDistributionReviewEntry[]; nextCursor: string | null; @@ -1197,18 +1243,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/AdminGameDistributionReviewPage.test.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx index 8291e815e..3ca7ab6be 100644 --- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx @@ -10,11 +10,17 @@ import { import { beforeEach, expect, test, vi } from 'vitest'; import { + createAdminGameDistributionPreviewSession, + getAdminGameDistributionVersion, + listAdminGameDistributionGames, listAdminGameDistributionReviews, reviewAdminGameDistributionVersion, - suspendAdminGameDistributionGame, } from '../api/adminApiClient'; -import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes'; +import type { + AdminGameDistributionGameListResponse, + AdminGameDistributionReviewEntry, + AdminGameDistributionVersionDetailResponse, +} from '../api/adminApiTypes'; import { AdminGameDistributionReviewPage } from './AdminGameDistributionReviewPage'; vi.mock('../api/adminApiClient', () => ({ @@ -28,11 +34,12 @@ vi.mock('../api/adminApiClient', () => ({ formatAdminApiError: vi.fn((error: unknown) => error instanceof Error ? error.message : '请求失败', ), + createAdminGameDistributionPreviewSession: vi.fn(), + getAdminGameDistributionVersion: vi.fn(), + listAdminGameDistributionGames: vi.fn(), listAdminGameDistributionReviews: vi.fn(), reviewAdminGameDistributionVersion: vi.fn(), - suspendAdminGameDistributionGame: vi.fn(), })); - const entry: AdminGameDistributionReviewEntry = { versionId: 'version-1', gameId: 'game_1', @@ -42,18 +49,72 @@ const entry: AdminGameDistributionReviewEntry = { status: 'pending_review', publicationRevision: 4, reviewReason: null, - createdAt: '2026-09-20T08:00:00Z', - updatedAt: '2026-09-20T08:00:00Z', + // 真实后台下发的是 `seconds.microsZ`(非 ISO 8601),见 shared-kernel `format_timestamp_micros`。 + createdAt: '1791008678.817257Z', + updatedAt: '1791008678.817257Z', +}; + +/** 游戏名与作者由既有后台作品列表接口按 gameId 反查(待审列表本身不含这两个字段)。 */ +const gameTitle = '测试游戏名'; +const gameListResponse: AdminGameDistributionGameListResponse = { + games: [ + { + gameId: 'game_1', + title: gameTitle, + author: { id: 'user-9f1', name: '136****8867', avatarUrl: null }, + status: 'unpublished', + versionCount: 1, + playCount: 0, + activeVersionId: null, + publicationRevision: 4, + createdAt: '2026-09-20T08:00:00Z', + updatedAt: '2026-09-20T08:00:00Z', + deletedAt: null, + versions: [], + }, + ], + nextCursor: null, +}; + +const detailResponse: AdminGameDistributionVersionDetailResponse = { + game: { + id: 'game_1', + title: '测试游戏', + summary: '测试简介', + description: '测试详细介绍', + category: '益智', + tags: ['测试'], + coverObjectKey: null, + screenshots: [], + author: { id: 'user-1', name: '测试作者', avatarUrl: null }, + deviceSupport: { desktop: true, mobile: false, touch: true }, + inputModes: ['keyboard'], + orientation: 'landscape', + status: 'unpublished', + publicationRevision: 7, + playCount: 0, + createdAt: '2026-09-20T08:00:00Z', + }, + version: { + ...entry, + packageFileCount: 3, + entryUrl: null, + recoveryAction: 'wait', + frozenMetadata: { title: '测试游戏' }, + }, }; beforeEach(() => { vi.mocked(listAdminGameDistributionReviews).mockReset(); + vi.mocked(listAdminGameDistributionGames).mockReset(); vi.mocked(reviewAdminGameDistributionVersion).mockReset(); - vi.mocked(suspendAdminGameDistributionGame).mockReset(); + vi.mocked(getAdminGameDistributionVersion).mockReset(); + vi.mocked(createAdminGameDistributionPreviewSession).mockReset(); vi.mocked(listAdminGameDistributionReviews).mockResolvedValue({ entries: [entry], nextCursor: null, }); + vi.mocked(listAdminGameDistributionGames).mockResolvedValue(gameListResponse); }); test('通过审核只提交当前 publicationRevision 并刷新列表', async () => { @@ -68,7 +129,7 @@ test('通过审核只提交当前 publicationRevision 并刷新列表', async () onUnauthorized={vi.fn()} />, ); - await screen.findByText('game_1'); + await screen.findByText(gameTitle); expect(screen.queryByLabelText('发行入口')).toBeNull(); expect(screen.queryByText('通过后由系统分配发行地址')).toBeNull(); @@ -107,7 +168,7 @@ test('点击拒绝后填写理由再提交审核接口', async () => { onUnauthorized={vi.fn()} />, ); - await screen.findByText('game_1'); + await screen.findByText(gameTitle); fireEvent.click(screen.getByRole('button', { name: '拒绝' })); const dialog = await screen.findByRole('dialog'); @@ -135,54 +196,44 @@ test('点击拒绝后填写理由再提交审核接口', async () => { }); }); -test('安全下架需要先填写原因,再二次确认并携带公开修订号', async () => { - vi.mocked(suspendAdminGameDistributionGame).mockResolvedValue({ - game: { - id: 'game_1', - title: '测试游戏', - status: 'suspended', - publicationRevision: 5, - }, - replayed: false, - }); - +test('待审版本行内只保留版本级审核动作,不提供游戏级安全下架', async () => { render( , ); - await screen.findByText('game_1'); + await screen.findByText(gameTitle); - fireEvent.click(screen.getByRole('button', { name: '安全下架' })); - const reasonDialog = await screen.findByRole('dialog'); - fireEvent.change( - within(reasonDialog).getByRole('textbox', { name: '下架原因' }), - { - target: { value: '盗用素材' }, - }, - ); - fireEvent.click( - within(reasonDialog).getByRole('button', { name: '继续下架' }), - ); + // 「安全下架」是游戏级动作(强制下线线上版本),入口在「游戏管理」页; + // 审核一个新版本时不该出现该动作。 + expect(screen.queryByRole('button', { name: '安全下架' })).toBeNull(); + expect(screen.queryByText('继续下架')).toBeNull(); + expect(screen.queryByLabelText('下架原因')).toBeNull(); - await screen.findByText('确认操作'); - const confirmDialog = screen.getByRole('dialog'); - fireEvent.click(within(confirmDialog).getByRole('button', { name: '确认' })); + fireEvent.click(screen.getByRole('button', { name: '拒绝' })); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).queryByRole('button', { name: '继续下架' })).toBeNull(); + expect(within(dialog).getByLabelText('拒绝理由')).toBeTruthy(); +}); - await waitFor(() => - expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1), +test('提交时间列把后端的 seconds.microsZ 渲染成可读本地时间', async () => { + render( + , + ); + await screen.findByText(gameTitle); + + const row = screen.getByText(gameTitle).closest('tr'); + expect(row).not.toBeNull(); + const submittedCell = within(row as HTMLElement).getAllByRole('cell')[3]; + // 解析不出来时会回落到 `-`,这正是本次修复前的线上表现。 + expect(submittedCell?.textContent).not.toBe('-'); + expect(submittedCell?.textContent).toMatch( + /^\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}$/u, ); - const [token, gameId, idempotencyKey, payload] = - vi.mocked(suspendAdminGameDistributionGame).mock.calls[0] ?? []; - expect(token).toBe('admin-token'); - expect(gameId).toBe('game_1'); - expect(String(idempotencyKey)).toContain('game_1'); - expect(payload).toEqual({ - expectedPublicationRevision: 4, - reason: '盗用素材', - }); - expect(await screen.findByText(/已安全下架/u)).toBeTruthy(); }); test('取消理由输入时不做审核操作', async () => { @@ -192,7 +243,7 @@ test('取消理由输入时不做审核操作', async () => { onUnauthorized={vi.fn()} />, ); - await screen.findByText('game_1'); + await screen.findByText(gameTitle); fireEvent.click(screen.getByRole('button', { name: '拒绝' })); const dialog = await screen.findByRole('dialog'); @@ -202,31 +253,67 @@ test('取消理由输入时不做审核操作', async () => { expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled(); }); -test('取消安全下架确认时不下架', async () => { +test('游戏列显示游戏名与作者,不再把 gameId 当主展示', async () => { render( , ); - await screen.findByText('game_1'); + await screen.findByText(gameTitle); - fireEvent.click(screen.getByRole('button', { name: '安全下架' })); - const reasonDialog = await screen.findByRole('dialog'); - fireEvent.change( - within(reasonDialog).getByRole('textbox', { name: '下架原因' }), - { - target: { value: '盗用素材' }, - }, - ); - fireEvent.click( - within(reasonDialog).getByRole('button', { name: '继续下架' }), + // 按 gameId 反查一次,第二次刷新走缓存(本次只加载一次列表)。 + expect(listAdminGameDistributionGames).toHaveBeenCalledWith( + 'admin-token', + expect.objectContaining({ keyword: 'game_1' }), ); - await screen.findByText('确认操作'); - const confirmDialog = screen.getByRole('dialog'); - fireEvent.click(within(confirmDialog).getByRole('button', { name: '取消' })); - - await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); - expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled(); + const row = screen.getByText(gameTitle).closest('tr'); + expect(row).not.toBeNull(); + const gameCell = within(row as HTMLElement).getAllByRole('cell')[0]; + // 主展示是游戏名 + 打码作者名;gameId 只留在悬浮标题里。 + expect(gameCell?.textContent).toContain('136****8867'); + expect(gameCell?.textContent).not.toContain('game_1'); + expect(screen.getByTitle('user-9f1').textContent).toBe('user-9f1'); + expect(gameCell?.querySelector('[title="game_1"]')).not.toBeNull(); +}); + +test('详情展示发布者和冻结资料,并试玩当前待审版本', async () => { + vi.mocked(getAdminGameDistributionVersion).mockResolvedValue(detailResponse); + vi.mocked(createAdminGameDistributionPreviewSession).mockResolvedValue({ + previewUrl: + 'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-1/', + expiresAt: '2026-10-02T12:10:00Z', + versionId: 'version-1', + }); + + render( + , + ); + await screen.findByText(gameTitle); + + fireEvent.click(screen.getByRole('button', { name: '详情' })); + expect((await screen.findAllByText(/测试作者/u)).length).toBeGreaterThan(0); + expect(screen.getByText('测试详细介绍')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '试玩当前待审版本' })); + const frame = await screen.findByTitle('测试游戏 待审版本试玩'); + expect(frame.getAttribute('src')).toBe( + 'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-1/', + ); + expect(frame.getAttribute('sandbox')).toBe('allow-scripts'); + fireEvent.click(screen.getByRole('button', { name: '通过' })); + await waitFor(() => + expect(reviewAdminGameDistributionVersion).toHaveBeenCalledWith( + 'admin-token', + 'version-1', + expect.any(String), + expect.objectContaining({ + decision: 'approve', + expectedPublicationRevision: 7, + }), + ), + ); }); diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx index 5325772f8..f63a9c79e 100644 --- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx @@ -1,3 +1,5 @@ +import './adminGameDistributionReview.css'; + import { AdminActionRow, AdminAlert, @@ -6,19 +8,26 @@ import { AdminPage, AdminPageHeading, formatAdminDateTime, + GameDetailDisplay, Modal, TextField, } from '@genarrative/shared/components'; import { RefreshCcw } from 'lucide-react'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { + createAdminGameDistributionPreviewSession, + getAdminAssetReadUrl, + getAdminGameDistributionVersion, + listAdminGameDistributionGames, listAdminGameDistributionReviews, reviewAdminGameDistributionVersion, - suspendAdminGameDistributionGame, } from '../api/adminApiClient'; -import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes'; -import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; +import type { + AdminGameDistributionGameDetail, + AdminGameDistributionReviewEntry, + AdminGameDistributionVersionDetailResponse, +} from '../api/adminApiTypes'; import { handlePageError } from './pageUtils'; interface AdminGameDistributionReviewPageProps { @@ -27,10 +36,71 @@ interface AdminGameDistributionReviewPageProps { } interface ReviewReasonPrompt { - decision: 'reject' | 'suspend'; entry: AdminGameDistributionReviewEntry; } +/** 待审列表要展示的游戏名与作者:由既有后台作品列表接口按 gameId 反查得到。 */ +interface ReviewGameMeta { + title: string; + authorName: string; + authorId: string; +} + +function applyFrozenGameMetadata( + game: AdminGameDistributionGameDetail, + metadata: Record | null, +): AdminGameDistributionGameDetail { + if (!metadata) return game; + const screenshots = Array.isArray(metadata.screenshots) + ? metadata.screenshots + .map((item) => + item && typeof item === 'object' && 'objectKey' in item + ? item.objectKey + : null, + ) + .filter( + (value): value is string => + typeof value === 'string' && Boolean(value.trim()), + ) + : game.screenshots; + const deviceSupport = + metadata.deviceSupport && typeof metadata.deviceSupport === 'object' + ? { ...game.deviceSupport, ...metadata.deviceSupport } + : game.deviceSupport; + return { + ...game, + title: typeof metadata.title === 'string' ? metadata.title : game.title, + summary: + typeof metadata.summary === 'string' ? metadata.summary : game.summary, + description: + typeof metadata.description === 'string' + ? metadata.description + : game.description, + category: + typeof metadata.category === 'string' ? metadata.category : game.category, + tags: Array.isArray(metadata.tags) + ? metadata.tags.filter( + (value): value is string => typeof value === 'string', + ) + : game.tags, + coverObjectKey: + typeof metadata.coverObjectKey === 'string' + ? metadata.coverObjectKey + : game.coverObjectKey, + screenshots, + deviceSupport, + inputModes: Array.isArray(metadata.inputModes) + ? metadata.inputModes.filter( + (value): value is string => typeof value === 'string', + ) + : game.inputModes, + orientation: + typeof metadata.orientation === 'string' + ? metadata.orientation + : game.orientation, + }; +} + function formatBytes(value: number) { if (value >= 1024 * 1024) { return `${(value / (1024 * 1024)).toFixed(1)} MiB`; @@ -41,14 +111,6 @@ function formatBytes(value: number) { return `${value} B`; } -function createSuspendIdempotencyKey(gameId: string) { - const random = - typeof crypto !== 'undefined' && 'randomUUID' in crypto - ? crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(16).slice(2)}`; - return `game-suspend-${gameId}-${random}`.slice(0, 128); -} - function createReviewIdempotencyKey(versionId: string) { const random = typeof crypto !== 'undefined' && 'randomUUID' in crypto @@ -73,8 +135,66 @@ export function AdminGameDistributionReviewPage({ ); const [reasonDraft, setReasonDraft] = useState(''); const [reasonError, setReasonError] = useState(''); - const [busyGameId, setBusyGameId] = useState(''); - const writeConfirm = useAdminWriteConfirm(); + const [selectedEntry, setSelectedEntry] = + useState(null); + const [detail, setDetail] = + useState(null); + const [isDetailLoading, setIsDetailLoading] = useState(false); + const [detailError, setDetailError] = useState(''); + const [previewUrl, setPreviewUrl] = useState(''); + const [previewError, setPreviewError] = useState(''); + const [isPreviewLoading, setIsPreviewLoading] = useState(false); + const [assetUrls, setAssetUrls] = useState>({}); + const [gameMetaByGameId, setGameMetaByGameId] = useState< + Record + >({}); + // 同一 gameId 的标题/作者在会话内不变,缓存避免每次刷新重复反查。 + const gameMetaCacheRef = useRef>({}); + + /** + * 待审列表只回版本字段(无游戏名/作者),这里用既有后台作品列表接口按 gameId 反查, + * 逐游戏一次请求并去重;单个游戏取不到只回落显示 gameId,不阻断审核列表。 + */ + const resolveGameMeta = useCallback( + async (gameIds: string[]) => { + const resolved = await Promise.all( + gameIds.map(async (gameId) => { + const cached = gameMetaCacheRef.current[gameId]; + if (cached) { + return [gameId, cached] as const; + } + try { + const response = await listAdminGameDistributionGames(token, { + limit: 5, + keyword: gameId, + }); + const matched = response.games.find( + (game) => game.gameId === gameId, + ); + if (!matched) return null; + const meta: ReviewGameMeta = { + title: matched.title?.trim() || matched.gameId, + authorName: matched.author?.name?.trim() || '', + authorId: matched.author?.id?.trim() || '', + }; + gameMetaCacheRef.current[gameId] = meta; + return [gameId, meta] as const; + } catch { + return null; + } + }), + ); + const merged = Object.fromEntries( + resolved.filter( + (item): item is readonly [string, ReviewGameMeta] => item !== null, + ), + ); + if (Object.keys(merged).length > 0) { + setGameMetaByGameId((current) => ({ ...current, ...merged })); + } + }, + [token], + ); const loadReviews = useCallback(async () => { setIsLoading(true); @@ -82,17 +202,92 @@ export function AdminGameDistributionReviewPage({ try { const response = await listAdminGameDistributionReviews(token); setEntries(response.entries); + await resolveGameMeta([ + ...new Set(response.entries.map((entry) => entry.gameId)), + ]); } catch (error) { handlePageError(error, onUnauthorized, setErrorMessage); } finally { setIsLoading(false); } - }, [token, onUnauthorized]); + }, [token, onUnauthorized, resolveGameMeta]); useEffect(() => { void loadReviews(); }, [loadReviews]); + async function openDetails(entry: AdminGameDistributionReviewEntry) { + setSelectedEntry(entry); + setDetail(null); + setDetailError(''); + setPreviewUrl(''); + setPreviewError(''); + setAssetUrls({}); + setIsDetailLoading(true); + try { + const response = await getAdminGameDistributionVersion( + token, + entry.versionId, + ); + const reviewGame = applyFrozenGameMetadata( + response.game, + response.version.frozenMetadata, + ); + const reviewDetail = { ...response, game: reviewGame }; + setDetail(reviewDetail); + const objectKeys = [ + reviewGame.coverObjectKey, + ...reviewGame.screenshots, + ].filter((value): value is string => Boolean(value?.trim())); + const resolvedUrls = await Promise.all( + objectKeys.map(async (objectKey) => { + try { + const asset = await getAdminAssetReadUrl(token, { + objectKey, + expireSeconds: 300, + }); + return [ + objectKey, + asset.read?.signedUrl ?? asset.signedUrl ?? '', + ] as const; + } catch { + return [objectKey, ''] as const; + } + }), + ); + setAssetUrls(Object.fromEntries(resolvedUrls)); + } catch (error) { + handlePageError(error, onUnauthorized, setDetailError); + } finally { + setIsDetailLoading(false); + } + } + + function closeDetails() { + setSelectedEntry(null); + setDetail(null); + setPreviewUrl(''); + setPreviewError(''); + setAssetUrls({}); + } + + async function startPreview() { + if (!selectedEntry) return; + setIsPreviewLoading(true); + setPreviewError(''); + try { + const response = await createAdminGameDistributionPreviewSession( + token, + selectedEntry.versionId, + ); + setPreviewUrl(response.previewUrl); + } catch (error) { + handlePageError(error, onUnauthorized, setPreviewError); + } finally { + setIsPreviewLoading(false); + } + } + async function submitReview( entry: AdminGameDistributionReviewEntry, decision: 'approve' | 'reject', @@ -136,48 +331,13 @@ export function AdminGameDistributionReviewPage({ } /** - * 管理员安全下架:先二次确认,再带当前公开修订号调用后端;并发审核导致修订号变化时 - * 由服务端返回冲突,前端只提示刷新,不静默重试。 + * 待审版本行内只做版本级动作(详情 / 通过 / 拒绝)。管理员「安全下架」是游戏级 + * 动作(强制下线线上版本),入口在后台「游戏管理」页,不在审核新版本的语境里。 */ - async function suspendGame( - entry: AdminGameDistributionReviewEntry, - reason: string, - ) { - const trimmedReason = reason.trim(); - const confirmed = await writeConfirm.confirmWrite({ - action: '安全下架游戏', - target: `${entry.gameId}(版本 v${entry.versionNumber})`, - }); - if (!confirmed) return; - setBusyGameId(entry.gameId); - setErrorMessage(''); - setStatusMessage(''); - try { - await suspendAdminGameDistributionGame( - token, - entry.gameId, - createSuspendIdempotencyKey(entry.gameId), - { - expectedPublicationRevision: entry.publicationRevision, - ...(trimmedReason ? { reason: trimmedReason } : {}), - }, - ); - setStatusMessage(`游戏 ${entry.gameId} 已安全下架,发行入口已关闭`); - await loadReviews(); - } catch (error) { - handlePageError(error, onUnauthorized, setErrorMessage); - } finally { - setBusyGameId(''); - } - } - - function openReasonPrompt( - entry: AdminGameDistributionReviewEntry, - decision: ReviewReasonPrompt['decision'], - ) { + function openReasonPrompt(entry: AdminGameDistributionReviewEntry) { setReasonDraft(''); setReasonError(''); - setReasonPrompt({ decision, entry }); + setReasonPrompt({ entry }); } function closeReasonPrompt() { @@ -190,18 +350,50 @@ export function AdminGameDistributionReviewPage({ if (!reasonPrompt) return; const reason = reasonDraft.trim(); - if (reasonPrompt.decision === 'reject' && !reason) { + if (!reason) { setReasonError('拒绝审核必须填写理由'); return; } - const { decision, entry } = reasonPrompt; + const { entry } = reasonPrompt; closeReasonPrompt(); - if (decision === 'reject') { - void submitReview(entry, 'reject', reason); - return; - } - void suspendGame(entry, reason); + void submitReview(entry, 'reject', reason); + } + + if (selectedEntry) { + return ( + void startPreview()} + onReject={() => { + const expectedEntry = detail + ? { + ...selectedEntry, + publicationRevision: detail.game.publicationRevision, + } + : selectedEntry; + closeDetails(); + openReasonPrompt(expectedEntry); + }} + onApprove={() => { + const expectedEntry = detail + ? { + ...selectedEntry, + publicationRevision: detail.game.publicationRevision, + } + : selectedEntry; + closeDetails(); + void submitReview(expectedEntry, 'approve'); + }} + /> + ); } return ( @@ -240,6 +432,7 @@ export function AdminGameDistributionReviewPage({ extra={共 {entries.length} 条} rows={entries} size="wide" + tableClassName="admin-game-review-table" columns={[ { key: 'gameId', label: '游戏' }, { key: 'versionNumber', label: '版本' }, @@ -249,10 +442,32 @@ export function AdminGameDistributionReviewPage({ ]} renderRow={(entry) => { const busy = busyVersionId === entry.versionId; + const gameMeta = gameMetaByGameId[entry.gameId]; return ( - {entry.gameId} + {/* gameId 不再作为主展示,只留在悬浮标题里做兜底标识。 */} +
+ + {gameMeta?.title ?? entry.gameId} + + + + {gameMeta?.authorName || '—'} + + {gameMeta?.authorId ? ( + + {gameMeta.authorId} + + ) : null} + +
v{entry.versionNumber} @@ -268,8 +483,16 @@ export function AdminGameDistributionReviewPage({ {formatAdminDateTime(entry.createdAt)} - - + + + void openDetails(entry)} + > + 详情 + openReasonPrompt(entry, 'reject')} + onClick={() => openReasonPrompt(entry)} > 拒绝 - openReasonPrompt(entry, 'suspend')} - > - {busyGameId === entry.gameId ? '正在下架…' : '安全下架'} - @@ -303,7 +518,7 @@ export function AdminGameDistributionReviewPage({ {reasonPrompt ? ( - {reasonPrompt.decision === 'reject' ? '确认拒绝' : '继续下架'} + 确认拒绝 } @@ -338,7 +551,7 @@ export function AdminGameDistributionReviewPage({ ) : null} - {writeConfirm.confirmDialog} + + ); +} + +function AdminGameDistributionReviewDetailView({ + detail, + detailError, + isDetailLoading, + isPreviewLoading, + previewError, + previewUrl, + assetUrls, + onBack, + onPreview, + onReject, + onApprove, +}: { + detail: AdminGameDistributionVersionDetailResponse | null; + detailError: string; + isDetailLoading: boolean; + isPreviewLoading: boolean; + previewError: string; + previewUrl: string; + assetUrls: Record; + onBack: () => void; + onPreview: () => void; + onReject: () => void; + onApprove: () => void; +}) { + if (!detail) { + return ( + + + {isDetailLoading ? 正在加载审核详情… : null} + {detailError ? ( + {detailError} + ) : null} + + 返回审核列表 + + + ); + } + + return ( + + + 返回审核列表 + + } + /> + {detailError ? ( + {detailError} + ) : null} + {detail.version.packageSha256}, + }, + ]} + actions={ + + + {isPreviewLoading ? '准备试玩…' : '试玩当前待审版本'} + + + 拒绝 + + + 通过 + + + } + > +
+

版本审核

+

+ 发布者:{detail.game.author.name} ·{' '} + {detail.game.author.id} +

+ {detail.version.reviewReason ? ( + + 审核理由:{detail.version.reviewReason} + + ) : null} + {detail.version.frozenMetadata ? ( +
+ 查看版本冻结资料 +
+                {JSON.stringify(detail.version.frozenMetadata, null, 2)}
+              
+
+ ) : null} + {previewError ? ( + {previewError} + ) : null} + {previewUrl ? ( +