Files
Genarrative/src/components/game-distribution/GameDetailPage.tsx
T
suzmii aad8eb2aba
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m2s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 4m16s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 4m49s
Project CI / Backend tests (pull_request) Successful in 7m16s
Project CI / Frontend tests (pull_request) Successful in 3m7s
Project CI / Native shell tests (pull_request) Successful in 6m59s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m40s
Project CI / Repository checks (pull_request) Successful in 5m42s
作品详情页在非公开状态下不再请求评价接口,封面显示审核状态
- useGameReviews 新增第 3 参 publiclyVisible:非公开(game.status !== 'published',含审核中 / 审核未通过 / 未公开 / 已下架)时 loadPage / loadMine / submit 直接 return,不置错误态
- GameReviews 两栏在非公开时渲染既有 PlatformEmptyState「暂无评价」,不出现错误块 / 重试 / 评分失败
- 封面占位文案新增 resolveNonPublicCoverLabel(pending_review → 审核中、rejected → 审核未通过,其余退回作品级文案),样式 .game-detail-cover__pending 落在本域 CSS
- hero 评分与分页在非公开时不渲染
- 已公开(published)行为保持不变
- 新增行为测试:非公开时零请求 + 两栏空态 + 封面文案映射;已公开仍照常发请求
2026-10-04 12:34:42 +08:00

416 lines
14 KiB
TypeScript

import './gameDistribution.css';
import {
type GameDetailAssetRenderInput,
GameDetailDisplay,
} from '@genarrative/shared/components';
import { ArrowLeft, Gamepad2, Maximize2 } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type {
GameDistributionRecoveryAction,
GameDistributionVersionSummary,
} from '../../../packages/shared/src/contracts/gameDistribution';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import {
type GameDistributionGame,
type GameDistributionInputMode,
type GameDistributionMyGame,
type GameDistributionVersionStatusEntry,
getGame,
getMyGame,
loadVersionRecoveryActions,
} from '../../services/gameDistributionClient';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import {
normalizeGameEntryUrl,
normalizeGameId,
useIsMobileViewport,
} from './gameDistributionGuards';
import { GAME_METADATA_INPUT_MODE_LABELS } from './gameMetadataOptions';
import { GameRatingSummary, GameReviews } from './GameReviews';
import { GameVersionHistory } from './GameVersionHistory';
import {
GAME_STATUS_LABEL,
resolveNonPublicCoverLabel,
VERSION_STATUS_LABEL,
VERSION_STATUS_NEEDS_AUTHOR_ACTION,
} from './gameVersionHistoryModel';
import { useGameReviews } from './useGameReviews';
type GameDetailPageProps = {
gameId: string | null;
onBack: () => void;
onPlay: (gameId: string) => void;
/**
* 作者视角:走 `GET /my-games/{gameId}` 读取自己名下作品。
*
* 公开详情只服务已公开投影,作者点自己的「审核中 / 被驳回 / 已下架 / 已撤回」作品
* 会拿到 404;作者中心进入详情时必须带上这个开关(壳层把它编码进 `owner=1`,
* 刷新后仍走同一份 owner 作用域数据)。
*/
ownerView?: boolean;
};
/**
* 游玩方式优先取版本冻结时声明的输入方式(公开投影里的 `inputModes`),
* 再退回版本自由文本 `controls`;两者都没有才显示“未标注操作方式”。
* 作者视角的版本条目没有 `controls`(它属于公开版本投影),按缺省处理。
*/
function resolveControlsLabel(
version:
| GameDistributionVersionSummary
| GameDistributionVersionStatusEntry
| null,
inputModes?: GameDistributionInputMode[],
) {
if (!version) return '暂未发布可玩版本';
const declared = Array.from(
new Set(
(inputModes ?? []).map(
(mode) => GAME_METADATA_INPUT_MODE_LABELS[mode] ?? mode,
),
),
).filter(Boolean);
if (declared.length > 0) return declared.join(' · ');
const controls = 'controls' in version ? version.controls : [];
const fallback = controls.join(' · ').trim();
return fallback || '未标注操作方式';
}
/**
* 展示层需要的素材换签桥接:公开详情仍按 objectKey 逐个换签,
* 只是把结果交给共享展示组件渲染,换签失败时保持占位。
*/
function GameDetailResolvedAsset({
objectKey,
role,
}: GameDetailAssetRenderInput) {
const { resolvedUrl } = useResolvedAssetReadUrl(null, { objectKey });
if (!resolvedUrl) return null;
return (
<img
src={resolvedUrl}
alt=""
loading={role === 'cover' ? 'eager' : 'lazy'}
decoding="async"
/>
);
}
export function GameDetailPage({
gameId,
onBack,
onPlay,
ownerView = false,
}: GameDetailPageProps) {
const authUi = useAuthUi();
const [publicGame, setPublicGame] = useState<GameDistributionGame | null>(
null,
);
const [ownerGame, setOwnerGame] = useState<GameDistributionMyGame | null>(
null,
);
const [recoveryActions, setRecoveryActions] = useState<
Record<string, GameDistributionRecoveryAction>
>({});
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
const requestSerialRef = useRef(0);
const isMobileViewport = useIsMobileViewport();
const loadPublicGame = useCallback(() => {
const requestSerial = ++requestSerialRef.current;
const normalizedGameId = normalizeGameId(gameId);
setPublicGame(null);
setError('');
if (!normalizedGameId) {
setIsLoading(false);
setError('游戏编号无效');
return;
}
setIsLoading(true);
void getGame(normalizedGameId)
.then((value) => {
if (requestSerial !== requestSerialRef.current) return;
if (value?.currentVersion) {
const entryUrl = normalizeGameEntryUrl(value.currentVersion.entryUrl);
if (!entryUrl) {
setError('游戏入口地址无效');
return;
}
setPublicGame({
...value,
currentVersion: { ...value.currentVersion, entryUrl },
});
return;
}
setPublicGame(value);
if (!value) setError('找不到这款游戏');
})
.catch((loadError: unknown) => {
if (requestSerial !== requestSerialRef.current) return;
setError(
loadError instanceof Error ? loadError.message : '游戏详情加载失败',
);
})
.finally(() => {
if (requestSerial === requestSerialRef.current) setIsLoading(false);
});
}, [gameId]);
/**
* 作者视角:读 owner 作用域条目,并只为还需要作者动作的版本补一次 `recoveryAction`
* 回读(列表与详情投影都不带这一步动作)。
*/
const loadOwnerGame = useCallback(() => {
const requestSerial = ++requestSerialRef.current;
const normalizedGameId = normalizeGameId(gameId);
setOwnerGame(null);
setRecoveryActions({});
setError('');
if (!normalizedGameId) {
setIsLoading(false);
setError('游戏编号无效');
return;
}
setIsLoading(true);
void getMyGame(normalizedGameId)
.then((entry) => {
if (requestSerial !== requestSerialRef.current) return;
if (!entry) {
setError('找不到这款游戏');
return;
}
setOwnerGame(entry);
const pendingVersionIds = entry.versions
.filter(
(version) =>
!version.recoveryAction &&
VERSION_STATUS_NEEDS_AUTHOR_ACTION[version.status],
)
.map((version) => version.versionId);
if (pendingVersionIds.length === 0) return;
return loadVersionRecoveryActions(pendingVersionIds).then((actions) => {
if (requestSerial !== requestSerialRef.current) return;
setRecoveryActions(actions);
});
})
.catch((loadError: unknown) => {
if (requestSerial !== requestSerialRef.current) return;
setError(
loadError instanceof Error ? loadError.message : '作品详情加载失败',
);
})
.finally(() => {
if (requestSerial === requestSerialRef.current) setIsLoading(false);
});
}, [gameId]);
useEffect(() => {
if (ownerView) loadOwnerGame();
else loadPublicGame();
return () => {
requestSerialRef.current += 1;
};
}, [loadOwnerGame, loadPublicGame, ownerView]);
const resolvedGame = ownerView ? ownerGame : publicGame;
const reload = ownerView ? loadOwnerGame : loadPublicGame;
if (
isLoading ||
(resolvedGame && resolvedGame.id !== normalizeGameId(gameId))
)
return (
<div className="game-detail-page">
<div className="game-loading-panel">正在加载游戏详情…</div>
</div>
);
if (error || !resolvedGame)
return (
<div className="game-detail-page">
<button type="button" className="game-back-button" onClick={onBack}>
<ArrowLeft /> 返回
</button>
<PlatformStatusMessage
tone="error"
surface="platform"
className="game-status-message"
>
<span>{error || '找不到这款游戏'}</span>
<button type="button" onClick={reload}>
重新加载
</button>
</PlatformStatusMessage>
</div>
);
const canReview = Boolean(authUi?.user && authUi.canAccessProtectedData);
return (
<GameDetailContent
key={`${resolvedGame.id}:${authUi?.user?.id ?? 'guest'}:${canReview}`}
game={resolvedGame}
ownerView={ownerView}
ownerGame={ownerGame}
recoveryActions={recoveryActions}
isMobileViewport={isMobileViewport}
canReview={canReview}
onLogin={() => authUi?.openLoginModal()}
onBack={onBack}
onPlay={onPlay}
/>
);
}
function GameDetailContent({
game,
ownerView,
ownerGame,
recoveryActions,
isMobileViewport,
canReview,
onLogin,
onBack,
onPlay,
}: {
game: GameDistributionGame;
ownerView: boolean;
ownerGame: GameDistributionMyGame | null;
recoveryActions: Record<string, GameDistributionRecoveryAction>;
isMobileViewport: boolean;
canReview: boolean;
onLogin: () => void;
onBack: () => void;
onPlay: (gameId: string) => void;
}) {
/**
* 只有已公开作品才有公开评价/评分投影;审核中、审核未通过、未公开、已下架
* 一律不请求评价接口,封面位改为状态文案。
*/
const publiclyVisible = game.status === 'published';
const reviews = useGameReviews(game.id, canReview, publiclyVisible);
/** 作者视角的条目没有公开投影 `currentVersion`,可玩版本取版本历史里的已发布版本。 */
const publishedVersion =
ownerGame?.versions.find((item) => item.status === 'published') ?? null;
const version = ownerView ? publishedVersion : (game.currentVersion ?? null);
const versionLabel = ownerView
? publishedVersion
? `v${publishedVersion.versionNumber}`
: ''
: (game.currentVersion?.version ?? '');
const latestVersion = ownerView ? (ownerGame?.latestVersion ?? null) : null;
const coverPlaceholderLabel = publiclyVisible
? ''
: resolveNonPublicCoverLabel(game.status, latestVersion?.status);
const latestReviewReason = latestVersion?.reviewReason?.trim() ?? '';
const mobilePlaybackBlocked = isMobileViewport && !game.deviceSupport.mobile;
const playbackBlocked = !version || mobilePlaybackBlocked;
return (
<div className="game-detail-page">
<button type="button" className="game-back-button" onClick={onBack}>
<ArrowLeft /> 返回
</button>
<GameDetailDisplay
game={game}
coverObjectKey={game.coverObjectKey}
screenshotObjectKeys={game.screenshots}
coverColor={game.coverColor}
icon={game.icon}
renderAsset={(asset) =>
asset.role === 'cover' && coverPlaceholderLabel ? (
<span className="game-detail-cover__pending">
{coverPlaceholderLabel}
</span>
) : (
<GameDetailResolvedAsset {...asset} />
)
}
controlsLabel={resolveControlsLabel(version, game.inputModes)}
versionLabel={versionLabel}
rating={publiclyVisible ? <GameRatingSummary state={reviews} /> : null}
actions={
<>
<PlatformActionButton
size="lg"
shape="pill"
onClick={() => onPlay(game.id)}
disabled={playbackBlocked}
aria-describedby={
mobilePlaybackBlocked
? 'game-mobile-playback-warning'
: undefined
}
>
<Gamepad2 /> 立即玩
</PlatformActionButton>
{mobilePlaybackBlocked ? (
<span
id="game-mobile-playback-warning"
className="game-device-warning"
role="status"
>
请在电脑上游玩
</span>
) : null}
{ownerView && !publishedVersion ? (
<span className="game-device-warning" role="status">
作品尚未公开,暂时没有在线可玩版本
</span>
) : null}
</>
}
>
{ownerView && ownerGame ? (
<section className="game-owner-panel" aria-label="作品状态">
<div className="game-owner-panel__heading">
<h2>作品状态</h2>
<span
className={`my-game-status my-game-status--${ownerGame.status}`}
>
{GAME_STATUS_LABEL[ownerGame.status] ?? ownerGame.status}
</span>
</div>
<div className="game-owner-panel__meta">
{latestVersion ? (
<span>
最新版本 v{latestVersion.versionNumber} ·{' '}
{VERSION_STATUS_LABEL[latestVersion.status] ??
latestVersion.status}
</span>
) : (
<span>还没有提交过版本</span>
)}
</div>
{latestReviewReason ? (
<p className="game-owner-panel__reason">
驳回理由:{latestReviewReason}
</p>
) : null}
{ownerGame.versions.length > 0 ? (
<GameVersionHistory
versions={ownerGame.versions}
recoveryActions={recoveryActions}
/>
) : null}
</section>
) : null}
</GameDetailDisplay>
<GameReviews
state={reviews}
canReview={canReview}
onLogin={onLogin}
publiclyVisible={publiclyVisible}
/>
<p className="game-detail-footnote">
<Maximize2 aria-hidden="true" />{' '}
游玩时可以切换全屏,游戏运行在独立的安全容器中。
</p>
</div>
);
}
export default GameDetailPage;