Files
Genarrative/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx
T
suzmii 959ed4fd53 合并 origin/master(51cb05f41,22 个提交)到 feat/game-purchase
- 逐块合并 server-rs/crates/api-server/src/modules/game_distribution.rs:以 master 的 ReleaseAssetResponseInput / 5 参 release_asset_response_with_cache / ETag / 304 / gzip 结构为准,并在其上保留本分支的付费 404 守卫(price_mud_points > 0 仍在 release_package_bytes 读包之前返回);播放会话资源响应改用同一结构(etag None、cache_control no-store、沿用 accept-encoding 协商)。
- 合并 src/components/game-distribution/GamePlayPage.tsx:接入 master 的共享 PlatformGameLoadingSurface 与 PLATFORM_GAME_LOADING_TIMEOUT_MS,删除本地重复常量 GAME_PLAY_STARTUP_TIMEOUT_MS 与裸 div,买断制播放会话准备态改由共享加载面承载。
- 合并 src/components/game-distribution/GameDetailPage.tsx:同时保留 master 的 onOpenCreator 作者插槽与本分支的买断制购买弹窗、onOpenRecharge 充值入口。
- 合并 vite.config.ts:保留 master 的 /api/creators 代理,并保留 play-sessions 前缀清 Cookie 规则(顺序仍在通用 /api/game-distribution 之前)。
- 合并 deploy/nginx/README.md:保留 master 的 SPA allowlist 门禁口径(含 /creators、/creators/connections)与本分支的播放会话前缀章节;三份 nginx 模板的 ^~ play-sessions location 与清 Cookie 原样保留。
- 合并 apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx:保留 master 的加载面/超时用例与本分支的审核价格(冻结价优先、历史按 0)用例。
- 合并 src/components/game-distribution/GameDistributionPages.test.tsx:保留双方 mock,并新增「付费作品在会话签发期间显示共享加载面」用例。
- 合并 docs/【玩法创作】平台入口与玩法链路-2026-05-15.md(保留买断制合同并回填 master 的创作者主页与关注粉丝合同)、decision-log.md、pitfalls.md:追加双方条目,不改写任一侧正文。
- 保留 master 侧新增能力:release_asset_etag / if_none_match_matches / accepts_gzip_encoding / gzip_release_asset / release_asset_not_modified_response、SPA 加载面、创作者主页与关注粉丝(user_follow 表、creator 查询与 author_id 过滤)。
2026-10-06 00:28:36 +08:00

764 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,
// 价格不在这里二次叠加:服务端 `version_detail_payload` 已按「版本冻结价优先、
// 缺字段按 0」把结果写进 `game.priceMudPoints`,客户端直接沿用该字段。
};
}
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`;
}
/** 买断制价格口径:0 或缺失(旧响应 / undefined)都按“免费”展示,避免出现 NaN。 */
function formatPriceMudPoints(value: number | null | undefined) {
return typeof value === 'number' && value > 0 ? `${value} 泥点` : '免费';
}
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: 'priceMudPoints', 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>{formatPriceMudPoints(entry.priceMudPoints)}</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: formatPriceMudPoints(detail.game.priceMudPoints),
},
{
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>
);
}