Merge remote-tracking branch 'origin/master' into feat/pricing-plan
Project CI / Backend tests (pull_request) Failing after 48s
Project CI / Native shell tests (pull_request) Failing after 3m50s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 5m21s
Project CI / Frontend tests (pull_request) Failing after 47s
Project CI / AI game creator shell web tests (pull_request) Failing after 39s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m6s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m23s
Project CI / Repository checks (pull_request) Successful in 6m31s

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
2026-10-05 10:50:30 +08:00
165 changed files with 13827 additions and 1670 deletions
File diff suppressed because it is too large Load Diff
@@ -11,27 +11,42 @@ import {
} from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { GameDistributionGame } from '../../services/gameDistributionClient';
import type { GameDistributionVersionStatus } from '../../../packages/shared/src/contracts/gameDistribution';
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';
import { resolveNonPublicCoverLabel } from './gameVersionHistoryModel';
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 recordGamePlayMock = vi.hoisted(() => vi.fn());
const resolvedAssetUrlMock = vi.hoisted(() => vi.fn());
const listGameReviewsMock = vi.hoisted(() => vi.fn());
const getMyGameReviewMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/gameDistributionClient', () => ({
listGames: listGamesMock,
getGame: getGameMock,
getMyGame: getMyGameMock,
loadVersionRecoveryActions: loadVersionRecoveryActionsMock,
recordGamePlay: recordGamePlayMock,
listGameReviews: vi.fn().mockResolvedValue({
reviews: [],
page: 1,
pageSize: 20,
total: 0,
totalPages: 0,
ratingSummary: { averageScore: null, ratingCount: 0 },
listGameReviews: listGameReviewsMock,
getMyGameReview: getMyGameReviewMock,
}));
// 作者视角详情页要按登录身份渲染评价区,这里固定一个可访问受保护数据的用户。
vi.mock('../auth/AuthUiContext', () => ({
useAuthUi: () => ({
user: { id: 'author-1' },
canAccessProtectedData: true,
openLoginModal: vi.fn(),
}),
}));
@@ -116,6 +131,17 @@ beforeEach(() => {
setViewportWidth(1024);
listGamesMock.mockResolvedValue([]);
getGameMock.mockResolvedValue(createGame());
getMyGameMock.mockResolvedValue(null);
loadVersionRecoveryActionsMock.mockResolvedValue({});
listGameReviewsMock.mockResolvedValue({
reviews: [],
page: 1,
pageSize: 20,
total: 0,
totalPages: 0,
ratingSummary: { averageScore: null, ratingCount: 0 },
});
getMyGameReviewMock.mockResolvedValue({ review: null });
recordGamePlayMock.mockResolvedValue(undefined);
resolvedAssetUrlMock.mockImplementation((objectKey: string) =>
objectKey ? `https://assets.example.test/${objectKey}` : '',
@@ -222,6 +248,209 @@ 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());
render(
<GameDetailPage
gameId="game-1"
onBack={vi.fn()}
onPlay={vi.fn()}
ownerView
/>,
);
await screen.findByText('作品状态');
// 封面位按最新版本状态给出「审核中」,不再只剩手柄占位图。
const cover = document.querySelector('.game-detail-cover') as HTMLElement;
expect(within(cover).getByText('审核中')).toBeTruthy();
// 两栏评价都是空态:没有错误块、没有重试、没有评分加载失败。
expect(screen.getAllByText('暂无评价')).toHaveLength(2);
expect(screen.queryByText('评分加载失败')).toBeNull();
expect(screen.queryByText('评分加载中…')).toBeNull();
expect(screen.queryByRole('button', { name: '重试' })).toBeNull();
expect(screen.queryByRole('textbox')).toBeNull();
// 代码级门禁:非公开作品一个评价/评分请求都不发。
expect(listGameReviewsMock).not.toHaveBeenCalled();
expect(getMyGameReviewMock).not.toHaveBeenCalled();
});
it('还没有提交过版本的非公开作品封面显示未公开', async () => {
getMyGameMock.mockResolvedValue(
createOwnerGame({ latestVersion: null, versions: [] }),
);
render(
<GameDetailPage
gameId="game-1"
onBack={vi.fn()}
onPlay={vi.fn()}
ownerView
/>,
);
await screen.findByText('还没有提交过版本');
expect(
within(
document.querySelector('.game-detail-cover') as HTMLElement,
).getByText('未公开'),
).toBeTruthy();
expect(screen.queryByText('评分加载失败')).toBeNull();
expect(listGameReviewsMock).not.toHaveBeenCalled();
expect(getMyGameReviewMock).not.toHaveBeenCalled();
});
it.each<[string, GameDistributionVersionStatus | null, string]>([
['unpublished', null, '未公开'],
['unpublished', 'awaiting_upload', '未公开'],
['unpublished', 'pending_review', '审核中'],
['unpublished', 'rejected', '审核未通过'],
['suspended', 'pending_review', '审核中'],
['suspended', null, '已被封禁'],
])('封面文案 %s + %s -> %s', (gameStatus, versionStatus, expected) => {
expect(resolveNonPublicCoverLabel(gameStatus, versionStatus)).toBe(
expected,
);
});
it('审核未通过的作品封面用更准确的驳回文案', async () => {
const rejected: GameDistributionVersionStatusEntry = {
...reviewVersion,
status: 'rejected',
};
getMyGameMock.mockResolvedValue(
createOwnerGame({ latestVersion: rejected, versions: [rejected] }),
);
render(
<GameDetailPage
gameId="game-1"
onBack={vi.fn()}
onPlay={vi.fn()}
ownerView
/>,
);
await screen.findByText('作品状态');
expect(
within(
document.querySelector('.game-detail-cover') as HTMLElement,
).getByText('审核未通过'),
).toBeTruthy();
expect(screen.queryByText('评分加载失败')).toBeNull();
});
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();
// 已公开作品照旧请求评价与评分,门禁只作用于非公开作品。
await waitFor(() =>
expect(listGameReviewsMock).toHaveBeenCalledWith('game-1', 1),
);
expect(getMyGameReviewMock).toHaveBeenCalledWith('game-1');
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()} />);
@@ -567,9 +796,7 @@ describe('游戏封面与截图展示', () => {
await screen.findByRole('heading', { name: '星轨防线' });
const heroImage = () =>
document
.querySelector('.game-detail-showcase img.game-cover__image')
?.getAttribute('src');
document.querySelector('.game-detail-cover img')?.getAttribute('src');
expect(heroImage()).toBe(
'https://assets.example.test/game-distribution/cover/alpha.png',
);
@@ -613,7 +840,9 @@ describe('游戏封面与截图展示', () => {
);
await screen.findByRole('heading', { name: '星轨防线' });
expect(document.querySelectorAll('img.game-cover__image')).toHaveLength(0);
expect(document.querySelectorAll('.game-cover__icon')).toHaveLength(1);
expect(document.querySelectorAll('.game-detail-cover img')).toHaveLength(0);
expect(document.querySelectorAll('.game-detail-cover__icon')).toHaveLength(
1,
);
});
});
@@ -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
@@ -84,9 +84,16 @@ function deferred<T>() {
});
return { promise, resolve, reject };
}
function Harness() {
const state = useGameReviews('game-1', true);
return <GameReviews state={state} canReview onLogin={mocks.login} />;
function Harness({ publiclyVisible = true }: { publiclyVisible?: boolean }) {
const state = useGameReviews('game-1', true, publiclyVisible);
return (
<GameReviews
state={state}
canReview
onLogin={mocks.login}
publiclyVisible={publiclyVisible}
/>
);
}
async function form() {
return screen.findByRole('textbox', { name: '评论(可选)' });
@@ -280,6 +287,21 @@ describe('游戏评价', () => {
await screen.findByText('暂无评分');
});
it('非公开作品不请求评价与评分接口,两栏直接是暂无评价', async () => {
render(<Harness publiclyVisible={false} />);
await screen.findByText('全部评价');
expect(screen.getAllByText('暂无评价')).toHaveLength(2);
expect(screen.queryByText('评分加载中…')).toBeNull();
expect(screen.queryByText('评分加载失败')).toBeNull();
expect(screen.queryByRole('button', { name: '重试' })).toBeNull();
expect(screen.queryByRole('button', { name: '登录后评价' })).toBeNull();
expect(screen.queryByText('正在加载我的评价…')).toBeNull();
expect(screen.queryByRole('textbox')).toBeNull();
expect(mocks.list).not.toHaveBeenCalled();
expect(mocks.mine).not.toHaveBeenCalled();
});
it('服务端分页保留总数,修改保留当前页、首次提交回第一页', async () => {
mocks.mine.mockResolvedValue({ review: review() });
mocks.list.mockImplementation(async (_id, page) =>
@@ -107,10 +107,16 @@ export function GameReviews({
state,
canReview,
onLogin,
publiclyVisible,
}: {
state: ReviewsState;
canReview: boolean;
onLogin: () => void;
/**
* 作品是否已公开。非公开作品没有公开评价投影,两栏一律按空态展示,
* 不出现错误块、重试按钮或评分加载失败。
*/
publiclyVisible: boolean;
}) {
const length = reviewCommentLength(state.comment);
const tooLong = length > GAME_REVIEW_COMMENT_LIMIT;
@@ -119,7 +125,9 @@ export function GameReviews({
<section className="game-reviews" aria-label="游戏评价">
<div className="game-reviews__personal">
<h2>我的评价</h2>
{!canReview ? (
{!publiclyVisible ? (
<PlatformEmptyState>暂无评价</PlatformEmptyState>
) : !canReview ? (
<PlatformActionButton tone="secondary" onClick={onLogin}>
登录后评价
</PlatformActionButton>
@@ -218,9 +226,11 @@ export function GameReviews({
<div className="game-reviews__public">
<div className="game-reviews__heading">
<h2>全部评价</h2>
<GameRatingSummary state={state} />
{publiclyVisible ? <GameRatingSummary state={state} /> : null}
</div>
{state.listLoading ? (
{!publiclyVisible ? (
<PlatformEmptyState>暂无评价</PlatformEmptyState>
) : state.listLoading ? (
<p role="status">正在加载评价…</p>
) : state.listError ? (
<ReviewError
@@ -236,27 +246,31 @@ export function GameReviews({
) : (
<PlatformEmptyState>暂无评价</PlatformEmptyState>
)}
<nav className="game-review-pagination" aria-label="评价分页">
<PlatformActionButton
tone="secondary"
size="sm"
disabled={state.page <= 1}
onClick={() => void state.loadPage(state.page - 1)}
>
上一页
</PlatformActionButton>
<span>
{totalPages === 0 ? '0 / 0 页' : `${state.page} / ${totalPages} 页`}
</span>
<PlatformActionButton
tone="secondary"
size="sm"
disabled={state.page >= totalPages}
onClick={() => void state.loadPage(state.page + 1)}
>
下一页
</PlatformActionButton>
</nav>
{publiclyVisible ? (
<nav className="game-review-pagination" aria-label="评价分页">
<PlatformActionButton
tone="secondary"
size="sm"
disabled={state.page <= 1}
onClick={() => void state.loadPage(state.page - 1)}
>
上一页
</PlatformActionButton>
<span>
{totalPages === 0
? '0 / 0 页'
: `${state.page} / ${totalPages} 页`}
</span>
<PlatformActionButton
tone="secondary"
size="sm"
disabled={state.page >= totalPages}
onClick={() => void state.loadPage(state.page + 1)}
>
下一页
</PlatformActionButton>
</nav>
) : null}
</div>
</section>
);
@@ -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
@@ -351,38 +351,6 @@
.game-back-button svg {
width: 1rem;
}
.game-detail-hero {
display: grid;
grid-template-columns: minmax(13rem, 21rem) minmax(0, 1fr);
align-items: center;
gap: clamp(1.4rem, 4vw, 3.5rem);
margin-top: 1.5rem;
}
.game-detail-hero .game-cover {
border-radius: 1.7rem;
box-shadow: 0 1.25rem 2.5rem rgba(70, 42, 34, 0.18);
}
.game-detail-hero h1 {
margin: 0.65rem 0 0;
font-size: clamp(2rem, 4.8vw, 3.7rem);
letter-spacing: -0.055em;
}
.game-detail-hero__summary {
max-width: 32rem;
margin: 0.8rem 0 0;
color: var(--platform-text-base);
font-size: 1rem;
line-height: 1.75;
}
.game-detail-hero__author {
display: flex;
align-items: center;
gap: 0.55rem;
margin-top: 1.15rem;
color: var(--platform-text-soft);
font-size: 0.78rem;
font-weight: 700;
}
.game-avatar {
display: grid;
width: 1.8rem;
@@ -393,90 +361,26 @@
color: var(--platform-accent-strong);
font-weight: 900;
}
.game-detail-hero__actions {
display: flex;
align-items: center;
gap: 1rem;
margin-top: 1.4rem;
}
.game-detail-hero__play-count {
color: var(--platform-text-soft);
font-size: 0.75rem;
}
.game-device-warning {
color: var(--platform-accent-strong);
font-size: 0.75rem;
font-weight: 800;
}
.game-detail-body {
/*
* 非公开作品的封面占位文案:封面素材不可换签时,封面位改为状态说明
* (审核中 / 审核未通过 / 未公开 / 已被封禁),压在渐变占位之上。
*/
.game-detail-cover__pending {
position: absolute;
inset: 0;
z-index: 3;
display: grid;
grid-template-columns: minmax(0, 1fr) 17rem;
gap: 3rem;
margin-top: 3rem;
border-top: 1px solid var(--platform-surface-border);
padding-top: 2rem;
}
.game-detail-main h2 {
margin: 0;
font-size: 1.25rem;
}
.game-detail-main p {
max-width: 45rem;
margin: 0.75rem 0 0;
color: var(--platform-text-base);
line-height: 1.9;
}
.game-tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
margin-top: 1.3rem;
}
.game-tag-list span {
border: 1px solid var(--platform-surface-border);
border-radius: 999px;
padding: 0.35rem 0.65rem;
color: var(--platform-text-soft);
font-size: 0.7rem;
font-weight: 700;
}
.game-detail-side {
display: grid;
gap: 0.7rem;
align-content: start;
}
.game-info-card {
display: grid;
gap: 0.3rem;
border: 1px solid var(--platform-surface-border);
border-radius: 1rem;
padding: 0.8rem 0.9rem;
background: var(--platform-panel-fill);
}
.game-info-card strong {
font-size: 0.72rem;
}
.game-info-card span {
display: inline-flex;
align-items: center;
gap: 0.28rem;
color: var(--platform-text-soft);
font-size: 0.72rem;
line-height: 1.45;
}
.game-info-card svg {
width: 0.85rem;
}
.game-detail-footnote {
display: flex;
align-items: center;
gap: 0.4rem;
margin-top: 2.2rem;
color: var(--platform-text-soft);
font-size: 0.7rem;
}
.game-detail-footnote svg {
width: 0.9rem;
place-items: center;
background: rgba(22, 21, 28, 0.55);
color: #fff5dc;
font-size: 0.95rem;
font-weight: 800;
letter-spacing: 0.16em;
}
.game-loading-panel {
display: grid;
@@ -646,25 +550,11 @@
.game-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.game-detail-hero {
grid-template-columns: 9rem minmax(0, 1fr);
gap: 1.15rem;
}
.game-detail-hero .game-cover {
min-height: 9rem;
}
.game-cover__icon {
width: 3.7rem;
border-radius: 1rem;
font-size: 2rem;
}
.game-detail-body {
grid-template-columns: 1fr;
gap: 1.5rem;
}
.game-detail-side {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
@@ -704,19 +594,6 @@
.game-cover {
min-height: 10.5rem;
}
.game-detail-hero {
display: block;
}
.game-detail-hero .game-cover {
min-height: 13rem;
margin-top: 1.2rem;
}
.game-detail-hero h1 {
margin-top: 1.4rem;
}
.game-detail-side {
grid-template-columns: 1fr;
}
.game-play-page {
padding: 0.7rem;
}
@@ -849,6 +726,7 @@
}
.my-game-card__actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: auto;
padding-top: 0.35rem;
@@ -879,6 +757,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;
}
@@ -1086,6 +1093,9 @@
.game-publish-row {
grid-template-columns: 1fr;
}
.my-game-filters .game-publish-field {
max-width: none;
}
.game-publish-media__preview {
width: 100%;
}
@@ -1,8 +1,10 @@
/*
* 游戏分发封面与截图的展示层补充样式。
* `.game-cover` 的「图片已就绪」态样式。
*
* 真实封面/截图换签成功后覆盖原有渐变占位;未上传、换签失败或仍在解析时保持
* gameDistribution.css 里的占位视觉,因此这里的规则只负责“图片已就绪”这一态。
* 列表卡片、播放页与作者中心卡片共用:真实封面/截图换签成功后覆盖
* gameDistribution.css 里的渐变占位;未上传、换签失败或仍在解析时保持占位视觉,
* 所以这里只负责这一态。详情展示层有自己的 `.game-detail-cover`,见
* packages/shared/src/components/GameDetailDisplay/index.css。
*/
.game-cover__image {
position: absolute;
@@ -12,64 +14,3 @@
height: 100%;
object-fit: cover;
}
.game-detail-showcase {
display: grid;
gap: 0.7rem;
min-width: 0;
}
.game-detail-thumbs {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 0.15rem;
scroll-snap-type: x proximity;
scrollbar-width: thin;
}
.game-detail-thumb {
flex: 0 0 auto;
width: 5.4rem;
aspect-ratio: 16 / 9;
overflow: hidden;
border: 1px solid var(--platform-surface-border);
border-radius: 0.7rem;
padding: 0;
background: linear-gradient(
135deg,
rgba(241, 159, 95, 0.24),
rgba(91, 49, 42, 0.16)
);
cursor: pointer;
scroll-snap-align: start;
transition: 160ms ease;
}
.game-detail-thumb:hover {
border-color: rgba(199, 101, 61, 0.45);
}
.game-detail-thumb[aria-pressed='true'] {
border-color: rgba(199, 101, 61, 0.62);
box-shadow: 0 0 0 2px rgba(199, 101, 61, 0.22);
}
.game-detail-thumb img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.game-detail-thumb__placeholder {
display: block;
width: 100%;
height: 100%;
}
@media (max-width: 560px) {
.game-detail-thumb {
width: 4.6rem;
}
}
@@ -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,85 @@
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: '已撤回',
};
/**
* 非公开作品在封面位置显示的占位文案。
*
* 作品没公开时封面素材不可换签(封面位只会剩渐变占位),这里按最新版本状态给出最贴近的
* 说明;其余状态退回作品级公开文案,避免在封面上编造审核结论。
*/
export const NON_PUBLIC_COVER_VERSION_LABEL: Partial<
Record<GameDistributionVersionStatus, string>
> = {
pending_review: '审核中',
rejected: '审核未通过',
};
/** 非公开作品的封面占位文案:优先最新版本状态,其次作品公开状态。 */
export function resolveNonPublicCoverLabel(
gameStatus: string,
versionStatus?: GameDistributionVersionStatus | null,
): string {
return (
(versionStatus ? NON_PUBLIC_COVER_VERSION_LABEL[versionStatus] : null) ??
GAME_STATUS_LABEL[gameStatus] ??
'未公开'
);
}
/** 服务端 `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,
};
@@ -0,0 +1,69 @@
import { useEffect, useState } from 'react';
import { getPublicAuthUserById } from '../../services/authService';
/**
* 作者展示名缺失时服务端会退化成角色词,不是用户名:
* api-server 的 `game_payload`(公开目录/公开详情/作者侧)写的是
* `author_name.as_deref().unwrap_or("创作者")`,后台行 `admin_game_payload` 写的是
* `unwrap_or("未知作者")`。这两个词一律当成「没有名字」,否则会把占位词当昵称渲染出来。
*/
const GAME_AUTHOR_PLACEHOLDER_NAMES: Record<string, true> = {
创作者: true,
未知作者: true,
};
export type GameAuthorIdentity = {
id?: string | null;
name?: string | null;
};
function isRealAuthorName(value: string | null | undefined) {
const name = (value ?? '').trim();
return name.length > 0 && GAME_AUTHOR_PLACEHOLDER_NAMES[name] !== true;
}
/**
* 游戏作者的对外展示名。
*
* 游戏行上的 `author_name` 由创建接口固定写空(api-server `create_game` 传 None),
* 所以公开投影里的 `author.name` 只会是占位角色词,不是用户名。真实昵称只能按
* `author.id`(owner_user_id)走公开用户查询补一次:`GET /api/auth/public-users/by-id/{userId}`,
* 与陶泥号查询 `/api/auth/public-users/by-code/{code}` 同一族接口,不需要登录态。
*
* 返回 `null` 表示还在查询——展示层此时不要渲染名字,避免先闪一下占位词;
* 返回空串表示查不到(账号失效 / 网络失败),由展示层降级。
* 回包名本来就是真实昵称时不再多发请求:后端补上作者名后这里自然退化为普通渲染。
*/
export function useGameAuthorDisplayName(
author: GameAuthorIdentity | null | undefined,
): string | null {
const fallbackName = (author?.name ?? '').trim();
const hasFallbackName = isRealAuthorName(fallbackName);
const authorId = (author?.id ?? '').trim();
const [lookedUp, setLookedUp] = useState<{
id: string;
name: string;
} | null>(null);
useEffect(() => {
if (hasFallbackName || !authorId) return;
let alive = true;
void getPublicAuthUserById(authorId)
.then((user) => {
if (alive) setLookedUp({ id: authorId, name: user.displayName.trim() });
})
.catch(() => {
// 作者账号查不到(已失效 / 非公开)时不编造名字,按空名交给展示层降级。
if (alive) setLookedUp({ id: authorId, name: '' });
});
return () => {
alive = false;
};
}, [authorId, hasFallbackName]);
if (hasFallbackName) return fallbackName;
if (!authorId) return '';
if (!lookedUp || lookedUp.id !== authorId) return null;
return lookedUp.name;
}
@@ -23,8 +23,17 @@ function errorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
/** 外层以游戏和账号身份为 key,切换上下文会卸载并清空整个评价草稿。 */
export function useGameReviews(gameId: string, canReview: boolean) {
/**
* 外层以游戏和账号身份为 key,切换上下文会卸载并清空整个评价草稿。
*
* `publiclyVisible` 为 false(审核中 / 审核未通过 / 未公开 / 已下架)时,服务端对评价与
* 评分一律按「作品不存在或不可公开访问」拒绝,前端直接不发起任何评价类请求,按空态展示。
*/
export function useGameReviews(
gameId: string,
canReview: boolean,
publiclyVisible: boolean,
) {
const [summary, setSummary] = useState<GameDistributionRatingSummary | null>(
null,
);
@@ -32,10 +41,10 @@ export function useGameReviews(gameId: string, canReview: boolean) {
null,
);
const [page, setPage] = useState(1);
const [listLoading, setListLoading] = useState(true);
const [listLoading, setListLoading] = useState(publiclyVisible);
const [listError, setListError] = useState('');
const [myReview, setMyReview] = useState<GameDistributionReview | null>(null);
const [myLoading, setMyLoading] = useState(canReview);
const [myLoading, setMyLoading] = useState(canReview && publiclyVisible);
const [myError, setMyError] = useState('');
const [editing, setEditing] = useState(false);
const [score, setScore] = useState<number | null>(null);
@@ -50,6 +59,8 @@ export function useGameReviews(gameId: string, canReview: boolean) {
const loadPage = useCallback(
async (nextPage: number) => {
// 非公开作品没有公开评价投影:不请求、不置错误态。
if (!publiclyVisible) return;
const serial = ++listSerial.current;
pageRef.current = nextPage;
setPage(nextPage);
@@ -68,11 +79,11 @@ export function useGameReviews(gameId: string, canReview: boolean) {
setListLoading(false);
}
},
[gameId],
[gameId, publiclyVisible],
);
const loadMine = useCallback(async () => {
if (!canReview) return;
if (!canReview || !publiclyVisible) return;
const serial = ++mySerial.current;
setMyLoading(true);
setMyError('');
@@ -86,18 +97,28 @@ export function useGameReviews(gameId: string, canReview: boolean) {
} finally {
if (alive.current && serial === mySerial.current) setMyLoading(false);
}
}, [canReview, gameId]);
}, [canReview, gameId, publiclyVisible]);
useEffect(() => {
alive.current = true;
void loadPage(1);
void loadMine();
if (!publiclyVisible) {
// 非公开作品:不进任何评价/评分请求,按空态起步。
listSerial.current += 1;
mySerial.current += 1;
setListLoading(false);
setMyLoading(false);
setListError('');
setMyError('');
} else {
void loadPage(1);
void loadMine();
}
return () => {
alive.current = false;
listSerial.current += 1;
mySerial.current += 1;
};
}, [loadMine, loadPage]);
}, [loadMine, loadPage, publiclyVisible]);
function edit() {
if (!myReview || savingRef.current) return;
@@ -116,7 +137,14 @@ export function useGameReviews(gameId: string, canReview: boolean) {
}
async function submit() {
if (savingRef.current || !canReview || myLoading || myError) return;
if (
savingRef.current ||
!canReview ||
!publiclyVisible ||
myLoading ||
myError
)
return;
const normalizedComment = normalizeReviewNewlines(comment);
if (score === null || !Number.isInteger(score) || score < 1 || score > 10) {
setSaveError('请选择 1–10 分');
@@ -54,12 +54,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(),
}));
@@ -1020,3 +1024,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();
});
});
@@ -457,8 +457,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],
@@ -850,16 +854,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}
/>
+2 -2
View File
@@ -6,8 +6,8 @@ export type FrontendRuntimeConfig = {
imageEditorAgentSidebarEnabled: boolean;
agcTemplateLibraryEnabled: boolean;
/**
* 游戏发布灰度开关:后端未配置 `game-distribution:publish` 时对已登录作者默认开放,
* 显式收紧后只有白名单/灰度命中的作者为 `true`,匿名恒为 `false`。
* 游戏发布灰度:未配置 `game-distribution:publish` 或 `enabled=false` 时不开放;
* 显式配置后只有白名单/灰度命中的作者为 `true`,匿名恒为 `false`。
*/
gameDistributionPublishEnabled: boolean;
};
+141 -2
View File
@@ -7,9 +7,11 @@ import type {
GameDistributionDeviceSupport,
GameDistributionGame,
GameDistributionMyReviewResponse,
GameDistributionRecoveryAction,
GameDistributionReviewsResponse,
GameDistributionSaveReviewRequest,
GameDistributionSaveReviewResponse,
GameDistributionUpdateGameMetadataRequest,
GameDistributionVersionDetail,
GameDistributionVersionStatus as GameDistributionVersionStatusValue,
GameDistributionVersionSummary,
@@ -133,8 +135,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',
@@ -142,9 +176,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(