作品管理与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
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:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user