44f02ee19b
后台审核详情复用公开游戏详情样式,补齐发布者、冻结资料、素材换签和待审 ZIP 试玩。 AGC 保存已发布作品绑定,支持同一 gameId 更新版本并由用户填写目标版本号。 保留 SpacetimeDB 表结构不变,增加版本号请求校验、幂等边界和本地清单字段。
634 lines
20 KiB
TypeScript
634 lines
20 KiB
TypeScript
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>
|
||
);
|
||
}
|