作品管理与Phaser4客户端发布闭环
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m21s
Project CI / Backend tests (pull_request) Failing after 16s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m18s
Project CI / Frontend tests (pull_request) Successful in 2m8s
Project CI / Repository checks (pull_request) Failing after 16s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m56s
Project CI / Native shell tests (pull_request) Successful in 5m47s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled

补齐 Phaser4 客户端发布前校验、阶段进度与原生幂等账本

增加 Web 作者私有作品管理、资料编辑、版本历史与软删除

增加后台作品筛选、游标分页与删除治理

同步 SpacetimeDB schema、bindings、DTO、文档与定向测试
This commit is contained in:
2026-10-01 16:30:40 +08:00
parent 1f055e9902
commit a2b70d92b8
80 changed files with 5836 additions and 676 deletions
@@ -10,11 +10,19 @@ import {
} 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 { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
@@ -24,34 +32,52 @@ import {
useIsMobileViewport,
} from './gameDistributionGuards';
import { GameCover } from './GameGalleryPage';
import { GAME_METADATA_INPUT_MODE_LABELS } from './gameMetadataOptions';
import { GameVersionHistory } from './GameVersionHistory';
import {
GAME_STATUS_LABEL,
VERSION_STATUS_LABEL,
VERSION_STATUS_NEEDS_AUTHOR_ACTION,
} from './gameVersionHistoryModel';
type GameDetailPageProps = {
gameId: string | null;
onBack: () => void;
onPlay: (gameId: string) => void;
};
const INPUT_MODE_LABELS: Record<GameDistributionInputMode, string> = {
keyboard: '键盘',
mouse: '鼠标',
touch: '触屏',
/**
* 作者视角:走 `GET /my-games/{gameId}` 读取自己名下作品。
*
* 公开详情只服务已公开投影,作者点自己的「审核中 / 被驳回 / 已下架 / 已撤回」作品
* 会拿到 404;作者中心进入详情时必须带上这个开关(壳层把它编码进 `owner=1`,
* 刷新后仍走同一份 owner 作用域数据)。
*/
ownerView?: boolean;
};
/**
* 游玩方式优先取版本冻结时声明的输入方式(公开投影里的 `inputModes`),
* 再退回版本自由文本 `controls`;两者都没有才显示“未标注操作方式”。
* 作者视角的版本条目没有 `controls`(它属于公开版本投影),按缺省处理。
*/
function resolveControlsLabel(
version: GameDistributionGame['currentVersion'],
version:
| GameDistributionVersionSummary
| GameDistributionVersionStatusEntry
| null,
inputModes?: GameDistributionInputMode[],
) {
if (!version) return '暂未发布可玩版本';
const declared = Array.from(
new Set((inputModes ?? []).map((mode) => INPUT_MODE_LABELS[mode] ?? mode)),
new Set(
(inputModes ?? []).map(
(mode) => GAME_METADATA_INPUT_MODE_LABELS[mode] ?? mode,
),
),
).filter(Boolean);
if (declared.length > 0) return declared.join(' · ');
const controls = version.controls.join(' · ').trim();
return controls || '未标注操作方式';
const controls = 'controls' in version ? version.controls : [];
const fallback = controls.join(' · ').trim();
return fallback || '未标注操作方式';
}
/** 缩略图自己换签,失败时退回空占位,不阻塞 hero 与主行动作。 */
@@ -129,17 +155,26 @@ export function GameDetailPage({
gameId,
onBack,
onPlay,
ownerView = false,
}: GameDetailPageProps) {
const [game, setGame] = useState<GameDistributionGame | null>(null);
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 loadGame = useCallback(() => {
const loadPublicGame = useCallback(() => {
const requestSerial = ++requestSerialRef.current;
const normalizedGameId = normalizeGameId(gameId);
setGame(null);
setPublicGame(null);
setError('');
if (!normalizedGameId) {
setIsLoading(false);
@@ -156,13 +191,13 @@ export function GameDetailPage({
setError('游戏入口地址无效');
return;
}
setGame({
setPublicGame({
...value,
currentVersion: { ...value.currentVersion, entryUrl },
});
return;
}
setGame(value);
setPublicGame(value);
if (!value) setError('找不到这款游戏');
})
.catch((loadError: unknown) => {
@@ -176,12 +211,64 @@ export function GameDetailPage({
});
}, [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(() => {
void loadGame();
if (ownerView) loadOwnerGame();
else loadPublicGame();
return () => {
requestSerialRef.current += 1;
};
}, [loadGame]);
}, [loadOwnerGame, loadPublicGame, ownerView]);
const game = ownerView ? ownerGame : publicGame;
const reload = ownerView ? loadOwnerGame : loadPublicGame;
if (isLoading)
return (
@@ -201,14 +288,24 @@ export function GameDetailPage({
className="game-status-message"
>
<span>{error || '找不到这款游戏'}</span>
<button type="button" onClick={loadGame}>
<button type="button" onClick={reload}>
重新加载
</button>
</PlatformStatusMessage>
</div>
);
const version = game.currentVersion;
/** 作者视角的条目没有公开投影 `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 latestReviewReason = latestVersion?.reviewReason?.trim() ?? '';
const mobilePlaybackBlocked = isMobileViewport && !game.deviceSupport.mobile;
const playbackBlocked = !version || mobilePlaybackBlocked;
return (
@@ -251,12 +348,51 @@ export function GameDetailPage({
请在电脑上游玩
</span>
) : null}
{ownerView && !publishedVersion ? (
<span className="game-device-warning" role="status">
作品尚未公开,暂时没有在线可玩版本
</span>
) : null}
<span className="game-detail-hero__play-count">
{game.playCount.toLocaleString()} 次游玩
</span>
</div>
</div>
</section>
{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}
<section className="game-detail-body">
<div className="game-detail-main">
<h2>关于这款游戏</h2>
@@ -283,7 +419,7 @@ export function GameDetailPage({
</div>
<div className="game-info-card">
<strong>当前版本</strong>
<span>{version?.version || '—'}</span>
<span>{versionLabel || '—'}</span>
</div>
</aside>
</section>
@@ -11,18 +11,26 @@ import {
} from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { GameDistributionGame } from '../../services/gameDistributionClient';
import type {
GameDistributionGame,
GameDistributionMyGame,
GameDistributionVersionStatusEntry,
} from '../../services/gameDistributionClient';
import { GameDetailPage } from './GameDetailPage';
import { GameGalleryPage } from './GameGalleryPage';
import { GAME_PLAY_STARTUP_TIMEOUT_MS, GamePlayPage } from './GamePlayPage';
const listGamesMock = vi.hoisted(() => vi.fn());
const getGameMock = vi.hoisted(() => vi.fn());
const getMyGameMock = vi.hoisted(() => vi.fn());
const loadVersionRecoveryActionsMock = vi.hoisted(() => vi.fn());
const resolvedAssetUrlMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/gameDistributionClient', () => ({
listGames: listGamesMock,
getGame: getGameMock,
getMyGame: getMyGameMock,
loadVersionRecoveryActions: loadVersionRecoveryActionsMock,
}));
// 只替换换签结果,测试不发起任何真实请求;返回空串即代表签名失败或字段为空。
@@ -105,6 +113,8 @@ beforeEach(() => {
setViewportWidth(1024);
listGamesMock.mockResolvedValue([]);
getGameMock.mockResolvedValue(createGame());
getMyGameMock.mockResolvedValue(null);
loadVersionRecoveryActionsMock.mockResolvedValue({});
resolvedAssetUrlMock.mockImplementation((objectKey: string) =>
objectKey ? `https://assets.example.test/${objectKey}` : '',
);
@@ -198,6 +208,111 @@ describe('GameDetailPage', () => {
});
});
describe('GameDetailPage 作者视角', () => {
const reviewVersion: GameDistributionVersionStatusEntry = {
versionId: 'version-2',
gameId: 'game-1',
versionNumber: 2,
packageSha256: 'b'.repeat(64),
packageBytes: 2048,
status: 'pending_review',
publicationRevision: 6,
reviewReason: '封面需要替换',
createdAt: '2026-09-20T08:00:00Z',
updatedAt: '2026-09-20T09:00:00Z',
};
const publishedVersion: GameDistributionVersionStatusEntry = {
versionId: 'version-1',
gameId: 'game-1',
versionNumber: 1,
packageSha256: 'c'.repeat(64),
packageBytes: 1024,
status: 'published',
publicationRevision: 5,
reviewReason: null,
createdAt: '2026-09-19T08:00:00Z',
updatedAt: '2026-09-19T09:00:00Z',
};
function createOwnerGame(
overrides: Partial<GameDistributionMyGame> = {},
): GameDistributionMyGame {
return {
...createGame({ currentVersion: null, status: 'unpublished' }),
latestVersion: reviewVersion,
versions: [reviewVersion],
...overrides,
} as GameDistributionMyGame;
}
it('走 owner 路由并展示未公开作品的私有状态,不再请求公开详情', async () => {
getMyGameMock.mockResolvedValue(createOwnerGame());
render(
<GameDetailPage
gameId="game-1"
onBack={vi.fn()}
onPlay={vi.fn()}
ownerView
/>,
);
expect(await screen.findByText('作品状态')).toBeTruthy();
expect(screen.getByText('未公开')).toBeTruthy();
expect(
screen.getByText('驳回理由:封面需要替换', {
selector: '.game-owner-panel__reason',
}),
).toBeTruthy();
expect(getMyGameMock).toHaveBeenCalledWith('game-1');
expect(getGameMock).not.toHaveBeenCalled();
const playButton = screen.getByRole('button', { name: /立即玩/u });
expect((playButton as HTMLButtonElement).disabled).toBe(true);
expect(screen.getByText('作品尚未公开,暂时没有在线可玩版本')).toBeTruthy();
});
it('以已发布版本作为在线版本,并为未完成版本回读下一步动作', async () => {
getMyGameMock.mockResolvedValue(
createOwnerGame({
status: 'published',
versions: [reviewVersion, publishedVersion],
}),
);
loadVersionRecoveryActionsMock.mockResolvedValue({ 'version-2': 'wait' });
render(
<GameDetailPage
gameId="game-1"
onBack={vi.fn()}
onPlay={vi.fn()}
ownerView
/>,
);
expect(await screen.findByText('下一步:等待审核结果')).toBeTruthy();
expect(loadVersionRecoveryActionsMock).toHaveBeenCalledWith(['version-2']);
expect(within(screen.getByRole('list')).getByText('v1')).toBeTruthy();
const playButton = screen.getByRole('button', { name: /立即玩/u });
expect((playButton as HTMLButtonElement).disabled).toBe(false);
});
it('作者读不到自己名下作品时按找不到处理', async () => {
getMyGameMock.mockResolvedValue(null);
render(
<GameDetailPage
gameId="game-1"
onBack={vi.fn()}
onPlay={vi.fn()}
ownerView
/>,
);
expect(await screen.findByText('找不到这款游戏')).toBeTruthy();
});
});
describe('GamePlayPage', () => {
it('waits for an explicit start before mounting the sandbox iframe', async () => {
render(<GamePlayPage gameId="game-1" onBack={vi.fn()} />);
@@ -0,0 +1,234 @@
import { useRef, useState } from 'react';
import {
GAME_PUBLISH_IMAGE_ACCEPT,
type GamePublishImageAsset,
MAX_GAME_SCREENSHOTS,
uploadGamePublishImageAsset,
} from './gamePublishAssets';
type GamePublishMediaFieldsetProps = {
cover: GamePublishImageAsset | null;
screenshots: GamePublishImageAsset[];
/** 提交流程进行中时禁止换图;上传中的禁用由组件自己叠加。 */
disabled?: boolean;
/**
* 线上封面没有可复用素材 ID 时的提示。
*
* 发布与资料编辑对“为什么必须重选”的说法不同,其余文案共用。
*/
missingCoverHint: string;
onCoverChange: (asset: GamePublishImageAsset | null) => void;
onScreenshotsChange: (assets: GamePublishImageAsset[]) => void;
onError: (message: string) => void;
/** 上传进行中状态;调用方用它禁用提交。 */
onUploadingChange: (uploading: boolean) => void;
};
/**
* 封面与截图选择区。
*
* 发布游戏与「作品资料编辑」共用同一份选图、上传与移除交互:两处都只需要素材 ID,
* 本地只负责把文件先传成平台素材再交给服务端冻结/保存。
*/
export function GamePublishMediaFieldset({
cover,
screenshots,
disabled = false,
missingCoverHint,
onCoverChange,
onScreenshotsChange,
onError,
onUploadingChange,
}: GamePublishMediaFieldsetProps) {
const coverInputRef = useRef<HTMLInputElement>(null);
const screenshotInputRef = useRef<HTMLInputElement>(null);
const [isUploading, setIsUploading] = useState(false);
const fieldDisabled = disabled || isUploading;
async function handleCoverSelected(file: File | null) {
if (!file) return;
onError('');
setIsUploading(true);
onUploadingChange(true);
try {
const uploaded = await uploadGamePublishImageAsset(file, {
kind: 'cover',
});
onCoverChange(uploaded);
} catch (uploadError: unknown) {
onError(
uploadError instanceof Error
? uploadError.message
: '封面上传失败,请重试',
);
} finally {
setIsUploading(false);
onUploadingChange(false);
// 清空 input 才能让作者重新选择同一张图。
if (coverInputRef.current) coverInputRef.current.value = '';
}
}
async function handleScreenshotsSelected(files: File[]) {
if (files.length === 0) return;
const remaining = MAX_GAME_SCREENSHOTS - screenshots.length;
if (files.length > remaining) {
onError(
remaining > 0
? `游戏截图最多 6 张,还可以再选 ${remaining} 张`
: '游戏截图最多 6 张',
);
if (screenshotInputRef.current) screenshotInputRef.current.value = '';
return;
}
onError('');
setIsUploading(true);
onUploadingChange(true);
try {
// 逐张入库:中途失败时已经传好的截图仍然保留,作者不用重新选择。
let nextScreenshots = [...screenshots];
for (const file of files) {
const uploaded = await uploadGamePublishImageAsset(file, {
kind: 'screenshot',
});
if (nextScreenshots.length >= MAX_GAME_SCREENSHOTS) continue;
nextScreenshots = [...nextScreenshots, uploaded];
onScreenshotsChange(nextScreenshots);
}
} catch (uploadError: unknown) {
onError(
uploadError instanceof Error
? uploadError.message
: '截图上传失败,请重试',
);
} finally {
setIsUploading(false);
onUploadingChange(false);
if (screenshotInputRef.current) screenshotInputRef.current.value = '';
}
}
function removeScreenshot(index: number) {
onScreenshotsChange(
screenshots.filter((_, itemIndex) => itemIndex !== index),
);
}
return (
<fieldset className="game-publish-fieldset game-publish-media">
<legend>封面与截图</legend>
<div className="game-publish-media__cover">
<div className="game-publish-media__preview">
{cover?.src ? (
<img src={cover.src} alt="游戏封面预览" />
) : (
<span className="game-publish-media__placeholder">
{cover ? '封面预览加载中' : '还没有封面'}
</span>
)}
</div>
<div className="game-publish-media__copy">
<label className="game-publish-field">
游戏封面(必需)
{/*
原生 file 控件在清空 value 后会显示“未选择任何文件”,与下方的“已选择”提示互相
矛盾;上传后必须清空 value 才能重选同一张图,所以这里改用自定义触发按钮。
*/}
<span className="game-publish-media__picker">
<input
ref={coverInputRef}
type="file"
accept={GAME_PUBLISH_IMAGE_ACCEPT}
disabled={fieldDisabled}
onChange={(event) => {
void handleCoverSelected(event.target.files?.[0] ?? null);
}}
/>
<span className="game-publish-media__pick">
{cover ? '更换封面图片' : '选择封面图片'}
</span>
</span>
<span className="game-publish-hint">
{cover?.assetObjectId
? `已选择「${cover.name}」;重新选择即可替换当前封面。`
: cover
? missingCoverHint
: '封面会作为广场卡片与详情页首图,建议 16:9 的 JPG/PNG/WebP,最大 6MB。'}
</span>
</label>
{cover ? (
<button
type="button"
className="game-publish-media__remove"
disabled={fieldDisabled}
onClick={() => onCoverChange(null)}
>
移除封面
</button>
) : null}
</div>
</div>
<div className="game-publish-media__screenshots">
<label className="game-publish-field">
{`游戏截图(可选,最多 ${MAX_GAME_SCREENSHOTS} 张)`}
<span className="game-publish-media__picker">
<input
ref={screenshotInputRef}
type="file"
accept={GAME_PUBLISH_IMAGE_ACCEPT}
multiple
disabled={
fieldDisabled || screenshots.length >= MAX_GAME_SCREENSHOTS
}
onChange={(event) => {
void handleScreenshotsSelected(
Array.from(event.target.files ?? []),
);
}}
/>
<span className="game-publish-media__pick">
{screenshots.length >= MAX_GAME_SCREENSHOTS
? '截图已满 6 张'
: '添加截图'}
</span>
</span>
<span className="game-publish-hint">
截图会按选择顺序展示在详情页;不需要可以留空。
</span>
</label>
{screenshots.length > 0 ? (
<ul className="game-publish-media__strip">
{screenshots.map((asset, index) => (
<li
key={`${asset.assetObjectId || asset.objectKey}-${index}`}
className="game-publish-media__shot"
>
{asset.src ? (
<img src={asset.src} alt={`截图 ${index + 1} 预览`} />
) : (
<span className="game-publish-media__placeholder">
{`截图 ${index + 1}`}
</span>
)}
<button
type="button"
className="game-publish-media__remove"
disabled={fieldDisabled}
onClick={() => removeScreenshot(index)}
>
{`移除截图 ${index + 1}`}
</button>
</li>
))}
</ul>
) : null}
</div>
{isUploading ? (
<span className="game-publish-hint">正在上传图片,请稍候…</span>
) : null}
</fieldset>
);
}
export default GamePublishMediaFieldset;
@@ -6,7 +6,6 @@ import { useEffect, useRef, useState } from 'react';
import type {
GameDistributionCategory,
GameDistributionDeviceSupport,
GameDistributionInputMode,
GameDistributionOrientation,
GameDistributionVersionDetail,
} from '../../../packages/shared/src/contracts/gameDistribution';
@@ -23,12 +22,12 @@ import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import {
GAME_PUBLISH_IMAGE_ACCEPT,
type GamePublishImageAsset,
MAX_GAME_SCREENSHOTS,
resolveGamePublishImagePreview,
uploadGamePublishImageAsset,
} from './gamePublishAssets';
GAME_METADATA_CATEGORIES,
GAME_METADATA_ORIENTATIONS,
parseGameTagsInput,
resolveGameInputModes,
} from './gameMetadataOptions';
import { type GamePublishImageAsset } from './gamePublishAssets';
import { GamePublishClientGuide } from './GamePublishClientGuide';
import {
clearPublishDraft,
@@ -37,28 +36,11 @@ import {
resolvePublishDraftRecoveryCopy,
writePublishDraft,
} from './gamePublishDraft';
import { applyVersionMediaDefaults } from './gamePublishMediaDefaults';
import { GamePublishMediaFieldset } from './GamePublishMediaFieldset';
import { resolvePublishMetadataError } from './gamePublishMetadata';
import { prepareGamePackage } from './gameZipPackage';
const CATEGORIES: GameDistributionCategory[] = [
'休闲',
'益智',
'动作',
'冒险',
'模拟',
'策略',
'其他',
];
const ORIENTATIONS: Array<{
value: GameDistributionOrientation;
label: string;
}> = [
{ value: 'responsive', label: '自适应' },
{ value: 'landscape', label: '横屏' },
{ value: 'portrait', label: '竖屏' },
];
type GamePublishPageProps = {
onBack: () => void;
onOpenMyGames: () => void;
@@ -83,81 +65,6 @@ function createPublishKey() {
return `web-publish-${random}`;
}
type VersionMediaApplyTarget = {
setCover: (
updater: (
current: GamePublishImageAsset | null,
) => GamePublishImageAsset | null,
) => void;
setScreenshots: (
updater: (current: GamePublishImageAsset[]) => GamePublishImageAsset[],
) => void;
isCancelled: () => boolean;
};
/**
* 用版本冻结资料回填封面与截图。
*
* 快照缺失(或旧版本没存素材 ID)时只回填线上封面预览:此时创作者必须重新选择一次封面,
* 因为服务端只接受素材 ID 作为冻结输入,前端不能凭对象键猜素材身份。
*/
async function applyVersionMediaDefaults(
detail: GameDistributionVersionDetail,
apply: VersionMediaApplyTarget,
) {
const frozen = detail.version.frozenMetadata ?? null;
const coverAssetId = frozen?.coverAssetId?.trim() ?? '';
const coverObjectKey =
frozen?.coverObjectKey?.trim() ?? detail.game.coverObjectKey?.trim() ?? '';
const coverName = detail.game.title.trim() || '当前封面';
const buildCover = (src: string): GamePublishImageAsset => ({
assetObjectId: coverAssetId,
objectKey: coverObjectKey,
src,
name: coverName,
});
const screenshots = (frozen?.screenshots ?? []).filter((screenshot) =>
Boolean(screenshot?.assetId?.trim()),
);
if (apply.isCancelled()) return;
apply.setCover(() =>
coverAssetId || coverObjectKey ? buildCover('') : null,
);
apply.setScreenshots(() =>
screenshots.map((screenshot, index) => ({
assetObjectId: screenshot.assetId.trim(),
objectKey: screenshot.objectKey?.trim() ?? '',
src: '',
name: `截图 ${index + 1}`,
})),
);
const coverPreview = await resolveGamePublishImagePreview(coverObjectKey);
if (apply.isCancelled()) return;
if (coverPreview) {
// 只在作者还没换图、且预览仍为空时补上签名地址,避免覆盖作者的新选择。
apply.setCover((current) =>
current && !current.src && current.objectKey === coverObjectKey
? { ...current, src: coverPreview }
: current,
);
}
for (const [index, screenshot] of screenshots.entries()) {
const preview = await resolveGamePublishImagePreview(
screenshot.objectKey ?? '',
);
if (apply.isCancelled() || !preview) continue;
apply.setScreenshots((current) =>
current.map((item, itemIndex) =>
itemIndex === index && item.assetObjectId === screenshot.assetId
? { ...item, src: preview }
: item,
),
);
}
}
export function GamePublishPage({
onBack,
onOpenMyGames,
@@ -165,8 +72,6 @@ export function GamePublishPage({
}: GamePublishPageProps) {
const authUi = useAuthUi();
const fileInputRef = useRef<HTMLInputElement>(null);
const coverInputRef = useRef<HTMLInputElement>(null);
const screenshotInputRef = useRef<HTMLInputElement>(null);
const [title, setTitle] = useState('');
const [summary, setSummary] = useState('');
const [description, setDescription] = useState('');
@@ -344,72 +249,6 @@ export function GamePublishPage({
draftDetail !== null &&
draftDetail.version.recoveryAction !== 'submit';
async function handleCoverSelected(file: File | null) {
if (!file) return;
setError('');
setIsUploadingAsset(true);
try {
const uploaded = await uploadGamePublishImageAsset(file, {
kind: 'cover',
});
setCoverAsset(uploaded);
} catch (uploadError: unknown) {
setError(
uploadError instanceof Error
? uploadError.message
: '封面上传失败,请重试',
);
} finally {
setIsUploadingAsset(false);
// 清空 input 才能让作者重新选择同一张图。
if (coverInputRef.current) coverInputRef.current.value = '';
}
}
async function handleScreenshotsSelected(files: File[]) {
if (files.length === 0) return;
const remaining = MAX_GAME_SCREENSHOTS - screenshotAssets.length;
if (files.length > remaining) {
setError(
remaining > 0
? `游戏截图最多 6 张,还可以再选 ${remaining} 张`
: '游戏截图最多 6 张',
);
if (screenshotInputRef.current) screenshotInputRef.current.value = '';
return;
}
setError('');
setIsUploadingAsset(true);
try {
for (const file of files) {
const uploaded = await uploadGamePublishImageAsset(file, {
kind: 'screenshot',
});
// 逐张入库:中途失败时已经传好的截图仍然保留,作者不用重新选择。
setScreenshotAssets((current) =>
current.length >= MAX_GAME_SCREENSHOTS
? current
: [...current, uploaded],
);
}
} catch (uploadError: unknown) {
setError(
uploadError instanceof Error
? uploadError.message
: '截图上传失败,请重试',
);
} finally {
setIsUploadingAsset(false);
if (screenshotInputRef.current) screenshotInputRef.current.value = '';
}
}
function removeScreenshotAsset(index: number) {
setScreenshotAssets((current) =>
current.filter((_, itemIndex) => itemIndex !== index),
);
}
/** 展示中的线上封面没有素材 ID 时无法冻结进新版本,必须先本地拦一次并说明原因。 */
function resolveMediaSubmitError() {
if (coverAsset && !coverAsset.assetObjectId.trim()) {
@@ -507,13 +346,8 @@ export function GamePublishPage({
try {
const prepared = await prepareGamePackage(packageFile);
const updateKey = resolvePublishKey();
const inputModes: GameDistributionInputMode[] = ['keyboard', 'mouse'];
if (deviceSupport.touch) inputModes.push('touch');
const tags = tagsInput
.split(/[,,]/u)
.map((tag) => tag.trim())
.filter(Boolean)
.slice(0, 5);
const inputModes = resolveGameInputModes(deviceSupport);
const tags = parseGameTagsInput(tagsInput);
const gameMetadata = {
title: title.trim(),
summary: summary.trim(),
@@ -601,13 +435,8 @@ export function GamePublishPage({
setError('');
try {
const prepared = await prepareGamePackage(packageFile);
const inputModes: GameDistributionInputMode[] = ['keyboard', 'mouse'];
if (deviceSupport.touch) inputModes.push('touch');
const tags = tagsInput
.split(/[,,]/u)
.map((tag) => tag.trim())
.filter(Boolean)
.slice(0, 5);
const inputModes = resolveGameInputModes(deviceSupport);
const tags = parseGameTagsInput(tagsInput);
const gameMetadata = {
title: title.trim(),
summary: summary.trim(),
@@ -882,7 +711,7 @@ export function GamePublishPage({
setCategory(e.target.value as GameDistributionCategory)
}
>
{CATEGORIES.map((item) => (
{GAME_METADATA_CATEGORIES.map((item) => (
<option key={item} value={item}>
{item}
</option>
@@ -907,7 +736,7 @@ export function GamePublishPage({
setOrientation(e.target.value as GameDistributionOrientation)
}
>
{ORIENTATIONS.map((item) => (
{GAME_METADATA_ORIENTATIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
@@ -940,125 +769,16 @@ export function GamePublishPage({
</label>
))}
</fieldset>
<fieldset className="game-publish-fieldset game-publish-media">
<legend>封面与截图</legend>
<div className="game-publish-media__cover">
<div className="game-publish-media__preview">
{coverAsset?.src ? (
<img src={coverAsset.src} alt="游戏封面预览" />
) : (
<span className="game-publish-media__placeholder">
{coverAsset ? '封面预览加载中' : '还没有封面'}
</span>
)}
</div>
<div className="game-publish-media__copy">
<label className="game-publish-field">
游戏封面(必需)
{/*
原生 file 控件在清空 value 后会显示“未选择任何文件”,与下方的“已选择”提示互相
矛盾;上传后必须清空 value 才能重选同一张图,所以这里改用自定义触发按钮。
*/}
<span className="game-publish-media__picker">
<input
ref={coverInputRef}
type="file"
accept={GAME_PUBLISH_IMAGE_ACCEPT}
disabled={
isResumingDraft || isSubmitting || isUploadingAsset
}
onChange={(event) => {
void handleCoverSelected(event.target.files?.[0] ?? null);
}}
/>
<span className="game-publish-media__pick">
{coverAsset ? '更换封面图片' : '选择封面图片'}
</span>
</span>
<span className="game-publish-hint">
{coverAsset?.assetObjectId
? `已选择「${coverAsset.name}」;重新选择即可替换本次发布的封面。`
: coverAsset
? '本次发布需要重新选择一次封面图片,才能沿用这张线上封面。'
: '封面会作为广场卡片与详情页首图,建议 16:9 的 JPG/PNG/WebP,最大 6MB。'}
</span>
</label>
{coverAsset ? (
<button
type="button"
className="game-publish-media__remove"
disabled={isResumingDraft || isSubmitting || isUploadingAsset}
onClick={() => setCoverAsset(null)}
>
移除封面
</button>
) : null}
</div>
</div>
<div className="game-publish-media__screenshots">
<label className="game-publish-field">
{`游戏截图(可选,最多 ${MAX_GAME_SCREENSHOTS} 张)`}
<span className="game-publish-media__picker">
<input
ref={screenshotInputRef}
type="file"
accept={GAME_PUBLISH_IMAGE_ACCEPT}
multiple
disabled={
isResumingDraft ||
isSubmitting ||
isUploadingAsset ||
screenshotAssets.length >= MAX_GAME_SCREENSHOTS
}
onChange={(event) => {
void handleScreenshotsSelected(
Array.from(event.target.files ?? []),
);
}}
/>
<span className="game-publish-media__pick">
{screenshotAssets.length >= MAX_GAME_SCREENSHOTS
? '截图已满 6 张'
: '添加截图'}
</span>
</span>
<span className="game-publish-hint">
截图会按选择顺序展示在详情页;不需要可以留空。
</span>
</label>
{screenshotAssets.length > 0 ? (
<ul className="game-publish-media__strip">
{screenshotAssets.map((asset, index) => (
<li
key={`${asset.assetObjectId || asset.objectKey}-${index}`}
className="game-publish-media__shot"
>
{asset.src ? (
<img src={asset.src} alt={`截图 ${index + 1} 预览`} />
) : (
<span className="game-publish-media__placeholder">
{`截图 ${index + 1}`}
</span>
)}
<button
type="button"
className="game-publish-media__remove"
disabled={
isResumingDraft || isSubmitting || isUploadingAsset
}
onClick={() => removeScreenshotAsset(index)}
>
{`移除截图 ${index + 1}`}
</button>
</li>
))}
</ul>
) : null}
</div>
{isUploadingAsset ? (
<span className="game-publish-hint">正在上传图片,请稍候…</span>
) : null}
</fieldset>
<GamePublishMediaFieldset
cover={coverAsset}
screenshots={screenshotAssets}
disabled={isResumingDraft || isSubmitting}
missingCoverHint="本次发布需要重新选择一次封面图片,才能沿用这张线上封面。"
onCoverChange={setCoverAsset}
onScreenshotsChange={setScreenshotAssets}
onError={setError}
onUploadingChange={setIsUploadingAsset}
/>
<label className="game-publish-field">
{resumeNeedsPackage ? '重新选择发行包 ZIP' : '发行包 ZIP'}
<input
@@ -0,0 +1,79 @@
import type { GameDistributionRecoveryAction } from '../../../packages/shared/src/contracts/gameDistribution';
import type { GameDistributionVersionStatusEntry } from '../../services/gameDistributionClient';
import {
RECOVERY_ACTION_LABEL,
VERSION_STATUS_LABEL,
} from './gameVersionHistoryModel';
function formatVersionTime(value: string) {
const time = new Date(value);
if (Number.isNaN(time.getTime())) return '';
const pad = (part: number) => String(part).padStart(2, '0');
return `${time.getFullYear()}-${pad(time.getMonth() + 1)}-${pad(time.getDate())} ${pad(time.getHours())}:${pad(time.getMinutes())}`;
}
function formatPackageSize(bytes: number) {
if (!Number.isFinite(bytes) || bytes <= 0) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
type GameVersionHistoryProps = {
id?: string;
versions: readonly GameDistributionVersionStatusEntry[];
/** 版本回读补到的 `recoveryAction`;条目自带的优先。 */
recoveryActions?: Record<string, GameDistributionRecoveryAction>;
};
/**
* 版本历史:逐条列出不可变版本的私有状态、体积与驳回理由。
*
* 只读列表,撤回/删除等动作仍留在卡片与详情页的行内操作上,避免同一个动作在两处出现。
*/
export function GameVersionHistory({
id,
versions,
recoveryActions = {},
}: GameVersionHistoryProps) {
return (
<ul id={id} className="game-version-list">
{versions.map((version) => {
const reason = version.reviewReason?.trim() ?? '';
const recoveryAction =
version.recoveryAction ?? recoveryActions[version.versionId] ?? null;
const size = formatPackageSize(version.packageBytes);
const time = formatVersionTime(version.updatedAt);
return (
<li key={version.versionId} className="game-version-list__item">
<div className="game-version-list__heading">
<strong>v{version.versionNumber}</strong>
<span
className={`game-version-status game-version-status--${version.status}`}
>
{VERSION_STATUS_LABEL[version.status] ?? version.status}
</span>
</div>
{time || size ? (
<div className="game-version-list__meta">
{time ? <span>更新于 {time}</span> : null}
{size ? <span>包体积 {size}</span> : null}
</div>
) : null}
{reason ? (
<p className="game-version-list__reason">驳回理由:{reason}</p>
) : null}
{recoveryAction && recoveryAction !== 'none' ? (
<p className="game-version-list__action">
下一步:
{RECOVERY_ACTION_LABEL[recoveryAction] ?? recoveryAction}
</p>
) : null}
</li>
);
})}
</ul>
);
}
export default GameVersionHistory;
@@ -0,0 +1,337 @@
import './gameDistribution.css';
import { useEffect, useRef, useState } from 'react';
import type {
GameDistributionCategory,
GameDistributionDeviceSupport,
GameDistributionOrientation,
GameDistributionUpdateGameMetadataRequest,
} from '../../../packages/shared/src/contracts/gameDistribution';
import {
type GameDistributionMyGame,
type GameDistributionUpdateMyGameResponse,
getGameVersion,
updateMyGame,
} from '../../services/gameDistributionClient';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { UnifiedModal } from '../common/UnifiedModal';
import {
GAME_METADATA_CATEGORIES,
GAME_METADATA_ORIENTATIONS,
parseGameTagsInput,
resolveGameInputModes,
} from './gameMetadataOptions';
import { type GamePublishImageAsset } from './gamePublishAssets';
import { applyVersionMediaDefaults } from './gamePublishMediaDefaults';
import { GamePublishMediaFieldset } from './GamePublishMediaFieldset';
import { resolvePublishMetadataError } from './gamePublishMetadata';
const DEVICE_OPTIONS = [
['desktop', '桌面端'],
['mobile', '移动端'],
['touch', '触控'],
] as const;
const MISSING_COVER_ASSET_HINT =
'这次编辑需要重新选择一次封面图片,才能保留这张线上封面。';
let metadataKeySeed = 0;
function createMetadataKey(gameId: string) {
metadataKeySeed += 1;
const random =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${metadataKeySeed}`;
return `game-metadata-${gameId}-${random}`;
}
type GameWorkMetadataEditorProps = {
game: GameDistributionMyGame;
onClose: () => void;
/** 保存成功回传服务端最新投影;调用方用它刷新列表里的这一行。 */
onSaved: (response: GameDistributionUpdateMyGameResponse) => void;
};
/**
* 作品资料编辑。
*
* 只改 `game` 级展示资料(标题/简介/分类/标签/设备/封面/截图),立即对公开目录与详情
* 生效;发行包与随包冻结的资料仍走「发布新版本」。
*
* 服务端复用创建游戏的资料校验,所以封面与截图必须带素材 ID:打开时先按最新版本的
* 冻结资料回填素材(含签名预览),旧版本没存素材 ID 时要求作者重新选一次封面。
* 调用方按作品挂载本组件(key=gameId),表单初值只取一次,不需要额外的重置逻辑。
*/
export function GameWorkMetadataEditor({
game,
onClose,
onSaved,
}: GameWorkMetadataEditorProps) {
const [title, setTitle] = useState(game.title);
const [summary, setSummary] = useState(game.summary);
const [description, setDescription] = useState(game.description);
const [category, setCategory] = useState<GameDistributionCategory>(
game.category,
);
const [tagsInput, setTagsInput] = useState((game.tags ?? []).join(','));
const [deviceSupport, setDeviceSupport] =
useState<GameDistributionDeviceSupport>({
...game.deviceSupport,
});
const [orientation, setOrientation] = useState<GameDistributionOrientation>(
game.orientation ?? 'responsive',
);
const [coverAsset, setCoverAsset] = useState<GamePublishImageAsset | null>(
null,
);
const [screenshotAssets, setScreenshotAssets] = useState<
GamePublishImageAsset[]
>([]);
const [isLoadingMedia, setIsLoadingMedia] = useState(
Boolean(game.latestVersion),
);
const [isUploadingAsset, setIsUploadingAsset] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState('');
/**
* 同一次编辑(含失败重试)复用同一个幂等键:服务端按 key 重放,不会因为双击或
* 网络重试写出两份审计记录。
*/
const saveKeyRef = useRef<string | null>(null);
const latestVersionId = game.latestVersion?.versionId ?? '';
useEffect(() => {
if (!latestVersionId) {
setIsLoadingMedia(false);
return;
}
let cancelled = false;
setIsLoadingMedia(true);
getGameVersion(latestVersionId)
.then((detail) => {
if (cancelled) return;
return applyVersionMediaDefaults(detail, {
setCover: setCoverAsset,
setScreenshots: setScreenshotAssets,
isCancelled: () => cancelled,
});
})
.catch(() => {
// 读不到冻结资料只影响默认素材:作者仍可以自己选一次封面再保存。
if (cancelled) return;
setCoverAsset(null);
setScreenshotAssets([]);
})
.finally(() => {
if (!cancelled) setIsLoadingMedia(false);
});
return () => {
cancelled = true;
};
}, [latestVersionId]);
/** 封面是服务端必填项:线上封面没有素材 ID 时必须重新选一次,不能凭对象键猜身份。 */
function resolveMediaSubmitError() {
if (!coverAsset) return '作品必须提供封面图片';
if (!coverAsset.assetObjectId.trim()) return MISSING_COVER_ASSET_HINT;
return '';
}
async function handleSubmit() {
if (isSaving || isLoadingMedia) return;
const mediaError = resolveMediaSubmitError();
if (mediaError) {
setError(mediaError);
return;
}
const metadataError = resolvePublishMetadataError({
title,
summary,
deviceSupport,
coverAssetId: coverAsset?.assetObjectId,
screenshotCount: screenshotAssets.length,
});
if (metadataError) {
setError(metadataError);
return;
}
const saveKey = saveKeyRef.current ?? createMetadataKey(game.id);
saveKeyRef.current = saveKey;
const payload: GameDistributionUpdateGameMetadataRequest = {
expectedPublicationRevision: game.publicationRevision,
title: title.trim(),
summary: summary.trim(),
description: description.trim() || summary.trim(),
category,
tags: parseGameTagsInput(tagsInput),
coverAssetId: coverAsset?.assetObjectId.trim(),
screenshots: screenshotAssets.map((asset) => asset.assetObjectId.trim()),
deviceSupport,
inputModes: resolveGameInputModes(deviceSupport),
orientation,
};
setIsSaving(true);
setError('');
try {
onSaved(await updateMyGame(game.id, payload, saveKey));
} catch (saveError: unknown) {
setError(
saveError instanceof Error ? saveError.message : '保存作品资料失败',
);
} finally {
setIsSaving(false);
}
}
return (
<UnifiedModal
open
title="编辑作品资料"
description="保存后立即对公开目录与详情生效;发行包内容仍随版本冻结,不受影响。"
size="md"
closeDisabled={isSaving}
onClose={onClose}
footer={
<>
<PlatformActionButton
tone="secondary"
size="sm"
shape="pill"
disabled={isSaving}
onClick={onClose}
>
取消
</PlatformActionButton>
<PlatformActionButton
type="button"
size="sm"
shape="pill"
disabled={isSaving || isLoadingMedia || isUploadingAsset}
onClick={() => void handleSubmit()}
>
{isSaving ? '正在保存…' : '保存资料'}
</PlatformActionButton>
</>
}
>
<form
className="game-work-editor"
onSubmit={(event) => {
event.preventDefault();
void handleSubmit();
}}
>
{error ? (
<PlatformStatusMessage tone="error" surface="platform">
{error}
</PlatformStatusMessage>
) : null}
<label className="game-publish-field">
作品名称
<input
value={title}
maxLength={40}
onChange={(event) => setTitle(event.target.value)}
/>
</label>
<label className="game-publish-field">
一句话简介
<input
value={summary}
maxLength={120}
onChange={(event) => setSummary(event.target.value)}
/>
</label>
<label className="game-publish-field">
详细介绍
<textarea
value={description}
rows={4}
maxLength={2000}
onChange={(event) => setDescription(event.target.value)}
/>
</label>
<div className="game-publish-row">
<label className="game-publish-field">
分类
<select
value={category}
onChange={(event) =>
setCategory(event.target.value as GameDistributionCategory)
}
>
{GAME_METADATA_CATEGORIES.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
</label>
<label className="game-publish-field">
标签
<input
value={tagsInput}
placeholder="用逗号分隔,最多 5 个"
onChange={(event) => setTagsInput(event.target.value)}
/>
</label>
<label className="game-publish-field">
画面方向
<select
value={orientation}
onChange={(event) =>
setOrientation(
event.target.value as GameDistributionOrientation,
)
}
>
{GAME_METADATA_ORIENTATIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
</div>
<fieldset className="game-publish-fieldset">
<legend>支持设备</legend>
{DEVICE_OPTIONS.map(([key, label]) => (
<label key={key} className="game-publish-checkbox">
<input
type="checkbox"
checked={deviceSupport[key]}
onChange={(event) =>
setDeviceSupport((current) => ({
...current,
[key]: event.target.checked,
}))
}
/>
{label}
</label>
))}
<span className="game-publish-hint">
键盘与鼠标按桌面端派生;选择移动端时必须支持触控。
</span>
</fieldset>
<GamePublishMediaFieldset
cover={coverAsset}
screenshots={screenshotAssets}
disabled={isSaving}
missingCoverHint={MISSING_COVER_ASSET_HINT}
onCoverChange={setCoverAsset}
onScreenshotsChange={setScreenshotAssets}
onError={setError}
onUploadingChange={setIsUploadingAsset}
/>
{isLoadingMedia ? (
<span className="game-publish-hint">正在读取当前封面与截图…</span>
) : null}
</form>
</UnifiedModal>
);
}
export default GameWorkMetadataEditor;
@@ -1,23 +1,33 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import { fireEvent } from '@testing-library/react';
import { fireEvent, within } from '@testing-library/react';
import type { ContextType } from 'react';
import { beforeEach, expect, test, vi } from 'vitest';
import {
cancelGameVersion,
deleteMyGame,
type GameDistributionMyGame,
getGameVersion,
listMyGames,
loadVersionRecoveryActions,
submitGameVersion,
unpublishGame,
updateMyGame,
} from '../../services/gameDistributionClient';
import { AuthUiContext } from '../auth/AuthUiContext';
import { MyGamesPage } from './MyGamesPage';
vi.mock('../../services/gameDistributionClient', () => ({
cancelGameVersion: vi.fn(),
deleteMyGame: vi.fn(),
getGameVersion: vi.fn(),
listMyGames: vi.fn(),
loadVersionRecoveryActions: vi.fn(),
submitGameVersion: vi.fn(),
unpublishGame: vi.fn(),
updateMyGame: vi.fn(),
}));
type AuthValue = NonNullable<ContextType<typeof AuthUiContext>>;
@@ -71,13 +81,18 @@ function createGame(
currentVersion: null,
playCount: 3,
createdAt: '2026-09-20T08:00:00Z',
latestVersion: null,
versions: [],
...overrides,
} as GameDistributionMyGame;
}
function renderPage(
authValue: AuthValue | null,
overrides: { onPublishVersion?: (gameId: string) => void } = {},
overrides: {
onPublishVersion?: (gameId: string) => void;
onResumePublish?: () => void;
} = {},
) {
return render(
<AuthUiContext.Provider value={authValue}>
@@ -85,6 +100,7 @@ function renderPage(
onBack={vi.fn()}
onOpenDetail={vi.fn()}
onPublishVersion={overrides.onPublishVersion}
onResumePublish={overrides.onResumePublish}
/>
</AuthUiContext.Provider>,
);
@@ -92,9 +108,20 @@ function renderPage(
beforeEach(() => {
vi.mocked(cancelGameVersion).mockReset();
vi.mocked(deleteMyGame).mockReset();
vi.mocked(getGameVersion).mockReset();
vi.mocked(listMyGames).mockReset();
vi.mocked(loadVersionRecoveryActions).mockReset();
vi.mocked(submitGameVersion).mockReset();
vi.mocked(unpublishGame).mockReset();
vi.mocked(updateMyGame).mockReset();
vi.mocked(listMyGames).mockResolvedValue([]);
vi.mocked(loadVersionRecoveryActions).mockResolvedValue({});
// 默认没有冻结素材:编辑弹窗打开时按“线上封面不可复用”处理,用例按需覆盖。
vi.mocked(getGameVersion).mockResolvedValue({
game: { coverObjectKey: null },
version: { frozenMetadata: null },
} as never);
});
test('未登录时提示登录且不请求作者接口', () => {
@@ -250,3 +277,316 @@ test('作者中心提供发布新版本入口并回传同一个 gameId', async (
expect(onPublishVersion).toHaveBeenCalledWith('game-42');
});
test('按作品状态与版本状态筛选列表,并可重置筛选', async () => {
vi.mocked(listMyGames).mockResolvedValue([
createGame({ id: 'game-live', title: '已上线作品', status: 'published' }),
createGame({
id: 'game-draft',
title: '待审作品',
status: 'unpublished',
latestVersion: {
versionId: 'version-draft',
gameId: 'game-draft',
versionNumber: 1,
packageSha256: 'd'.repeat(64),
packageBytes: 512,
status: 'pending_review',
publicationRevision: 2,
reviewReason: null,
createdAt: '2026-09-20T08:00:00Z',
updatedAt: '2026-09-20T09:00:00Z',
},
}),
]);
renderPage(createAuthValue());
await screen.findByText('已上线作品');
expect(screen.getByText('待审作品')).toBeTruthy();
fireEvent.change(screen.getByLabelText('版本状态'), {
target: { value: 'review' },
});
await waitFor(() => expect(screen.queryByText('已上线作品')).toBeNull());
expect(screen.getByText('待审作品')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '重置筛选' }));
expect(await screen.findByText('已上线作品')).toBeTruthy();
fireEvent.change(screen.getByLabelText('作品状态'), {
target: { value: 'published' },
});
await waitFor(() => expect(screen.queryByText('待审作品')).toBeNull());
});
test('展开版本历史展示每个版本的私有状态与驳回理由', async () => {
vi.mocked(listMyGames).mockResolvedValue([
createGame({
id: 'game-history',
title: '多版本作品',
latestVersion: {
versionId: 'version-2',
gameId: 'game-history',
versionNumber: 2,
packageSha256: 'e'.repeat(64),
packageBytes: 2048,
status: 'rejected',
publicationRevision: 6,
reviewReason: '封面需要替换',
createdAt: '2026-09-20T08:00:00Z',
updatedAt: '2026-09-20T09:00:00Z',
},
versions: [
{
versionId: 'version-2',
gameId: 'game-history',
versionNumber: 2,
packageSha256: 'e'.repeat(64),
packageBytes: 2048,
status: 'rejected',
publicationRevision: 6,
reviewReason: '封面需要替换',
createdAt: '2026-09-20T08:00:00Z',
updatedAt: '2026-09-20T09:00:00Z',
},
{
versionId: 'version-1',
gameId: 'game-history',
versionNumber: 1,
packageSha256: 'f'.repeat(64),
packageBytes: 1024,
status: 'revoked',
publicationRevision: 5,
reviewReason: null,
createdAt: '2026-09-19T08:00:00Z',
updatedAt: '2026-09-19T09:00:00Z',
},
],
}),
]);
renderPage(createAuthValue());
await screen.findByText('多版本作品');
expect(screen.queryByText('包体积 2.0 KB')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /版本历史(2)/u }));
expect(await screen.findByText('包体积 2.0 KB')).toBeTruthy();
expect(screen.getByText('包体积 1.0 KB')).toBeTruthy();
expect(screen.getByText('v2')).toBeTruthy();
expect(screen.getByText('v1')).toBeTruthy();
});
test('服务端给出 submit 动作时行内直接提交审核', async () => {
vi.mocked(listMyGames).mockResolvedValue([
createGame({
id: 'game-submit',
status: 'unpublished',
publicationRevision: 8,
latestVersion: {
versionId: 'version-8',
gameId: 'game-submit',
versionNumber: 3,
packageSha256: 'a'.repeat(64),
packageBytes: 4096,
status: 'uploaded',
publicationRevision: 8,
reviewReason: null,
createdAt: '2026-09-20T08:00:00Z',
updatedAt: '2026-09-20T09:00:00Z',
},
}),
]);
vi.mocked(loadVersionRecoveryActions).mockResolvedValue({
'version-8': 'submit',
});
vi.mocked(submitGameVersion).mockResolvedValue({});
renderPage(createAuthValue());
expect(await screen.findByText('下一步:提交审核')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '提交审核' }));
await waitFor(() => expect(submitGameVersion).toHaveBeenCalledTimes(1));
const [versionId, revision, idempotencyKey] =
vi.mocked(submitGameVersion).mock.calls[0] ?? [];
expect(versionId).toBe('version-8');
expect(revision).toBe(8);
expect(String(idempotencyKey)).toContain('version-8');
});
test('本地草稿对应的未完成版本提供继续上传入口', async () => {
window.localStorage.setItem(
'genarrative.game-distribution.publish-draft.v1',
JSON.stringify({
ownerUserId: 'user-1',
gameId: 'game-draft',
versionId: 'version-draft',
versionNumber: 2,
title: '待续发作品',
updatedAt: '2026-09-20T09:00:00Z',
}),
);
vi.mocked(listMyGames).mockResolvedValue([
createGame({
id: 'game-draft',
title: '待续发作品',
latestVersion: {
versionId: 'version-draft',
gameId: 'game-draft',
versionNumber: 2,
packageSha256: 'b'.repeat(64),
packageBytes: 1024,
status: 'awaiting_upload',
publicationRevision: 3,
reviewReason: null,
createdAt: '2026-09-20T08:00:00Z',
updatedAt: '2026-09-20T09:00:00Z',
},
}),
]);
vi.mocked(loadVersionRecoveryActions).mockResolvedValue({
'version-draft': 'upload',
});
const onResumePublish = vi.fn();
renderPage(createAuthValue(), { onResumePublish });
fireEvent.click(await screen.findByRole('button', { name: '继续上传' }));
expect(onResumePublish).toHaveBeenCalledTimes(1);
window.localStorage.clear();
});
test('编辑资料走 PATCH owner 路由并带上当前修订号与封面素材', async () => {
vi.mocked(listMyGames).mockResolvedValue([
createGame({
id: 'game-edit',
title: '旧标题',
status: 'published',
publicationRevision: 11,
latestVersion: {
versionId: 'version-edit',
gameId: 'game-edit',
versionNumber: 1,
packageSha256: 'b'.repeat(64),
packageBytes: 1024,
status: 'published',
publicationRevision: 11,
reviewReason: null,
createdAt: '2026-09-20T08:00:00Z',
updatedAt: '2026-09-20T09:00:00Z',
},
}),
]);
// 服务端只接受素材 ID,编辑前必须按最新版本的冻结资料回填封面。
vi.mocked(getGameVersion).mockResolvedValue({
game: { title: '旧标题', coverObjectKey: null },
version: {
versionId: 'version-edit',
publicationRevision: 11,
recoveryAction: 'none',
frozenMetadata: { coverAssetId: 'asset-cover-old' },
},
} as never);
vi.mocked(updateMyGame).mockResolvedValue({
game: createGame({
id: 'game-edit',
title: '新标题',
status: 'published',
publicationRevision: 12,
}),
replayed: false,
});
renderPage(createAuthValue());
fireEvent.click(await screen.findByRole('button', { name: '编辑资料' }));
const dialog = await screen.findByRole('dialog', { name: '编辑作品资料' });
await waitFor(() =>
expect(getGameVersion).toHaveBeenCalledWith('version-edit'),
);
// 封面素材按冻结资料回填完成后保存按钮才可用。
await waitFor(() =>
expect(within(dialog).queryByText('正在读取当前封面与截图…')).toBeNull(),
);
fireEvent.change(within(dialog).getByLabelText('作品名称'), {
target: { value: '新标题' },
});
fireEvent.click(within(dialog).getByRole('button', { name: '保存资料' }));
await waitFor(() => expect(updateMyGame).toHaveBeenCalledTimes(1));
const [gameId, payload, idempotencyKey] =
vi.mocked(updateMyGame).mock.calls[0] ?? [];
expect(gameId).toBe('game-edit');
expect(payload?.expectedPublicationRevision).toBe(11);
expect(payload?.title).toBe('新标题');
expect(payload?.coverAssetId).toBe('asset-cover-old');
expect(String(idempotencyKey)).toContain('game-edit');
expect(await screen.findByText('新标题')).toBeTruthy();
});
test('线上封面不可复用时阻止保存并要求重新选图', async () => {
vi.mocked(listMyGames).mockResolvedValue([
createGame({
id: 'game-legacy',
title: '旧作品',
status: 'unpublished',
latestVersion: {
versionId: 'version-legacy',
gameId: 'game-legacy',
versionNumber: 1,
packageSha256: 'c'.repeat(64),
packageBytes: 1024,
status: 'rejected',
publicationRevision: 2,
reviewReason: null,
createdAt: '2026-09-20T08:00:00Z',
updatedAt: '2026-09-20T09:00:00Z',
},
}),
]);
renderPage(createAuthValue());
fireEvent.click(await screen.findByRole('button', { name: '编辑资料' }));
const dialog = await screen.findByRole('dialog', { name: '编辑作品资料' });
// 默认冻结资料为空:没有可复用的封面素材,服务端会拒收,本地先拦一次。
await waitFor(() =>
expect(within(dialog).queryByText('正在读取当前封面与截图…')).toBeNull(),
);
fireEvent.click(within(dialog).getByRole('button', { name: '保存资料' }));
expect(await within(dialog).findByText('作品必须提供封面图片')).toBeTruthy();
expect(updateMyGame).not.toHaveBeenCalled();
});
test('删除作品需二次确认并带上当前修订号', async () => {
vi.mocked(listMyGames).mockResolvedValue([
createGame({
id: 'game-delete',
title: '待删除作品',
status: 'unpublished',
publicationRevision: 9,
}),
]);
vi.mocked(deleteMyGame).mockResolvedValue({ replayed: false });
renderPage(createAuthValue());
fireEvent.click(await screen.findByRole('button', { name: '删除作品' }));
const dialog = await screen.findByRole('dialog', { name: '删除作品' });
expect(deleteMyGame).not.toHaveBeenCalled();
fireEvent.click(within(dialog).getByRole('button', { name: '删除作品' }));
await waitFor(() => expect(deleteMyGame).toHaveBeenCalledTimes(1));
const [gameId, revision, idempotencyKey] =
vi.mocked(deleteMyGame).mock.calls[0] ?? [];
expect(gameId).toBe('game-delete');
expect(revision).toBe(9);
expect(String(idempotencyKey)).toContain('game-delete');
await waitFor(() => expect(listMyGames).toHaveBeenCalledTimes(2));
});
File diff suppressed because it is too large Load Diff
@@ -841,6 +841,7 @@
}
.my-game-card__actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: auto;
padding-top: 0.35rem;
@@ -871,6 +872,135 @@
}
}
/* 作品管理:筛选、版本历史、下一步提示与作者状态面板。 */
.my-game-filters {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 0.75rem;
margin-bottom: 0.9rem;
}
.my-game-filters .game-publish-field {
flex: 1 1 9rem;
max-width: 15rem;
}
.my-game-card__next-step {
margin: 0;
color: var(--platform-text-soft);
font-size: 0.72rem;
font-weight: 700;
}
.my-game-card__history-toggle {
align-self: flex-start;
border: 0;
padding: 0;
background: transparent;
color: var(--platform-accent-strong);
font: inherit;
font-size: 0.72rem;
font-weight: 700;
cursor: pointer;
}
.my-game-card__history-toggle:hover {
text-decoration: underline;
}
.game-version-list {
display: grid;
gap: 0.4rem;
margin: 0.2rem 0 0;
padding: 0;
list-style: none;
}
.game-version-list__item {
display: grid;
gap: 0.2rem;
border: 1px solid var(--platform-surface-border);
border-radius: 0.8rem;
padding: 0.5rem 0.7rem;
background: var(--platform-surface-fill);
min-width: 0;
}
.game-version-list__heading {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
font-size: 0.78rem;
}
.game-version-list__meta {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
color: var(--platform-text-soft);
font-size: 0.68rem;
}
.game-version-list__reason {
margin: 0;
color: var(--platform-button-danger-text);
font-size: 0.7rem;
}
.game-version-list__action {
margin: 0;
color: var(--platform-text-soft);
font-size: 0.7rem;
font-weight: 600;
}
.game-version-status {
border-radius: 999px;
padding: 0.15rem 0.5rem;
background: var(--platform-surface-fill);
color: var(--platform-text-soft);
font-size: 0.64rem;
font-weight: 800;
}
.game-version-status--published {
background: var(--platform-success-bg);
color: var(--platform-success-text);
}
.game-version-status--rejected,
.game-version-status--upload_failed,
.game-version-status--validation_failed {
background: var(--platform-button-danger-fill);
color: var(--platform-button-danger-text);
}
.game-owner-panel {
display: grid;
gap: 0.5rem;
margin-top: 1rem;
border: 1px solid var(--platform-surface-border);
border-radius: 1.2rem;
padding: 0.9rem 1rem;
background: var(--platform-panel-fill);
}
.game-owner-panel__heading {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
}
.game-owner-panel__heading h2 {
margin: 0;
font-size: 1rem;
letter-spacing: -0.02em;
}
.game-owner-panel__meta {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
color: var(--platform-text-soft);
font-size: 0.72rem;
}
.game-owner-panel__reason {
margin: 0;
color: var(--platform-button-danger-text);
font-size: 0.72rem;
}
.game-work-editor {
display: grid;
gap: 0.85rem;
}
.game-publish-page {
max-width: 54rem;
}
@@ -1078,6 +1208,9 @@
.game-publish-row {
grid-template-columns: 1fr;
}
.my-game-filters .game-publish-field {
max-width: none;
}
.game-publish-media__preview {
width: 100%;
}
@@ -0,0 +1,55 @@
import {
GAME_DISTRIBUTION_CATEGORIES,
type GameDistributionCategory,
type GameDistributionDeviceSupport,
type GameDistributionInputMode,
type GameDistributionOrientation,
} from '../../../packages/shared/src/contracts/gameDistribution';
/**
* 作品资料的展示选项与派生逻辑。
*
* 发布页与「作品资料编辑」共用同一份口径,避免同一组分类/方向在两条链路上漂移;
* 服务端仍会各自校验,这里只是客户端的一致性来源。
*/
export const GAME_METADATA_CATEGORIES: readonly GameDistributionCategory[] =
GAME_DISTRIBUTION_CATEGORIES;
export const GAME_METADATA_ORIENTATIONS: readonly {
value: GameDistributionOrientation;
label: string;
}[] = [
{ value: 'responsive', label: '自适应' },
{ value: 'landscape', label: '横屏' },
{ value: 'portrait', label: '竖屏' },
];
export const GAME_METADATA_INPUT_MODE_LABELS: Record<
GameDistributionInputMode,
string
> = {
keyboard: '键盘',
mouse: '鼠标',
touch: '触屏',
};
/** 标签输入框口径:中英文逗号分隔、去空白、最多 5 个,与服务端上限一致。 */
export function parseGameTagsInput(value: string): string[] {
return value
.split(/[,,]/u)
.map((tag) => tag.trim())
.filter(Boolean)
.slice(0, 5);
}
/**
* 输入方式由设备支持派生(与创建游戏时同一口径):桌面端给键盘与鼠标,
* 支持触控再补触屏,避免作者在两处填同一件事。
*/
export function resolveGameInputModes(
deviceSupport: GameDistributionDeviceSupport,
): GameDistributionInputMode[] {
const inputModes: GameDistributionInputMode[] = ['keyboard', 'mouse'];
if (deviceSupport.touch) inputModes.push('touch');
return inputModes;
}
@@ -0,0 +1,80 @@
import type { GameDistributionVersionDetail } from '../../../packages/shared/src/contracts/gameDistribution';
import {
type GamePublishImageAsset,
resolveGamePublishImagePreview,
} from './gamePublishAssets';
type VersionMediaApplyTarget = {
setCover: (
updater: (
current: GamePublishImageAsset | null,
) => GamePublishImageAsset | null,
) => void;
setScreenshots: (
updater: (current: GamePublishImageAsset[]) => GamePublishImageAsset[],
) => void;
isCancelled: () => boolean;
};
/**
* 用版本冻结资料回填封面与截图。
*
* 快照缺失(或旧版本没存素材 ID)时只回填线上封面预览:此时作者必须重新选择一次封面,
* 因为服务端只接受素材 ID 作为冻结/保存输入,前端不能凭对象键猜素材身份。
*/
export async function applyVersionMediaDefaults(
detail: GameDistributionVersionDetail,
apply: VersionMediaApplyTarget,
) {
const frozen = detail.version.frozenMetadata ?? null;
const coverAssetId = frozen?.coverAssetId?.trim() ?? '';
const coverObjectKey =
frozen?.coverObjectKey?.trim() ?? detail.game.coverObjectKey?.trim() ?? '';
const coverName = detail.game.title.trim() || '当前封面';
const buildCover = (src: string): GamePublishImageAsset => ({
assetObjectId: coverAssetId,
objectKey: coverObjectKey,
src,
name: coverName,
});
const screenshots = (frozen?.screenshots ?? []).filter((screenshot) =>
Boolean(screenshot?.assetId?.trim()),
);
if (apply.isCancelled()) return;
apply.setCover(() =>
coverAssetId || coverObjectKey ? buildCover('') : null,
);
apply.setScreenshots(() =>
screenshots.map((screenshot, index) => ({
assetObjectId: screenshot.assetId.trim(),
objectKey: screenshot.objectKey?.trim() ?? '',
src: '',
name: `截图 ${index + 1}`,
})),
);
const coverPreview = await resolveGamePublishImagePreview(coverObjectKey);
if (apply.isCancelled()) return;
if (coverPreview) {
// 只在作者还没换图、且预览仍为空时补上签名地址,避免覆盖作者的新选择。
apply.setCover((current) =>
current && !current.src && current.objectKey === coverObjectKey
? { ...current, src: coverPreview }
: current,
);
}
for (const [index, screenshot] of screenshots.entries()) {
const preview = await resolveGamePublishImagePreview(
screenshot.objectKey ?? '',
);
if (apply.isCancelled() || !preview) continue;
apply.setScreenshots((current) =>
current.map((item, itemIndex) =>
itemIndex === index && item.assetObjectId === screenshot.assetId
? { ...item, src: preview }
: item,
),
);
}
}
@@ -0,0 +1,60 @@
import type {
GameDistributionRecoveryAction,
GameDistributionVersionStatus,
} from '../../../packages/shared/src/contracts/gameDistribution';
/** 作品公开状态文案;`suspended` 由管理员安全下架产生,作者侧只读展示。 */
export const GAME_STATUS_LABEL: Record<string, string> = {
unpublished: '未公开',
published: '已公开',
suspended: '已被封禁',
};
export const VERSION_STATUS_LABEL: Record<string, string> = {
awaiting_upload: '待上传',
uploaded: '已上传',
validating: '校验中',
pending_review: '审核中',
published: '已发布',
upload_failed: '上传失败',
validation_failed: '校验失败',
rejected: '已拒绝',
cancelled: '已撤回',
revoked: '已撤回',
};
/** 服务端 `recoveryAction` 的中文说明;前端只翻译,不推断状态。 */
export const RECOVERY_ACTION_LABEL: Record<
GameDistributionRecoveryAction,
string
> = {
upload: '继续上传发行包',
submit: '提交审核',
wait: '等待审核结果',
none: '无需操作',
reupload: '重新上传发行包',
fix_package: '修正发行包后重新上传',
fix_metadata: '修改作品资料后重新提交',
};
/**
* 还需要作者动作的版本状态。
*
* 已发布 / 已撤回 / 已取消的版本没有下一步,作者列表不为它们多回读一次版本详情;
* 只做请求调度用,不参与任何状态文案或动作推断。
*/
export const VERSION_STATUS_NEEDS_AUTHOR_ACTION: Record<
GameDistributionVersionStatus,
boolean
> = {
awaiting_upload: true,
uploaded: true,
validating: true,
pending_review: true,
upload_failed: true,
validation_failed: true,
rejected: true,
published: false,
cancelled: false,
revoked: false,
};
@@ -48,12 +48,16 @@ const gameDistributionMock = vi.hoisted(() => ({
cancelGameVersion: vi.fn(),
createGame: vi.fn(),
createGameVersion: vi.fn(),
deleteMyGame: vi.fn(),
getGame: vi.fn(),
getGameVersion: vi.fn(),
getMyGame: vi.fn(),
listGames: vi.fn(),
listMyGames: vi.fn(),
loadVersionRecoveryActions: vi.fn(),
submitGameVersion: vi.fn(),
unpublishGame: vi.fn(),
updateMyGame: vi.fn(),
uploadGamePackage: vi.fn(),
}));
@@ -1012,3 +1016,159 @@ describe('游戏详情返回', () => {
expect(window.location.pathname).toBe('/games');
});
});
describe('作品管理 owner 作用域', () => {
beforeEach(() => {
window.history.replaceState(null, '', '/creation');
authUiMock.value.user = null;
authUiMock.value.canAccessProtectedData = false;
for (const mock of Object.values(gameDistributionMock)) {
mock.mockReset();
}
frontendRuntimeConfigMock.loadFrontendRuntimeConfig.mockReset();
frontendRuntimeConfigMock.loadFrontendRuntimeConfig.mockResolvedValue({
imageEditorAgentSidebarEnabled: false,
agcTemplateLibraryEnabled: false,
gameDistributionPublishEnabled: true,
});
});
const ownerEntry = {
id: 'game-1',
title: '未公开作品',
summary: '还在审核',
description: '作者自有游戏',
category: '动作',
tags: ['塔防'],
coverColor: '#d77a51',
icon: '✦',
author: { id: 'user-1', name: '测试作者' },
deviceSupport: { desktop: true, mobile: true, touch: true },
status: 'unpublished',
publicationRevision: 4,
currentVersion: null,
playCount: 0,
createdAt: '2026-09-20T08:00:00Z',
latestVersion: {
versionId: 'gamever-1',
gameId: 'game-1',
versionNumber: 1,
packageSha256: 'a'.repeat(64),
packageBytes: 1024,
status: 'pending_review',
publicationRevision: 4,
reviewReason: '封面需要替换',
createdAt: '2026-09-20T08:00:00Z',
updatedAt: '2026-09-20T09:00:00Z',
},
versions: [
{
versionId: 'gamever-1',
gameId: 'game-1',
versionNumber: 1,
packageSha256: 'a'.repeat(64),
packageBytes: 1024,
status: 'pending_review',
publicationRevision: 4,
reviewReason: '封面需要替换',
createdAt: '2026-09-20T08:00:00Z',
updatedAt: '2026-09-20T09:00:00Z',
},
],
};
function loginAsAuthor() {
authUiMock.value.user = {
id: 'user-1',
publicUserCode: '100001',
displayName: '测试作者',
avatarUrl: null,
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
};
authUiMock.value.canAccessProtectedData = true;
}
it('从我的作品进入详情时把 owner 作用域编码进地址', async () => {
loginAsAuthor();
gameDistributionMock.listMyGames.mockResolvedValue([ownerEntry]);
gameDistributionMock.loadVersionRecoveryActions.mockResolvedValue({
'gamever-1': 'wait',
});
const setSelectionStage = vi.fn();
render(
<PlatformEntryFlowShellImpl
selectionStage="game-mine"
setSelectionStage={setSelectionStage}
/>,
);
fireEvent.click(await screen.findByRole('button', { name: '查看详情' }));
expect(setSelectionStage).toHaveBeenCalledWith('game-detail', {
path: '/games/detail?id=game-1&owner=1',
});
});
it('带 owner 的详情深链读 owner 路由而不是公开详情', async () => {
loginAsAuthor();
gameDistributionMock.getMyGame.mockResolvedValue(ownerEntry);
gameDistributionMock.loadVersionRecoveryActions.mockResolvedValue({
'gamever-1': 'wait',
});
gameDistributionMock.getGame.mockResolvedValue(null);
window.history.replaceState(null, '', '/games/detail?id=game-1&owner=1');
render(
<PlatformEntryFlowShellImpl
selectionStage="game-detail"
setSelectionStage={vi.fn()}
/>,
);
expect(await screen.findByText('作品状态')).toBeTruthy();
expect(gameDistributionMock.getMyGame).toHaveBeenCalledWith('game-1');
expect(gameDistributionMock.getGame).not.toHaveBeenCalled();
});
it('没有 owner 的详情仍走公开详情', async () => {
gameDistributionMock.getMyGame.mockResolvedValue(ownerEntry);
gameDistributionMock.getGame.mockResolvedValue({
id: 'game-1',
title: '公开作品',
summary: '公开详情',
description: '公开详情',
category: '动作',
tags: [],
coverColor: '#d77a51',
icon: '✦',
author: { id: 'author-1', name: '测试作者' },
deviceSupport: { desktop: true, mobile: true, touch: true },
status: 'published',
currentVersion: {
id: 'version-1',
version: '1.0.0',
entryUrl: 'https://play.example.test/releases/version-1/index.html',
sha256: 'sha256',
publishedAt: '2026-09-18T00:00:00.000Z',
controls: ['键盘'],
},
playCount: 1,
createdAt: '2026-09-18T00:00:00.000Z',
});
window.history.replaceState(null, '', '/games/detail?id=game-1');
render(
<PlatformEntryFlowShellImpl
selectionStage="game-detail"
setSelectionStage={vi.fn()}
/>,
);
expect(await screen.findByText('公开作品')).toBeTruthy();
expect(gameDistributionMock.getMyGame).not.toHaveBeenCalled();
});
});
@@ -443,8 +443,12 @@ export function PlatformEntryFlowShellImpl({
}, [setSelectionStage]);
const openGameDetail = useCallback(
(gameId: string) => {
const path = `/games/detail?id=${encodeURIComponent(gameId)}`;
(gameId: string, options?: { owner?: boolean }) => {
const params = new URLSearchParams({ id: gameId });
// 作者视角要一直带着 owner 作用域:刷新或直接打开该链接时详情页仍读
// `GET /my-games/{gameId}`,否则作者点自己的未公开作品只会拿到公开接口的 404。
if (options?.owner) params.set('owner', '1');
const path = `/games/detail?${params.toString()}`;
setSelectionStage('game-detail', { path });
},
[setSelectionStage],
@@ -804,16 +808,23 @@ export function PlatformEntryFlowShellImpl({
) : selectionStage === 'game-mine' ? (
<MyGamesPage
onBack={openGames}
onOpenDetail={openGameDetail}
onOpenDetail={(gameId) =>
openGameDetail(gameId, { owner: true })
}
onPublishVersion={
gamePublishGateAllowed
? openGamePublishVersion
: undefined
}
onResumePublish={
gamePublishGateAllowed ? openGamePublish : undefined
}
publishEnabled={gamePublishGateAllowed}
/>
) : selectionStage === 'game-detail' ? (
<GameDetailPage
gameId={gameId}
ownerView={gameSearchParams.get('owner') === '1'}
onBack={backFromGameDetail}
onPlay={openGamePlay}
/>
+141 -2
View File
@@ -6,6 +6,8 @@ import type {
GameDistributionCreateVersionRequest,
GameDistributionDeviceSupport,
GameDistributionGame,
GameDistributionRecoveryAction,
GameDistributionUpdateGameMetadataRequest,
GameDistributionVersionDetail,
GameDistributionVersionStatus as GameDistributionVersionStatusValue,
GameDistributionVersionSummary,
@@ -119,8 +121,40 @@ export type GameDistributionVersionStatusEntry = {
reviewReason?: string | null;
createdAt: string;
updatedAt: string;
/**
* 版本私有的发行入口;只有已公开版本有值,作者详情用它判定当前是否能在线游玩。
*/
entryUrl?: string | null;
/**
* 服务端派生的下一步动作。作者列表投影目前不带这一项(只有版本回读带),
* 缺失时前端只展示状态、不自行推断动作。
*/
recoveryAction?: GameDistributionRecoveryAction | null;
};
/**
* 作者自有游戏条目一定是「公开投影 + 私有版本状态」的形状;服务端缺字段时按空值兜底,
* 避免页面对半成品响应直接崩掉。
*/
function normalizeOwnerGameEntry(
value: unknown,
): GameDistributionMyGame | null {
if (!value || typeof value !== 'object') return null;
const entry = value as Partial<GameDistributionMyGame>;
if (!entry.id) return null;
const versions = Array.isArray(entry.versions)
? entry.versions.filter(
(item): item is GameDistributionVersionStatusEntry =>
Boolean(item && typeof item === 'object' && item.versionId),
)
: [];
return {
...(entry as GameDistributionMyGame),
latestVersion: entry.latestVersion ?? null,
versions,
};
}
export async function listMyGames() {
const payload = await requestJson<{ games?: unknown[] }>(
'/my-games',
@@ -128,9 +162,114 @@ export async function listMyGames() {
'读取我的游戏失败',
);
const games = Array.isArray(payload?.games) ? payload.games : [];
return games.filter((item): item is GameDistributionMyGame =>
Boolean(item && typeof item === 'object' && (item as { id?: string }).id),
return games
.map(normalizeOwnerGameEntry)
.filter((game): game is GameDistributionMyGame => game !== null);
}
/**
* 作者读取自己名下单个作品:审核中 / 被驳回 / 已下架 / 已撤回的作品都能打开,
* 公开详情只服务已公开投影(作者点自己的未公开作品会拿到 404)。
*/
export async function getMyGame(
gameId: string,
): Promise<GameDistributionMyGame | null> {
const normalizedGameId = gameId.trim();
if (!normalizedGameId) return null;
const payload = await requestJson<{ game?: unknown }>(
`/my-games/${encodeURIComponent(normalizedGameId)}`,
{ method: 'GET' },
'读取作品详情失败',
);
return normalizeOwnerGameEntry(payload?.game);
}
/**
* 保存作品资料的成功响应:`game` 是公开投影形状(不含版本列表),
* `replayed` 表示服务端按同一个 Idempotency-Key 重放了这次写入。
*/
export type GameDistributionUpdateMyGameResponse = {
game: GameDistributionGame;
replayed: boolean;
};
/**
* 保存作品展示资料;包内容不在这里改动,仍随版本冻结。
*
* 服务端复用创建游戏的资料校验(含"必须有封面、截图必须是本人素材"),
* 因此编辑也必须带上当前封面/截图的素材 ID,否则资料不完整或会把线上截图清空。
*/
export async function updateMyGame(
gameId: string,
payload: GameDistributionUpdateGameMetadataRequest,
idempotencyKey: string,
) {
const normalizedGameId = gameId.trim();
const normalizedKey = idempotencyKey.trim();
if (!normalizedGameId) throw new Error('缺少作品编号');
if (!normalizedKey) throw new Error('保存作品资料缺少幂等键');
return requestJson<GameDistributionUpdateMyGameResponse>(
`/my-games/${encodeURIComponent(normalizedGameId)}`,
{
method: 'PATCH',
headers: { 'Idempotency-Key': normalizedKey },
body: JSON.stringify(payload),
},
'保存作品资料失败',
);
}
/**
* 软删除作品:成功后该作品从作者列表与公开投影里消失,历史版本包留在回收窗口内。
*
* 与其它写动作一致要带 `Idempotency-Key`;CAS 修订号走查询串(删除本身没有请求体),
* 并发下架/审核后过期由服务端拒绝。
*/
export async function deleteMyGame(
gameId: string,
expectedPublicationRevision: number,
idempotencyKey: string,
) {
const normalizedGameId = gameId.trim();
const normalizedKey = idempotencyKey.trim();
if (!normalizedGameId) throw new Error('缺少作品编号');
if (!normalizedKey) throw new Error('删除作品缺少幂等键');
const params = new URLSearchParams({
expectedPublicationRevision: String(expectedPublicationRevision),
});
return requestJson<{ game?: GameDistributionGame; replayed?: boolean }>(
`/my-games/${encodeURIComponent(normalizedGameId)}?${params.toString()}`,
{
method: 'DELETE',
headers: { 'Idempotency-Key': normalizedKey },
},
'删除作品失败',
);
}
/**
* 补读版本回读里的 `recoveryAction`。
*
* 作者列表投影不带这一步动作,只为「还需要作者动作」的最新版本各回读一次;单个版本
* 回读失败只丢失该版本的下一步提示,不影响列表与其它作品。
*/
export async function loadVersionRecoveryActions(
versionIds: readonly string[],
): Promise<Record<string, GameDistributionRecoveryAction>> {
const uniqueVersionIds = [
...new Set(versionIds.map((versionId) => versionId.trim()).filter(Boolean)),
];
const details = await Promise.allSettled(
uniqueVersionIds.map((versionId) => getGameVersion(versionId)),
);
const actions: Record<string, GameDistributionRecoveryAction> = {};
uniqueVersionIds.forEach((versionId, index) => {
const detail = details[index];
if (detail?.status === 'fulfilled') {
actions[versionId] = detail.value.version.recoveryAction;
}
});
return actions;
}
export async function unpublishGame(