Files
Genarrative/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx
T
suzmii d7c4f82d7d
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m48s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m3s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m33s
Project CI / Frontend tests (pull_request) Successful in 3m24s
Project CI / Backend tests (pull_request) Successful in 7m29s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m55s
Project CI / Repository checks (pull_request) Successful in 6m33s
Project CI / Native shell tests (pull_request) Successful in 9m0s
修复自查发现的门禁与体验问题:格式、超时态语义、压缩 CPU 边界
- 新增 Rust 测试补齐 cargo fmt;ESLint 按仓库约定收口:常量与解析函数拆到 platformGameLoadingSurfaceModel.ts,删除 GAME_PLAY_STARTUP_TIMEOUT_MS 常量别名(测试直接用共享常量)
- 共享加载面新增 stalled:宿主判定等待不再推进时停掉进度动画并把 aria-busy 置回 false(去掉与 role=status 重复的 aria-live)
- 后台试玩超时时传入 stalled,避免「一边转圈一边说超时」
- Accept-Encoding 权重参数名改为大小写不敏感(RFC 9110 §12.5.3),GZIP;Q=0 视为拒绝并补用例
- 发行资源压缩分级:≥2 MiB 改用 level 1(zlib 端实测约 1/3 耗时、压缩比差约 3%),小文件仍用 level 6
- 文档:主规范补压缩与条件请求契约(并写明 60 秒窗口与撤销窗口不放宽)、nginx README 修正 Pingora 压缩关闭时的口径、pitfalls 补 CPU 边界与措辞修正
2026-10-05 18:02:48 +08:00

751 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
}
stalled={isPreviewTimedOut}
/>
)}
<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>
);
}