3206f8f49b
- packages/shared 新增共享游戏加载面 PlatformGameLoadingSurface:封面/标题 + 动效进度 + 按时长推进的阶段文案(准备/资源/引擎)+ 慢加载提示,附用例 - 网页游玩页用共享加载面替换近黑壳上的一行小字,加载上限改为共享常量 PLATFORM_GAME_LOADING_TIMEOUT_MS - 后台审核试玩补加载面、20 秒超时与「重新创建试玩会话」入口,新会话重置加载态;补两条用例 - 发行网关(api-server)对文本类发行资源按 Accept-Encoding 下发 gzip,并下发强 ETag、命中 If-None-Match 返回 304;补 5 条 Rust 用例 - E2E 脚本 check-game-distribution-ops-rollback-e2e 补压缩/Vary/ETag/304/不接受 gzip 四条断言 - 同步 pitfalls、开发运维文档、nginx README 与两份里程碑记录
750 lines
24 KiB
TypeScript
750 lines
24 KiB
TypeScript
import './adminGameDistributionReview.css';
|
|
|
|
import {
|
|
AdminActionRow,
|
|
AdminAlert,
|
|
AdminButton,
|
|
AdminListPanel,
|
|
AdminPage,
|
|
AdminPageHeading,
|
|
formatAdminDateTime,
|
|
GameDetailDisplay,
|
|
Modal,
|
|
PLATFORM_GAME_LOADING_TIMEOUT_MS,
|
|
PlatformGameLoadingSurface,
|
|
TextField,
|
|
} from '@genarrative/shared/components';
|
|
import { RefreshCcw } from 'lucide-react';
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
|
import {
|
|
createAdminGameDistributionPreviewSession,
|
|
getAdminAssetReadUrl,
|
|
getAdminGameDistributionVersion,
|
|
listAdminGameDistributionGames,
|
|
listAdminGameDistributionReviews,
|
|
reviewAdminGameDistributionVersion,
|
|
} from '../api/adminApiClient';
|
|
import type {
|
|
AdminGameDistributionGameDetail,
|
|
AdminGameDistributionReviewEntry,
|
|
AdminGameDistributionVersionDetailResponse,
|
|
} from '../api/adminApiTypes';
|
|
import { handlePageError } from './pageUtils';
|
|
|
|
interface AdminGameDistributionReviewPageProps {
|
|
token: string;
|
|
onUnauthorized: (message?: string) => void;
|
|
}
|
|
|
|
interface ReviewReasonPrompt {
|
|
entry: AdminGameDistributionReviewEntry;
|
|
}
|
|
|
|
/** 待审列表要展示的游戏名与作者:由既有后台作品列表接口按 gameId 反查得到。 */
|
|
interface ReviewGameMeta {
|
|
title: string;
|
|
authorName: string;
|
|
authorId: string;
|
|
}
|
|
|
|
function applyFrozenGameMetadata(
|
|
game: AdminGameDistributionGameDetail,
|
|
metadata: Record<string, unknown> | 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`;
|
|
}
|
|
if (value >= 1024) {
|
|
return `${(value / 1024).toFixed(1)} KiB`;
|
|
}
|
|
return `${value} B`;
|
|
}
|
|
|
|
function createReviewIdempotencyKey(versionId: string) {
|
|
const random =
|
|
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
|
? crypto.randomUUID()
|
|
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
return `game-review-${versionId}-${random}`.slice(0, 128);
|
|
}
|
|
|
|
export function AdminGameDistributionReviewPage({
|
|
token,
|
|
onUnauthorized,
|
|
}: AdminGameDistributionReviewPageProps) {
|
|
const [entries, setEntries] = useState<AdminGameDistributionReviewEntry[]>(
|
|
[],
|
|
);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [busyVersionId, setBusyVersionId] = useState('');
|
|
const [errorMessage, setErrorMessage] = useState('');
|
|
const [statusMessage, setStatusMessage] = useState('');
|
|
const [reasonPrompt, setReasonPrompt] = useState<ReviewReasonPrompt | null>(
|
|
null,
|
|
);
|
|
const [reasonDraft, setReasonDraft] = useState('');
|
|
const [reasonError, setReasonError] = useState('');
|
|
const [selectedEntry, setSelectedEntry] =
|
|
useState<AdminGameDistributionReviewEntry | null>(null);
|
|
const [detail, setDetail] =
|
|
useState<AdminGameDistributionVersionDetailResponse | null>(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<Record<string, string>>({});
|
|
const [gameMetaByGameId, setGameMetaByGameId] = useState<
|
|
Record<string, ReviewGameMeta>
|
|
>({});
|
|
// 同一 gameId 的标题/作者在会话内不变,缓存避免每次刷新重复反查。
|
|
const gameMetaCacheRef = useRef<Record<string, ReviewGameMeta>>({});
|
|
|
|
/**
|
|
* 待审列表只回版本字段(无游戏名/作者),这里用既有后台作品列表接口按 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);
|
|
setErrorMessage('');
|
|
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, 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',
|
|
reason = '',
|
|
) {
|
|
const trimmedReason = reason.trim();
|
|
if (decision === 'reject' && !trimmedReason) {
|
|
setErrorMessage('拒绝审核必须填写理由');
|
|
return;
|
|
}
|
|
setBusyVersionId(entry.versionId);
|
|
setErrorMessage('');
|
|
setStatusMessage('');
|
|
try {
|
|
await reviewAdminGameDistributionVersion(
|
|
token,
|
|
entry.versionId,
|
|
createReviewIdempotencyKey(entry.versionId),
|
|
decision === 'approve'
|
|
? {
|
|
decision,
|
|
expectedPublicationRevision: entry.publicationRevision,
|
|
}
|
|
: {
|
|
decision,
|
|
expectedPublicationRevision: entry.publicationRevision,
|
|
reviewReason: trimmedReason,
|
|
},
|
|
);
|
|
setStatusMessage(
|
|
decision === 'approve'
|
|
? `版本 v${entry.versionNumber} 已通过审核`
|
|
: `版本 v${entry.versionNumber} 已拒绝`,
|
|
);
|
|
await loadReviews();
|
|
} catch (error) {
|
|
handlePageError(error, onUnauthorized, setErrorMessage);
|
|
} finally {
|
|
setBusyVersionId('');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 待审版本行内只做版本级动作(详情 / 通过 / 拒绝)。管理员「安全下架」是游戏级
|
|
* 动作(强制下线线上版本),入口在后台「游戏管理」页,不在审核新版本的语境里。
|
|
*/
|
|
function openReasonPrompt(entry: AdminGameDistributionReviewEntry) {
|
|
setReasonDraft('');
|
|
setReasonError('');
|
|
setReasonPrompt({ entry });
|
|
}
|
|
|
|
function closeReasonPrompt() {
|
|
setReasonPrompt(null);
|
|
setReasonDraft('');
|
|
setReasonError('');
|
|
}
|
|
|
|
function confirmReasonPrompt() {
|
|
if (!reasonPrompt) return;
|
|
|
|
const reason = reasonDraft.trim();
|
|
if (!reason) {
|
|
setReasonError('拒绝审核必须填写理由');
|
|
return;
|
|
}
|
|
|
|
const { entry } = reasonPrompt;
|
|
closeReasonPrompt();
|
|
void submitReview(entry, 'reject', reason);
|
|
}
|
|
|
|
if (selectedEntry) {
|
|
return (
|
|
<AdminGameDistributionReviewDetailView
|
|
detail={detail}
|
|
detailError={detailError}
|
|
isDetailLoading={isDetailLoading}
|
|
isPreviewLoading={isPreviewLoading}
|
|
previewError={previewError}
|
|
previewUrl={previewUrl}
|
|
assetUrls={assetUrls}
|
|
onBack={closeDetails}
|
|
onPreview={() => 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 (
|
|
<AdminPage wide>
|
|
<AdminPageHeading
|
|
title="游戏审核"
|
|
actions={
|
|
<AdminButton
|
|
variant="secondary"
|
|
type="button"
|
|
onClick={() => void loadReviews()}
|
|
disabled={isLoading}
|
|
>
|
|
<RefreshCcw aria-hidden="true" />
|
|
刷新
|
|
</AdminButton>
|
|
}
|
|
/>
|
|
|
|
{errorMessage ? (
|
|
<AdminAlert tone="warning" role="alert">
|
|
{errorMessage}
|
|
</AdminAlert>
|
|
) : null}
|
|
{statusMessage ? (
|
|
<AdminAlert tone="success">{statusMessage}</AdminAlert>
|
|
) : null}
|
|
|
|
<AdminListPanel
|
|
busy={isLoading}
|
|
busyText="正在加载待审版本…"
|
|
busyVariant="line"
|
|
emptyText="当前没有待审核的游戏版本。"
|
|
emptyVariant="text"
|
|
title="待审版本"
|
|
extra={<span className="admin-muted-text">共 {entries.length} 条</span>}
|
|
rows={entries}
|
|
size="wide"
|
|
tableClassName="admin-game-review-table"
|
|
columns={[
|
|
{ key: 'gameId', label: '游戏' },
|
|
{ key: 'versionNumber', label: '版本' },
|
|
{ key: 'packageBytes', label: '发行包' },
|
|
{ key: 'createdAt', label: '提交时间' },
|
|
{ key: 'actions', label: '审核' },
|
|
]}
|
|
renderRow={(entry) => {
|
|
const busy = busyVersionId === entry.versionId;
|
|
const gameMeta = gameMetaByGameId[entry.gameId];
|
|
return (
|
|
<tr key={entry.versionId}>
|
|
<td>
|
|
{/* gameId 不再作为主展示,只留在悬浮标题里做兜底标识。 */}
|
|
<div className="admin-game-review-game" title={entry.gameId}>
|
|
<span className="admin-table-cell-ellipsis">
|
|
{gameMeta?.title ?? entry.gameId}
|
|
</span>
|
|
<small className="admin-game-review-game-author admin-muted-text">
|
|
<span
|
|
className="admin-game-review-game-author-name"
|
|
title={gameMeta?.authorName || undefined}
|
|
>
|
|
{gameMeta?.authorName || '—'}
|
|
</span>
|
|
{gameMeta?.authorId ? (
|
|
<span
|
|
className="admin-game-review-game-author-id"
|
|
title={gameMeta.authorId}
|
|
>
|
|
{gameMeta.authorId}
|
|
</span>
|
|
) : null}
|
|
</small>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
v{entry.versionNumber}
|
|
<div className="admin-muted-text">{entry.status}</div>
|
|
{entry.reviewReason ? (
|
|
<div className="admin-muted-text">{entry.reviewReason}</div>
|
|
) : null}
|
|
</td>
|
|
<td>
|
|
{formatBytes(entry.packageBytes)}
|
|
<div className="admin-muted-text">
|
|
<code>{entry.packageSha256.slice(0, 12)}</code>
|
|
</div>
|
|
</td>
|
|
<td>{formatAdminDateTime(entry.createdAt)}</td>
|
|
<td className="admin-game-review-cell--actions">
|
|
<AdminActionRow className="admin-review-actions">
|
|
<AdminButton
|
|
variant="secondary"
|
|
type="button"
|
|
disabled={busy}
|
|
onClick={() => void openDetails(entry)}
|
|
>
|
|
详情
|
|
</AdminButton>
|
|
<AdminButton
|
|
variant="primary"
|
|
type="button"
|
|
disabled={busy}
|
|
onClick={() => void submitReview(entry, 'approve')}
|
|
>
|
|
通过
|
|
</AdminButton>
|
|
<AdminButton
|
|
variant="ghost"
|
|
type="button"
|
|
disabled={busy}
|
|
onClick={() => openReasonPrompt(entry)}
|
|
>
|
|
拒绝
|
|
</AdminButton>
|
|
</AdminActionRow>
|
|
</td>
|
|
</tr>
|
|
);
|
|
}}
|
|
/>
|
|
{reasonPrompt ? (
|
|
<Modal
|
|
open
|
|
title="拒绝审核"
|
|
description={`${reasonPrompt.entry.gameId} · 版本 v${reasonPrompt.entry.versionNumber}`}
|
|
closeLabel="关闭理由输入"
|
|
onClose={closeReasonPrompt}
|
|
size="sm"
|
|
className="genarrative-ui"
|
|
footer={
|
|
<div
|
|
className="admin-confirm-actions admin-review-actions"
|
|
style={{
|
|
width: '100%',
|
|
}}
|
|
>
|
|
<AdminButton
|
|
variant="secondary"
|
|
type="button"
|
|
onClick={closeReasonPrompt}
|
|
>
|
|
取消
|
|
</AdminButton>
|
|
<AdminButton
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={confirmReasonPrompt}
|
|
>
|
|
确认拒绝
|
|
</AdminButton>
|
|
</div>
|
|
}
|
|
>
|
|
<TextField
|
|
autoFocus
|
|
multiline
|
|
label="拒绝理由"
|
|
value={reasonDraft}
|
|
error={reasonError}
|
|
rows={4}
|
|
onChange={(event) => {
|
|
setReasonDraft(event.target.value);
|
|
if (reasonError) setReasonError('');
|
|
}}
|
|
/>
|
|
</Modal>
|
|
) : null}
|
|
</AdminPage>
|
|
);
|
|
}
|
|
|
|
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<string, string>;
|
|
onBack: () => void;
|
|
onPreview: () => void;
|
|
onReject: () => void;
|
|
onApprove: () => void;
|
|
}) {
|
|
// 试玩会话是按 version 现建的:新会话必须回到加载态,不能沿用上一个会话的 ready。
|
|
const [isPreviewFrameReady, setIsPreviewFrameReady] = useState(false);
|
|
const [isPreviewTimedOut, setIsPreviewTimedOut] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setIsPreviewFrameReady(false);
|
|
setIsPreviewTimedOut(false);
|
|
if (!previewUrl) return undefined;
|
|
const timer = window.setTimeout(() => {
|
|
setIsPreviewTimedOut(true);
|
|
}, PLATFORM_GAME_LOADING_TIMEOUT_MS);
|
|
return () => window.clearTimeout(timer);
|
|
}, [previewUrl]);
|
|
|
|
if (!detail) {
|
|
return (
|
|
<AdminPage wide>
|
|
<AdminPageHeading title="审核详情" />
|
|
{isDetailLoading ? <AdminAlert>正在加载审核详情…</AdminAlert> : null}
|
|
{detailError ? (
|
|
<AdminAlert tone="warning">{detailError}</AdminAlert>
|
|
) : null}
|
|
<AdminButton variant="secondary" type="button" onClick={onBack}>
|
|
返回审核列表
|
|
</AdminButton>
|
|
</AdminPage>
|
|
);
|
|
}
|
|
|
|
const coverUrl = detail.game.coverObjectKey
|
|
? assetUrls[detail.game.coverObjectKey]
|
|
: '';
|
|
|
|
return (
|
|
<AdminPage wide>
|
|
<AdminPageHeading
|
|
title={`${detail.game.title} · v${detail.version.versionNumber}`}
|
|
actions={
|
|
<AdminButton variant="secondary" type="button" onClick={onBack}>
|
|
返回审核列表
|
|
</AdminButton>
|
|
}
|
|
/>
|
|
{detailError ? (
|
|
<AdminAlert tone="warning">{detailError}</AdminAlert>
|
|
) : null}
|
|
<GameDetailDisplay
|
|
className="platform-theme platform-theme--light admin-game-review-detail"
|
|
game={detail.game}
|
|
coverObjectKey={detail.game.coverObjectKey}
|
|
screenshotObjectKeys={detail.game.screenshots}
|
|
assetUrls={assetUrls}
|
|
controlsLabel={detail.game.inputModes.join('、') || '未声明'}
|
|
versionLabel={`v${detail.version.versionNumber}`}
|
|
infoCards={[
|
|
{ label: '状态', value: detail.version.status },
|
|
{
|
|
label: '提交时间',
|
|
value: formatAdminDateTime(detail.version.createdAt),
|
|
},
|
|
{ label: '包大小', value: formatBytes(detail.version.packageBytes) },
|
|
{ label: '文件数', value: detail.version.packageFileCount },
|
|
{
|
|
label: 'SHA-256',
|
|
value: <code>{detail.version.packageSha256}</code>,
|
|
},
|
|
]}
|
|
actions={
|
|
<AdminActionRow className="admin-review-actions">
|
|
<AdminButton
|
|
variant="primary"
|
|
type="button"
|
|
disabled={isPreviewLoading}
|
|
onClick={onPreview}
|
|
>
|
|
{isPreviewLoading ? '准备试玩…' : '试玩当前待审版本'}
|
|
</AdminButton>
|
|
<AdminButton variant="ghost" type="button" onClick={onReject}>
|
|
拒绝
|
|
</AdminButton>
|
|
<AdminButton variant="primary" type="button" onClick={onApprove}>
|
|
通过
|
|
</AdminButton>
|
|
</AdminActionRow>
|
|
}
|
|
>
|
|
<section>
|
|
<h2>版本审核</h2>
|
|
<p>
|
|
发布者:{detail.game.author.name} ·{' '}
|
|
<code>{detail.game.author.id}</code>
|
|
</p>
|
|
{detail.version.reviewReason ? (
|
|
<AdminAlert tone="warning">
|
|
审核理由:{detail.version.reviewReason}
|
|
</AdminAlert>
|
|
) : null}
|
|
{detail.version.frozenMetadata ? (
|
|
<details>
|
|
<summary>查看版本冻结资料</summary>
|
|
<pre className="admin-json-preview">
|
|
{JSON.stringify(detail.version.frozenMetadata, null, 2)}
|
|
</pre>
|
|
</details>
|
|
) : null}
|
|
{previewError ? (
|
|
<AdminAlert tone="warning">{previewError}</AdminAlert>
|
|
) : null}
|
|
{previewUrl ? (
|
|
<div className="admin-game-review-preview-shell">
|
|
{isPreviewFrameReady ? null : (
|
|
<PlatformGameLoadingSurface
|
|
tone="panel"
|
|
title={detail.game.title}
|
|
cover={
|
|
coverUrl ? (
|
|
<img alt={`${detail.game.title} 封面`} src={coverUrl} />
|
|
) : null
|
|
}
|
|
message={isPreviewTimedOut ? '试玩版本加载超时' : undefined}
|
|
hint={
|
|
isPreviewTimedOut
|
|
? '可以重新创建试玩会话,或稍后再试。'
|
|
: undefined
|
|
}
|
|
/>
|
|
)}
|
|
<iframe
|
|
title={`${detail.game.title} 待审版本试玩`}
|
|
src={previewUrl}
|
|
sandbox="allow-scripts"
|
|
referrerPolicy="no-referrer"
|
|
className="admin-game-review-preview"
|
|
onLoad={() => setIsPreviewFrameReady(true)}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
{previewUrl && isPreviewTimedOut && !isPreviewFrameReady ? (
|
|
<AdminActionRow className="admin-review-actions">
|
|
<AdminButton
|
|
variant="secondary"
|
|
type="button"
|
|
disabled={isPreviewLoading}
|
|
onClick={onPreview}
|
|
>
|
|
{isPreviewLoading ? '准备试玩…' : '重新创建试玩会话'}
|
|
</AdminButton>
|
|
</AdminActionRow>
|
|
) : null}
|
|
</section>
|
|
</GameDetailDisplay>
|
|
</AdminPage>
|
|
);
|
|
}
|