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)行为保持不变 - 新增行为测试:非公开时零请求 + 两栏空态 + 封面文案映射;已公开仍照常发请求
416 lines
14 KiB
TypeScript
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;
|