Files
Genarrative/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx
T
suzmii 44f02ee19b 完善游戏审核试玩与客户端版本更新
后台审核详情复用公开游戏详情样式,补齐发布者、冻结资料、素材换签和待审 ZIP 试玩。

AGC 保存已发布作品绑定,支持同一 gameId 更新版本并由用户填写目标版本号。

保留 SpacetimeDB 表结构不变,增加版本号请求校验、幂等边界和本地清单字段。
2026-10-03 12:35:57 +08:00

634 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
AdminActionRow,
AdminAlert,
AdminButton,
AdminListPanel,
AdminPage,
AdminPageHeading,
GameDetailDisplay,
formatAdminDateTime,
Modal,
TextField,
} from '@genarrative/shared/components';
import { RefreshCcw } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import {
createAdminGameDistributionPreviewSession,
getAdminAssetReadUrl,
getAdminGameDistributionVersion,
listAdminGameDistributionReviews,
reviewAdminGameDistributionVersion,
suspendAdminGameDistributionGame,
} from '../api/adminApiClient';
import type {
AdminGameDistributionGameDetail,
AdminGameDistributionReviewEntry,
AdminGameDistributionVersionDetailResponse,
} from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError } from './pageUtils';
interface AdminGameDistributionReviewPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
interface ReviewReasonPrompt {
decision: 'reject' | 'suspend';
entry: AdminGameDistributionReviewEntry;
}
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 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
? 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 [busyGameId, setBusyGameId] = useState('');
const writeConfirm = useAdminWriteConfirm();
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 loadReviews = useCallback(async () => {
setIsLoading(true);
setErrorMessage('');
try {
const response = await listAdminGameDistributionReviews(token);
setEntries(response.entries);
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoading(false);
}
}, [token, onUnauthorized]);
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('');
}
}
/**
* 管理员安全下架:先二次确认,再带当前公开修订号调用后端;并发审核导致修订号变化时
* 由服务端返回冲突,前端只提示刷新,不静默重试。
*/
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'],
) {
setReasonDraft('');
setReasonError('');
setReasonPrompt({ decision, entry });
}
function closeReasonPrompt() {
setReasonPrompt(null);
setReasonDraft('');
setReasonError('');
}
function confirmReasonPrompt() {
if (!reasonPrompt) return;
const reason = reasonDraft.trim();
if (reasonPrompt.decision === 'reject' && !reason) {
setReasonError('拒绝审核必须填写理由');
return;
}
const { decision, entry } = reasonPrompt;
closeReasonPrompt();
if (decision === 'reject') {
void submitReview(entry, 'reject', reason);
return;
}
void suspendGame(entry, 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={() => {
closeDetails();
openReasonPrompt(selectedEntry, 'reject');
}}
onApprove={() => {
closeDetails();
void submitReview(selectedEntry, '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"
columns={[
{ key: 'gameId', label: '游戏' },
{ key: 'versionNumber', label: '版本' },
{ key: 'packageBytes', label: '发行包' },
{ key: 'createdAt', label: '提交时间' },
{ key: 'actions', label: '审核' },
]}
renderRow={(entry) => {
const busy = busyVersionId === entry.versionId;
return (
<tr key={entry.versionId}>
<td>
<code>{entry.gameId}</code>
</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>
<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, 'reject')}
>
拒绝
</AdminButton>
<AdminButton
variant="danger"
type="button"
disabled={busy || busyGameId === entry.gameId}
onClick={() => openReasonPrompt(entry, 'suspend')}
>
{busyGameId === entry.gameId ? '正在下架…' : '安全下架'}
</AdminButton>
</AdminActionRow>
</td>
</tr>
);
}}
/>
{reasonPrompt ? (
<Modal
open
title={reasonPrompt.decision === 'reject' ? '拒绝审核' : '安全下架'}
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={
reasonPrompt.decision === 'reject' ? 'ghost' : 'danger'
}
onClick={confirmReasonPrompt}
>
{reasonPrompt.decision === 'reject' ? '确认拒绝' : '继续下架'}
</AdminButton>
</div>
}
>
<TextField
autoFocus
multiline
label={reasonPrompt.decision === 'reject' ? '拒绝理由' : '下架原因'}
value={reasonDraft}
error={reasonError}
rows={4}
onChange={(event) => {
setReasonDraft(event.target.value);
if (reasonError) setReasonError('');
}}
/>
</Modal>
) : null}
{writeConfirm.confirmDialog}
</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;
}) {
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>
);
}
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 ? (
<iframe
title={`${detail.game.title} 待审版本试玩`}
src={previewUrl}
sandbox="allow-scripts"
referrerPolicy="no-referrer"
className="admin-game-review-preview"
/>
) : null}
</section>
</GameDetailDisplay>
</AdminPage>
);
}