diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index f03280a2b..b3e502b37 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -34,11 +34,13 @@ import type { AdminFeatureGateConfigResponse, AdminGameDistributionGameListResponse, AdminGameDistributionGameStatus, + AdminGameDistributionPreviewSessionResponse, AdminGameDistributionRestoreRequest, AdminGameDistributionRestoreResponse, AdminGameDistributionReviewListResponse, AdminGameDistributionReviewRequest, AdminGameDistributionReviewResponse, + AdminGameDistributionVersionDetailResponse, AdminGameReviewDetailResponse, AdminGameReviewGamesQuery, AdminGameReviewGamesResponse, @@ -1251,6 +1253,32 @@ 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(), + })); +} + /** * 后台作品管理列表。 * diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 32dcefd63..d3718adf8 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -1110,6 +1110,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; diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx index 8291e815e..013603ed9 100644 --- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx @@ -10,11 +10,16 @@ import { import { beforeEach, expect, test, vi } from 'vitest'; import { + createAdminGameDistributionPreviewSession, + getAdminGameDistributionVersion, listAdminGameDistributionReviews, reviewAdminGameDistributionVersion, suspendAdminGameDistributionGame, } from '../api/adminApiClient'; -import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes'; +import type { + AdminGameDistributionReviewEntry, + AdminGameDistributionVersionDetailResponse, +} from '../api/adminApiTypes'; import { AdminGameDistributionReviewPage } from './AdminGameDistributionReviewPage'; vi.mock('../api/adminApiClient', () => ({ @@ -28,11 +33,13 @@ vi.mock('../api/adminApiClient', () => ({ formatAdminApiError: vi.fn((error: unknown) => error instanceof Error ? error.message : '请求失败', ), + createAdminGameDistributionPreviewSession: vi.fn(), + getAdminGameDistributionVersion: vi.fn(), listAdminGameDistributionReviews: vi.fn(), reviewAdminGameDistributionVersion: vi.fn(), suspendAdminGameDistributionGame: vi.fn(), -})); +})); const entry: AdminGameDistributionReviewEntry = { versionId: 'version-1', gameId: 'game_1', @@ -46,10 +53,40 @@ const entry: AdminGameDistributionReviewEntry = { updatedAt: '2026-09-20T08:00:00Z', }; +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: 4, + 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(reviewAdminGameDistributionVersion).mockReset(); vi.mocked(suspendAdminGameDistributionGame).mockReset(); + vi.mocked(getAdminGameDistributionVersion).mockReset(); + vi.mocked(createAdminGameDistributionPreviewSession).mockReset(); vi.mocked(listAdminGameDistributionReviews).mockResolvedValue({ entries: [entry], nextCursor: null, @@ -230,3 +267,30 @@ test('取消安全下架确认时不下架', async () => { await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled(); }); + +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('game_1'); + + 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'); +}); diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx index 5325772f8..42911fe55 100644 --- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx @@ -5,6 +5,7 @@ import { AdminListPanel, AdminPage, AdminPageHeading, + GameDetailDisplay, formatAdminDateTime, Modal, TextField, @@ -13,11 +14,18 @@ import { RefreshCcw } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; import { + createAdminGameDistributionPreviewSession, + getAdminAssetReadUrl, + getAdminGameDistributionVersion, listAdminGameDistributionReviews, reviewAdminGameDistributionVersion, suspendAdminGameDistributionGame, } from '../api/adminApiClient'; -import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes'; +import type { + AdminGameDistributionGameDetail, + AdminGameDistributionReviewEntry, + AdminGameDistributionVersionDetailResponse, +} from '../api/adminApiTypes'; import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; import { handlePageError } from './pageUtils'; @@ -31,6 +39,48 @@ interface ReviewReasonPrompt { entry: AdminGameDistributionReviewEntry; } +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`; @@ -75,6 +125,16 @@ export function AdminGameDistributionReviewPage({ 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 loadReviews = useCallback(async () => { setIsLoading(true); @@ -93,6 +153,77 @@ export function AdminGameDistributionReviewPage({ 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', @@ -204,6 +335,30 @@ export function AdminGameDistributionReviewPage({ void suspendGame(entry, reason); } + if (selectedEntry) { + return ( + void startPreview()} + onReject={() => { + closeDetails(); + openReasonPrompt(selectedEntry, 'reject'); + }} + onApprove={() => { + closeDetails(); + void submitReview(selectedEntry, 'approve'); + }} + /> + ); + } + return ( {formatAdminDateTime(entry.createdAt)} - + + void openDetails(entry)} + > + 详情 + ); } + +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 ? ( +