恢复游戏分发完整实现(特性分支)
- 主站:游戏广场、详情、在线游玩、网页发布与作者中心,以及共享契约与客户端服务 - 后端:module-game-distribution 领域层、SpacetimeDB 表/迁移/绑定、spacetime-client facade、api-server 路由与发行网关 - 后台:游戏审核页(待审列表、通过/拒绝、安全下架) - AGC:发布面板、本地导出包读取命令与发布服务,含默认跳过的真实链路测试 - 运维:发行来源 nginx 模板与门禁、game-distribution:publish 灰度发布开关、OSS PutObject 受控重试 - 文档:主规范、里程碑与实施计划、决策日志与踩坑记录
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
import './gameDistribution.css';
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
Gamepad2,
|
||||
Maximize2,
|
||||
Smartphone,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
type GameDistributionGame,
|
||||
type GameDistributionInputMode,
|
||||
getGame,
|
||||
} from '../../services/gameDistributionClient';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import {
|
||||
normalizeGameEntryUrl,
|
||||
normalizeGameId,
|
||||
useIsMobileViewport,
|
||||
} from './gameDistributionGuards';
|
||||
import { GameCover } from './GameGalleryPage';
|
||||
|
||||
type GameDetailPageProps = {
|
||||
gameId: string | null;
|
||||
onBack: () => void;
|
||||
onPlay: (gameId: string) => void;
|
||||
};
|
||||
|
||||
const INPUT_MODE_LABELS: Record<GameDistributionInputMode, string> = {
|
||||
keyboard: '键盘',
|
||||
mouse: '鼠标',
|
||||
touch: '触屏',
|
||||
};
|
||||
|
||||
/**
|
||||
* 游玩方式优先取版本冻结时声明的输入方式(公开投影里的 `inputModes`),
|
||||
* 再退回版本自由文本 `controls`;两者都没有才显示“未标注操作方式”。
|
||||
*/
|
||||
function resolveControlsLabel(
|
||||
version: GameDistributionGame['currentVersion'],
|
||||
inputModes?: GameDistributionInputMode[],
|
||||
) {
|
||||
if (!version) return '暂未发布可玩版本';
|
||||
const declared = Array.from(
|
||||
new Set((inputModes ?? []).map((mode) => INPUT_MODE_LABELS[mode] ?? mode)),
|
||||
).filter(Boolean);
|
||||
if (declared.length > 0) return declared.join(' · ');
|
||||
const controls = version.controls.join(' · ').trim();
|
||||
return controls || '未标注操作方式';
|
||||
}
|
||||
|
||||
export function GameDetailPage({
|
||||
gameId,
|
||||
onBack,
|
||||
onPlay,
|
||||
}: GameDetailPageProps) {
|
||||
const [game, setGame] = useState<GameDistributionGame | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const requestSerialRef = useRef(0);
|
||||
const isMobileViewport = useIsMobileViewport();
|
||||
|
||||
const loadGame = useCallback(() => {
|
||||
const requestSerial = ++requestSerialRef.current;
|
||||
const normalizedGameId = normalizeGameId(gameId);
|
||||
setGame(null);
|
||||
setError('');
|
||||
if (!normalizedGameId) {
|
||||
setIsLoading(false);
|
||||
setError('游戏编号无效');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
void getGame(normalizedGameId)
|
||||
.then((value) => {
|
||||
if (requestSerial !== requestSerialRef.current) return;
|
||||
if (value?.currentVersion) {
|
||||
const entryUrl = normalizeGameEntryUrl(value.currentVersion.entryUrl);
|
||||
if (!entryUrl) {
|
||||
setError('游戏入口地址无效');
|
||||
return;
|
||||
}
|
||||
setGame({
|
||||
...value,
|
||||
currentVersion: { ...value.currentVersion, entryUrl },
|
||||
});
|
||||
return;
|
||||
}
|
||||
setGame(value);
|
||||
if (!value) setError('找不到这款游戏');
|
||||
})
|
||||
.catch((loadError: unknown) => {
|
||||
if (requestSerial !== requestSerialRef.current) return;
|
||||
setError(
|
||||
loadError instanceof Error ? loadError.message : '游戏详情加载失败',
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (requestSerial === requestSerialRef.current) setIsLoading(false);
|
||||
});
|
||||
}, [gameId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadGame();
|
||||
return () => {
|
||||
requestSerialRef.current += 1;
|
||||
};
|
||||
}, [loadGame]);
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="game-detail-page">
|
||||
<div className="game-loading-panel">正在加载游戏详情…</div>
|
||||
</div>
|
||||
);
|
||||
if (error || !game)
|
||||
return (
|
||||
<div className="game-detail-page">
|
||||
<button type="button" className="game-back-button" onClick={onBack}>
|
||||
<ArrowLeft /> 返回游戏广场
|
||||
</button>
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
surface="platform"
|
||||
className="game-status-message"
|
||||
>
|
||||
<span>{error || '找不到这款游戏'}</span>
|
||||
<button type="button" onClick={loadGame}>
|
||||
重新加载
|
||||
</button>
|
||||
</PlatformStatusMessage>
|
||||
</div>
|
||||
);
|
||||
|
||||
const version = game.currentVersion;
|
||||
const mobilePlaybackBlocked = isMobileViewport && !game.deviceSupport.mobile;
|
||||
const playbackBlocked = !version || mobilePlaybackBlocked;
|
||||
return (
|
||||
<div className="game-detail-page">
|
||||
<button type="button" className="game-back-button" onClick={onBack}>
|
||||
<ArrowLeft /> 返回游戏广场
|
||||
</button>
|
||||
<section className="game-detail-hero">
|
||||
<GameCover game={game} />
|
||||
<div className="game-detail-hero__copy">
|
||||
<span className="game-eyebrow">
|
||||
<Sparkles aria-hidden="true" /> {game.category}
|
||||
</span>
|
||||
<h1>{game.title}</h1>
|
||||
<p className="game-detail-hero__summary">{game.summary}</p>
|
||||
<div className="game-detail-hero__author">
|
||||
<span className="game-avatar">{game.author.name.slice(0, 1)}</span>
|
||||
<span>由 {game.author.name} 制作</span>
|
||||
</div>
|
||||
<div className="game-detail-hero__actions">
|
||||
<PlatformActionButton
|
||||
size="lg"
|
||||
shape="pill"
|
||||
onClick={() => onPlay(game.id)}
|
||||
disabled={playbackBlocked}
|
||||
aria-describedby={
|
||||
mobilePlaybackBlocked
|
||||
? 'game-mobile-playback-warning'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Gamepad2 /> 立即玩
|
||||
</PlatformActionButton>
|
||||
{mobilePlaybackBlocked ? (
|
||||
<span
|
||||
id="game-mobile-playback-warning"
|
||||
className="game-device-warning"
|
||||
role="status"
|
||||
>
|
||||
请在电脑上游玩
|
||||
</span>
|
||||
) : null}
|
||||
<span className="game-detail-hero__play-count">
|
||||
{game.playCount.toLocaleString()} 次游玩
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="game-detail-body">
|
||||
<div className="game-detail-main">
|
||||
<h2>关于这款游戏</h2>
|
||||
<p>{game.description}</p>
|
||||
<div className="game-tag-list">
|
||||
{game.tags.map((tag) => (
|
||||
<span key={tag}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<aside className="game-detail-side">
|
||||
<div className="game-info-card">
|
||||
<strong>游玩方式</strong>
|
||||
<span>{resolveControlsLabel(version, game.inputModes)}</span>
|
||||
</div>
|
||||
<div className="game-info-card">
|
||||
<strong>设备支持</strong>
|
||||
<span>
|
||||
<Smartphone aria-hidden="true" />{' '}
|
||||
{game.deviceSupport.mobile ? '移动端' : ''}
|
||||
{game.deviceSupport.desktop ? ' · 桌面端' : ''}
|
||||
{game.deviceSupport.touch ? ' · 支持触屏' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="game-info-card">
|
||||
<strong>当前版本</strong>
|
||||
<span>{version?.version || '—'}</span>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
<p className="game-detail-footnote">
|
||||
<Maximize2 aria-hidden="true" />{' '}
|
||||
游玩时可以切换全屏,游戏运行在独立的安全容器中。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GameDetailPage;
|
||||
@@ -0,0 +1,420 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GameDistributionGame } 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());
|
||||
|
||||
vi.mock('../../services/gameDistributionClient', () => ({
|
||||
listGames: listGamesMock,
|
||||
getGame: getGameMock,
|
||||
}));
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, reject, resolve };
|
||||
}
|
||||
|
||||
function createGame(
|
||||
overrides: Partial<GameDistributionGame> = {},
|
||||
): GameDistributionGame {
|
||||
return {
|
||||
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: 12,
|
||||
createdAt: '2026-09-18T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setViewportWidth(width: number) {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
value: width,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
addEventListener: vi.fn(),
|
||||
matches: width <= 820,
|
||||
removeEventListener: vi.fn(),
|
||||
}),
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setViewportWidth(1024);
|
||||
listGamesMock.mockResolvedValue([]);
|
||||
getGameMock.mockResolvedValue(createGame());
|
||||
});
|
||||
|
||||
describe('GameGalleryPage', () => {
|
||||
it('ignores a stale response after search keyword changes', async () => {
|
||||
const first = deferred<GameDistributionGame[]>();
|
||||
const second = deferred<GameDistributionGame[]>();
|
||||
listGamesMock
|
||||
.mockImplementationOnce(() => first.promise)
|
||||
.mockImplementationOnce(() => second.promise);
|
||||
const onOpenDetail = vi.fn();
|
||||
const { rerender } = render(
|
||||
<GameGalleryPage searchKeyword="" onOpenDetail={onOpenDetail} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(listGamesMock).toHaveBeenCalledTimes(1));
|
||||
rerender(
|
||||
<GameGalleryPage searchKeyword="新搜索" onOpenDetail={onOpenDetail} />,
|
||||
);
|
||||
await waitFor(() => expect(listGamesMock).toHaveBeenCalledTimes(2));
|
||||
|
||||
const newerGame = createGame({ id: 'game-new', title: '新搜索结果' });
|
||||
second.resolve([newerGame]);
|
||||
await screen.findByText('新搜索结果');
|
||||
first.resolve([createGame({ id: 'game-old', title: '旧搜索结果' })]);
|
||||
await waitFor(() => expect(screen.queryByText('旧搜索结果')).toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
describe('GameDetailPage', () => {
|
||||
it('blocks desktop-only games on a mobile viewport', async () => {
|
||||
setViewportWidth(375);
|
||||
getGameMock.mockResolvedValueOnce(
|
||||
createGame({
|
||||
deviceSupport: { desktop: true, mobile: false, touch: false },
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
|
||||
);
|
||||
|
||||
const playButton = await screen.findByRole('button', { name: /立即玩/u });
|
||||
expect((playButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(screen.getByText('请在电脑上游玩')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects an invalid query id without requesting the API', async () => {
|
||||
render(<GameDetailPage gameId="" onBack={vi.fn()} onPlay={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText('游戏编号无效')).toBeTruthy();
|
||||
expect(getGameMock).not.toHaveBeenCalled();
|
||||
});
|
||||
it('已发布但未标注操作方式时不谎称没有可玩版本', async () => {
|
||||
getGameMock.mockResolvedValueOnce(
|
||||
createGame({
|
||||
currentVersion: { ...createGame().currentVersion!, controls: [] },
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('未标注操作方式')).toBeTruthy();
|
||||
expect(screen.queryByText('暂未发布可玩版本')).toBeNull();
|
||||
});
|
||||
|
||||
it('优先展示版本声明的输入方式', async () => {
|
||||
getGameMock.mockResolvedValueOnce(
|
||||
createGame({
|
||||
inputModes: ['keyboard', 'touch'],
|
||||
currentVersion: { ...createGame().currentVersion!, controls: [] },
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('键盘 · 触屏')).toBeTruthy();
|
||||
expect(screen.queryByText('未标注操作方式')).toBeNull();
|
||||
});
|
||||
|
||||
it('没有当前版本时才显示暂未发布可玩版本', async () => {
|
||||
getGameMock.mockResolvedValueOnce(createGame({ currentVersion: null }));
|
||||
render(
|
||||
<GameDetailPage gameId="game-1" onBack={vi.fn()} onPlay={vi.fn()} />,
|
||||
);
|
||||
|
||||
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()} />);
|
||||
|
||||
await screen.findByRole('button', { name: /开始游戏/u });
|
||||
expect(screen.queryByTitle('星轨防线 在线游玩')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /开始游戏/u }));
|
||||
const iframe = await screen.findByTitle('星轨防线 在线游玩');
|
||||
expect(iframe.getAttribute('sandbox')).toBe('allow-scripts');
|
||||
});
|
||||
|
||||
it('rejects an unsafe release entry URL before rendering an iframe', async () => {
|
||||
getGameMock.mockResolvedValueOnce(
|
||||
createGame({
|
||||
currentVersion: {
|
||||
...createGame().currentVersion!,
|
||||
entryUrl: 'javascript:alert(1)',
|
||||
},
|
||||
}),
|
||||
);
|
||||
render(<GamePlayPage gameId="game-1" onBack={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText('游戏入口地址无效')).toBeTruthy();
|
||||
expect(screen.queryByTitle('星轨防线 在线游玩')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GameGalleryPage 筛选与返回上下文', () => {
|
||||
function resetGalleryContext() {
|
||||
window.sessionStorage.clear();
|
||||
window.history.replaceState({}, '', '/games');
|
||||
}
|
||||
|
||||
function openGame(title: string) {
|
||||
fireEvent.click(screen.getByText(title).closest('button')!);
|
||||
}
|
||||
|
||||
it('设备筛选按真实 deviceSupport 过滤,并写回地址栏', async () => {
|
||||
resetGalleryContext();
|
||||
listGamesMock.mockResolvedValue([
|
||||
createGame({
|
||||
id: 'game-desktop',
|
||||
title: '桌面专属',
|
||||
deviceSupport: { desktop: true, mobile: false, touch: false },
|
||||
}),
|
||||
createGame({
|
||||
id: 'game-mobile',
|
||||
title: '移动可玩',
|
||||
deviceSupport: { desktop: false, mobile: true, touch: true },
|
||||
}),
|
||||
]);
|
||||
render(<GameGalleryPage onOpenDetail={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText('桌面专属')).toBeTruthy();
|
||||
const deviceTabs = within(
|
||||
screen.getByRole('tablist', { name: '设备筛选' }),
|
||||
);
|
||||
fireEvent.click(deviceTabs.getByRole('tab', { name: /移动/u }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('桌面专属')).toBeNull());
|
||||
expect(screen.getByText('移动可玩')).toBeTruthy();
|
||||
expect(window.location.search).toContain('device=');
|
||||
expect(decodeURIComponent(window.location.search)).toContain('device=移动');
|
||||
});
|
||||
|
||||
it('筛选结果为空时提供重置筛选,并清理地址栏条件', async () => {
|
||||
resetGalleryContext();
|
||||
listGamesMock.mockResolvedValue([
|
||||
createGame({
|
||||
id: 'game-desktop',
|
||||
title: '桌面专属',
|
||||
deviceSupport: { desktop: true, mobile: false, touch: false },
|
||||
}),
|
||||
]);
|
||||
render(<GameGalleryPage onOpenDetail={vi.fn()} />);
|
||||
expect(await screen.findByText('桌面专属')).toBeTruthy();
|
||||
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('tablist', { name: '设备筛选' })).getByRole(
|
||||
'tab',
|
||||
{ name: /移动/u },
|
||||
),
|
||||
);
|
||||
expect(await screen.findByText('还没有找到对应游戏')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '重置筛选' }));
|
||||
expect(await screen.findByText('桌面专属')).toBeTruthy();
|
||||
expect(window.location.search).not.toContain('device=');
|
||||
});
|
||||
|
||||
it('从地址栏恢复筛选条件', async () => {
|
||||
resetGalleryContext();
|
||||
window.history.replaceState(
|
||||
{},
|
||||
'',
|
||||
'/games?keyword=%E6%98%9F%E8%BD%A8&category=%E5%8A%A8%E4%BD%9C&device=%E7%A7%BB%E5%8A%A8',
|
||||
);
|
||||
listGamesMock.mockResolvedValue([createGame({ title: '星轨防线' })]);
|
||||
render(<GameGalleryPage onOpenDetail={vi.fn()} />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(listGamesMock).toHaveBeenCalledWith({
|
||||
keyword: '星轨',
|
||||
category: '动作',
|
||||
}),
|
||||
);
|
||||
const search = screen.getByLabelText('搜索游戏') as HTMLInputElement;
|
||||
expect(search.value).toBe('星轨');
|
||||
const deviceTabs = within(
|
||||
screen.getByRole('tablist', { name: '设备筛选' }),
|
||||
);
|
||||
expect(
|
||||
deviceTabs
|
||||
.getByRole('tab', { name: /移动/u })
|
||||
.getAttribute('aria-selected'),
|
||||
).toBe('true');
|
||||
});
|
||||
|
||||
it('返回游戏广场时按上次筛选恢复列表与滚动位置', async () => {
|
||||
resetGalleryContext();
|
||||
window.sessionStorage.setItem(
|
||||
'game-gallery-filters',
|
||||
JSON.stringify({ keyword: '', category: '全部', device: '移动' }),
|
||||
);
|
||||
window.sessionStorage.setItem('game-gallery-scroll:|全部|移动', '320');
|
||||
listGamesMock.mockResolvedValue([
|
||||
createGame({
|
||||
id: 'game-desktop',
|
||||
title: '桌面专属',
|
||||
deviceSupport: { desktop: true, mobile: false, touch: false },
|
||||
}),
|
||||
createGame({
|
||||
id: 'game-mobile',
|
||||
title: '移动可玩',
|
||||
deviceSupport: { desktop: false, mobile: true, touch: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const { container } = render(
|
||||
<div className="platform-tab-panel">
|
||||
<GameGalleryPage onOpenDetail={vi.fn()} />
|
||||
</div>,
|
||||
);
|
||||
const scroller = container.querySelector(
|
||||
'.platform-tab-panel',
|
||||
) as HTMLElement;
|
||||
|
||||
expect(await screen.findByText('移动可玩')).toBeTruthy();
|
||||
await waitFor(() => expect(scroller.scrollTop).toBe(320));
|
||||
expect(screen.queryByText('桌面专属')).toBeNull();
|
||||
});
|
||||
|
||||
it('打开详情时保存目录滚动位置,返回后按同一容器复位', async () => {
|
||||
resetGalleryContext();
|
||||
const onOpenDetail = vi.fn();
|
||||
listGamesMock.mockResolvedValue([createGame({ title: '星轨防线' })]);
|
||||
const { container } = render(
|
||||
<div className="platform-tab-panel">
|
||||
<GameGalleryPage onOpenDetail={onOpenDetail} />
|
||||
</div>,
|
||||
);
|
||||
const scroller = container.querySelector(
|
||||
'.platform-tab-panel',
|
||||
) as HTMLElement;
|
||||
expect(await screen.findByText('星轨防线')).toBeTruthy();
|
||||
|
||||
scroller.scrollTop = 180;
|
||||
openGame('星轨防线');
|
||||
|
||||
expect(onOpenDetail).toHaveBeenCalledWith('game-1');
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
window.sessionStorage.getItem('game-gallery-scroll:|全部|全部'),
|
||||
).toBe('180'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GamePlayPage 启动超时与旋转提示', () => {
|
||||
it('启动超时后给出重试与返回详情,重试可恢复可玩', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const onBack = vi.fn();
|
||||
getGameMock.mockResolvedValue(createGame());
|
||||
render(<GamePlayPage gameId="game-1" onBack={onBack} />);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /开始游戏/u }));
|
||||
const firstFrame = screen.getByTitle('星轨防线 在线游玩');
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(GAME_PLAY_STARTUP_TIMEOUT_MS);
|
||||
});
|
||||
expect(screen.getByText(/启动超时/u)).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /返回详情/u })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^重试$/u }));
|
||||
const retriedFrame = screen.getByTitle('星轨防线 在线游玩');
|
||||
expect(retriedFrame).not.toBe(firstFrame);
|
||||
expect(screen.queryByText(/启动超时/u)).toBeNull();
|
||||
|
||||
fireEvent.load(retriedFrame);
|
||||
expect(retriedFrame.className.includes('game-player-frame--ready')).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(GAME_PLAY_STARTUP_TIMEOUT_MS);
|
||||
});
|
||||
expect(screen.queryByText(/启动超时/u)).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /星轨防线/u }));
|
||||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('横屏游戏只在竖屏视口提示旋转', async () => {
|
||||
setViewportWidth(390);
|
||||
getGameMock.mockResolvedValue(createGame({ orientation: 'landscape' }));
|
||||
const { unmount } = render(
|
||||
<GamePlayPage gameId="game-1" onBack={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(await screen.findByText(/旋转设备/u)).toBeTruthy();
|
||||
unmount();
|
||||
|
||||
setViewportWidth(1280);
|
||||
getGameMock.mockResolvedValue(createGame({ orientation: 'landscape' }));
|
||||
render(<GamePlayPage gameId="game-1" onBack={vi.fn()} />);
|
||||
|
||||
await screen.findByRole('button', { name: /开始游戏/u });
|
||||
expect(screen.queryByText(/旋转设备/u)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,389 @@
|
||||
import './gameDistribution.css';
|
||||
|
||||
import { Gamepad2, Monitor, Search, Smartphone, Sparkles } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
type GameCategory,
|
||||
type GameDistributionGame,
|
||||
listGames,
|
||||
} from '../../services/gameDistributionClient';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import {
|
||||
GAME_GALLERY_CATEGORIES as CATEGORIES,
|
||||
GAME_GALLERY_DEVICE_FILTERS as DEVICE_FILTERS,
|
||||
GAME_GALLERY_SCROLL_CONTAINER_SELECTOR,
|
||||
type GameDeviceFilter,
|
||||
type GameGalleryFilters,
|
||||
gameGalleryScrollKey,
|
||||
matchesGameDeviceFilter,
|
||||
readGameGalleryQueryFilters,
|
||||
readStoredGameGalleryFilters,
|
||||
readStoredGameGalleryScrollTop,
|
||||
syncGameGalleryQuery,
|
||||
writeStoredGameGalleryFilters,
|
||||
writeStoredGameGalleryScrollTop,
|
||||
} from './gameGalleryViewState';
|
||||
|
||||
type GameGalleryPageProps = {
|
||||
searchKeyword?: string;
|
||||
onOpenDetail: (gameId: string) => void;
|
||||
onOpenMyGames?: () => void;
|
||||
onOpenPublish?: () => void;
|
||||
};
|
||||
|
||||
export function GameCover({
|
||||
game,
|
||||
compact = false,
|
||||
}: {
|
||||
game: GameDistributionGame;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`game-cover ${compact ? 'game-cover--compact' : ''}`}
|
||||
style={{
|
||||
background: `linear-gradient(145deg, ${game.coverColor}, #16151c)`,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="game-cover__glow" />
|
||||
<span className="game-cover__icon">{game.icon}</span>
|
||||
<span className="game-cover__grid" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GameCard({
|
||||
game,
|
||||
onOpen,
|
||||
}: {
|
||||
game: GameDistributionGame;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" className="game-card" onClick={onOpen}>
|
||||
<GameCover game={game} />
|
||||
<span className="game-card__body">
|
||||
<span className="game-card__title-row">
|
||||
<strong>{game.title}</strong>
|
||||
<span className="game-card__play-count">
|
||||
{game.playCount.toLocaleString()} 次游玩
|
||||
</span>
|
||||
</span>
|
||||
<span className="game-card__summary">{game.summary}</span>
|
||||
<span className="game-card__meta">
|
||||
<span>{game.category}</span>
|
||||
<span>·</span>
|
||||
<span>{game.author.name}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function GameGalleryPage({
|
||||
searchKeyword = '',
|
||||
onOpenDetail,
|
||||
onOpenMyGames,
|
||||
onOpenPublish,
|
||||
}: GameGalleryPageProps) {
|
||||
const initialFiltersRef = useRef<GameGalleryFilters | null>(null);
|
||||
if (initialFiltersRef.current === null) {
|
||||
const queryFilters =
|
||||
typeof window === 'undefined'
|
||||
? {}
|
||||
: readGameGalleryQueryFilters(window.location.search);
|
||||
const storedFilters = readStoredGameGalleryFilters();
|
||||
initialFiltersRef.current = {
|
||||
keyword: queryFilters.keyword ?? storedFilters.keyword ?? searchKeyword,
|
||||
category: queryFilters.category ?? storedFilters.category ?? '全部',
|
||||
device: queryFilters.device ?? storedFilters.device ?? '全部',
|
||||
};
|
||||
}
|
||||
const initialFilters = initialFiltersRef.current;
|
||||
const [category, setCategory] = useState<GameCategory | '全部'>(
|
||||
initialFilters.category,
|
||||
);
|
||||
const [device, setDevice] = useState<GameDeviceFilter>(initialFilters.device);
|
||||
const [keywordInput, setKeywordInput] = useState(initialFilters.keyword);
|
||||
const [activeKeyword, setActiveKeyword] = useState(initialFilters.keyword);
|
||||
const [games, setGames] = useState<GameDistributionGame[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const requestSerialRef = useRef(0);
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
||||
const hasRestoredScrollRef = useRef(false);
|
||||
const didSyncPropKeywordRef = useRef(false);
|
||||
|
||||
const filters = useMemo<GameGalleryFilters>(
|
||||
() => ({ keyword: activeKeyword, category, device }),
|
||||
[activeKeyword, category, device],
|
||||
);
|
||||
const scrollKey = gameGalleryScrollKey(filters);
|
||||
|
||||
const attachPageRoot = useCallback((node: HTMLDivElement | null) => {
|
||||
if (!node) return;
|
||||
const container = node.closest(GAME_GALLERY_SCROLL_CONTAINER_SELECTOR);
|
||||
scrollContainerRef.current =
|
||||
container instanceof HTMLElement ? container : null;
|
||||
}, []);
|
||||
|
||||
const loadGames = useCallback(() => {
|
||||
const requestSerial = ++requestSerialRef.current;
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
void listGames({ keyword: activeKeyword, category })
|
||||
.then((nextGames) => {
|
||||
if (requestSerial !== requestSerialRef.current) return;
|
||||
setGames(nextGames);
|
||||
})
|
||||
.catch((loadError: unknown) => {
|
||||
if (requestSerial !== requestSerialRef.current) return;
|
||||
setError(
|
||||
loadError instanceof Error ? loadError.message : '游戏目录加载失败',
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (requestSerial === requestSerialRef.current) setIsLoading(false);
|
||||
});
|
||||
}, [activeKeyword, category]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!didSyncPropKeywordRef.current) {
|
||||
didSyncPropKeywordRef.current = true;
|
||||
return;
|
||||
}
|
||||
setKeywordInput(searchKeyword);
|
||||
setActiveKeyword(searchKeyword);
|
||||
}, [searchKeyword]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadGames();
|
||||
return () => {
|
||||
requestSerialRef.current += 1;
|
||||
};
|
||||
}, [loadGames]);
|
||||
|
||||
useEffect(() => {
|
||||
writeStoredGameGalleryFilters(filters);
|
||||
syncGameGalleryQuery(filters);
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
hasRestoredScrollRef.current = false;
|
||||
}, [scrollKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasRestoredScrollRef.current || isLoading || games.length === 0) return;
|
||||
hasRestoredScrollRef.current = true;
|
||||
const container = scrollContainerRef.current;
|
||||
const storedScrollTop = readStoredGameGalleryScrollTop(scrollKey);
|
||||
if (!container || storedScrollTop <= 0) return;
|
||||
container.scrollTop = storedScrollTop;
|
||||
}, [games.length, isLoading, scrollKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const key = scrollKey;
|
||||
return () => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (container) writeStoredGameGalleryScrollTop(key, container.scrollTop);
|
||||
};
|
||||
}, [scrollKey]);
|
||||
|
||||
const visibleGames = useMemo(
|
||||
() => games.filter((game) => matchesGameDeviceFilter(game, device)),
|
||||
[device, games],
|
||||
);
|
||||
const featuredGame = useMemo(() => visibleGames[0] ?? null, [visibleGames]);
|
||||
const hasActiveFilters =
|
||||
activeKeyword.trim().length > 0 || category !== '全部' || device !== '全部';
|
||||
const resetFilters = () => {
|
||||
setKeywordInput('');
|
||||
setActiveKeyword('');
|
||||
setCategory('全部');
|
||||
setDevice('全部');
|
||||
};
|
||||
const openDetail = useCallback(
|
||||
(gameId: string) => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (container)
|
||||
writeStoredGameGalleryScrollTop(scrollKey, container.scrollTop);
|
||||
onOpenDetail(gameId);
|
||||
},
|
||||
[onOpenDetail, scrollKey],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="game-page" ref={attachPageRoot}>
|
||||
<section className="game-hero">
|
||||
<div className="game-hero__copy">
|
||||
<span className="game-eyebrow">
|
||||
<Sparkles aria-hidden="true" /> 陶泥儿游戏分发
|
||||
</span>
|
||||
<h1>
|
||||
把灵感做成游戏,
|
||||
<br />
|
||||
<em>现在就开始玩。</em>
|
||||
</h1>
|
||||
<p>
|
||||
来自 AGC
|
||||
创作者的轻量游戏集合。打开网页即可游玩,每一次尝试都值得被看见。
|
||||
</p>
|
||||
<div className="game-hero__actions">
|
||||
<PlatformActionButton
|
||||
size="md"
|
||||
shape="pill"
|
||||
onClick={() => featuredGame && openDetail(featuredGame.id)}
|
||||
>
|
||||
<Gamepad2 aria-hidden="true" /> 立即试玩
|
||||
</PlatformActionButton>
|
||||
<span className="game-hero__hint">支持桌面端与移动端</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="game-hero__orb" aria-hidden="true">
|
||||
<span>✦</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="game-toolbar">
|
||||
<div className="game-toolbar__title">
|
||||
<span className="game-eyebrow">发现好玩的</span>
|
||||
<h2>游戏广场</h2>
|
||||
</div>
|
||||
<div className="game-toolbar__actions">
|
||||
{onOpenPublish ? (
|
||||
<PlatformActionButton
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={onOpenPublish}
|
||||
>
|
||||
发布游戏
|
||||
</PlatformActionButton>
|
||||
) : null}
|
||||
{onOpenMyGames ? (
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={onOpenMyGames}
|
||||
>
|
||||
我的游戏
|
||||
</PlatformActionButton>
|
||||
) : null}
|
||||
<label className="game-search">
|
||||
<Search aria-hidden="true" />
|
||||
<input
|
||||
value={keywordInput}
|
||||
placeholder="搜索游戏、作者或标签"
|
||||
aria-label="搜索游戏"
|
||||
onChange={(event) => {
|
||||
const nextKeyword = event.target.value;
|
||||
setKeywordInput(nextKeyword);
|
||||
setActiveKeyword(nextKeyword);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="game-category-tabs" role="tablist" aria-label="游戏分类">
|
||||
{CATEGORIES.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={category === item}
|
||||
className={
|
||||
category === item
|
||||
? 'game-category-tab game-category-tab--active'
|
||||
: 'game-category-tab'
|
||||
}
|
||||
onClick={() => setCategory(item)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="game-category-tabs game-category-tabs--device"
|
||||
role="tablist"
|
||||
aria-label="设备筛选"
|
||||
>
|
||||
{DEVICE_FILTERS.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={device === item}
|
||||
className={
|
||||
device === item
|
||||
? 'game-category-tab game-category-tab--active'
|
||||
: 'game-category-tab'
|
||||
}
|
||||
onClick={() => setDevice(item)}
|
||||
>
|
||||
{item === '桌面' ? <Monitor aria-hidden="true" /> : null}
|
||||
{item === '移动' ? <Smartphone aria-hidden="true" /> : null}
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="game-loading-grid" aria-label="正在加载游戏">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
) : null}
|
||||
{!isLoading && error ? (
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
surface="platform"
|
||||
className="game-status-message"
|
||||
>
|
||||
<span>{error}</span>
|
||||
<button type="button" onClick={loadGames}>
|
||||
重新加载
|
||||
</button>
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
{!isLoading && !error && visibleGames.length === 0 ? (
|
||||
<PlatformEmptyState surface="subpanel" size="panel">
|
||||
<strong>还没有找到对应游戏</strong>
|
||||
<br />
|
||||
<span>
|
||||
{hasActiveFilters
|
||||
? '换个关键词、分类或设备条件再试试。'
|
||||
: '新的游戏正在路上,稍后再来看看。'}
|
||||
</span>
|
||||
{hasActiveFilters ? (
|
||||
<div className="game-empty-actions">
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={resetFilters}
|
||||
>
|
||||
重置筛选
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
) : null}
|
||||
</PlatformEmptyState>
|
||||
) : null}
|
||||
{!isLoading && !error && visibleGames.length > 0 ? (
|
||||
<div className="game-grid">
|
||||
{visibleGames.map((game) => (
|
||||
<GameCard
|
||||
key={game.id}
|
||||
game={game}
|
||||
onOpen={() => openDetail(game.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import './gameDistribution.css';
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
Expand,
|
||||
Gamepad2,
|
||||
RotateCcw,
|
||||
Smartphone,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
type GameDistributionGame,
|
||||
getGame,
|
||||
} from '../../services/gameDistributionClient';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import {
|
||||
normalizeGameEntryUrl,
|
||||
normalizeGameId,
|
||||
useIsMobileViewport,
|
||||
useIsPortraitViewport,
|
||||
} from './gameDistributionGuards';
|
||||
import { GameCover } from './GameGalleryPage';
|
||||
|
||||
type GamePlayPageProps = { gameId: string | null; onBack: () => void };
|
||||
|
||||
/** 发行包首屏加载上限:超过后给出显式超时与重试,不用无限期“正在启动”。 */
|
||||
export const GAME_PLAY_STARTUP_TIMEOUT_MS = 20_000;
|
||||
|
||||
type GameStartupState = 'idle' | 'loading' | 'ready' | 'timeout';
|
||||
|
||||
export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
|
||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||
const [game, setGame] = useState<GameDistributionGame | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [startupState, setStartupState] = useState<GameStartupState>('idle');
|
||||
const [hasStarted, setHasStarted] = useState(false);
|
||||
const [frameRunId, setFrameRunId] = useState(0);
|
||||
const [error, setError] = useState('');
|
||||
const requestSerialRef = useRef(0);
|
||||
const isMobileViewport = useIsMobileViewport();
|
||||
const isPortraitViewport = useIsPortraitViewport();
|
||||
|
||||
const loadGame = useCallback(() => {
|
||||
const requestSerial = ++requestSerialRef.current;
|
||||
const normalizedGameId = normalizeGameId(gameId);
|
||||
setGame(null);
|
||||
setHasStarted(false);
|
||||
setStartupState('idle');
|
||||
setError('');
|
||||
if (!normalizedGameId) {
|
||||
setError('缺少游戏编号');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
void getGame(normalizedGameId)
|
||||
.then((value) => {
|
||||
if (requestSerial !== requestSerialRef.current) return;
|
||||
if (!value?.currentVersion?.entryUrl) {
|
||||
throw new Error('这款游戏暂时没有可玩的版本');
|
||||
}
|
||||
const entryUrl = normalizeGameEntryUrl(value.currentVersion.entryUrl);
|
||||
if (!entryUrl) throw new Error('游戏入口地址无效');
|
||||
setGame({
|
||||
...value,
|
||||
currentVersion: { ...value.currentVersion, entryUrl },
|
||||
});
|
||||
})
|
||||
.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();
|
||||
return () => {
|
||||
requestSerialRef.current += 1;
|
||||
};
|
||||
}, [loadGame]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasStarted || startupState !== 'loading') return undefined;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setStartupState((current) =>
|
||||
current === 'loading' ? 'timeout' : current,
|
||||
);
|
||||
}, GAME_PLAY_STARTUP_TIMEOUT_MS);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [hasStarted, startupState]);
|
||||
|
||||
const enterFullscreen = () => {
|
||||
void frameRef.current?.requestFullscreen?.();
|
||||
};
|
||||
|
||||
const mobilePlaybackBlocked = isMobileViewport && !game?.deviceSupport.mobile;
|
||||
const showRotationHint =
|
||||
Boolean(game) && game?.orientation === 'landscape' && isPortraitViewport;
|
||||
const startGame = () => {
|
||||
if (mobilePlaybackBlocked) return;
|
||||
setHasStarted(true);
|
||||
setStartupState('loading');
|
||||
setFrameRunId((runId) => runId + 1);
|
||||
};
|
||||
|
||||
/** 重试只重挂载发行页,不改变已开始的会话,也不会在退出后自动重启。 */
|
||||
const retryGame = () => {
|
||||
setStartupState('loading');
|
||||
setFrameRunId((runId) => runId + 1);
|
||||
};
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="game-play-page">
|
||||
<div className="game-loading-panel">正在准备游戏…</div>
|
||||
</div>
|
||||
);
|
||||
if (error || !game?.currentVersion)
|
||||
return (
|
||||
<div className="game-play-page">
|
||||
<button type="button" className="game-back-button" onClick={onBack}>
|
||||
<ArrowLeft /> 返回详情
|
||||
</button>
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
surface="platform"
|
||||
className="game-status-message"
|
||||
>
|
||||
<span>{error || '游戏版本不可用'}</span>
|
||||
<button type="button" onClick={loadGame}>
|
||||
重新加载
|
||||
</button>
|
||||
</PlatformStatusMessage>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="game-play-page">
|
||||
<div className="game-play-toolbar">
|
||||
<button type="button" className="game-back-button" onClick={onBack}>
|
||||
<ArrowLeft /> {game.title}
|
||||
</button>
|
||||
<div className="game-play-toolbar__actions">
|
||||
<span className="game-play-device-hint">
|
||||
<Smartphone aria-hidden="true" />{' '}
|
||||
{game.deviceSupport.touch ? '支持触屏' : '桌面端游戏'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="game-icon-button"
|
||||
aria-label="重新加载游戏"
|
||||
onClick={loadGame}
|
||||
>
|
||||
<RotateCcw />
|
||||
</button>
|
||||
<PlatformActionButton
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={enterFullscreen}
|
||||
disabled={!hasStarted}
|
||||
>
|
||||
<Expand /> 全屏
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</div>
|
||||
{showRotationHint ? (
|
||||
<p className="game-device-warning" role="status">
|
||||
这款游戏以横屏设计,旋转设备即可获得完整画面。
|
||||
</p>
|
||||
) : null}
|
||||
<div className="game-player-shell">
|
||||
{!hasStarted ? (
|
||||
<div className="game-player-launch">
|
||||
<GameCover game={game} compact />
|
||||
<div className="game-player-launch__copy">
|
||||
<h1>{game.title}</h1>
|
||||
<p>{game.summary}</p>
|
||||
{mobilePlaybackBlocked ? (
|
||||
<p
|
||||
className="game-device-warning"
|
||||
role="status"
|
||||
id="game-play-mobile-warning"
|
||||
>
|
||||
请在电脑上游玩
|
||||
</p>
|
||||
) : null}
|
||||
<PlatformActionButton
|
||||
size="lg"
|
||||
shape="pill"
|
||||
onClick={startGame}
|
||||
disabled={mobilePlaybackBlocked}
|
||||
aria-describedby={
|
||||
mobilePlaybackBlocked ? 'game-play-mobile-warning' : undefined
|
||||
}
|
||||
>
|
||||
<Gamepad2 aria-hidden="true" /> 开始游戏
|
||||
</PlatformActionButton>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{hasStarted && startupState === 'loading' ? (
|
||||
<div className="game-player-loading">游戏正在启动…</div>
|
||||
) : null}
|
||||
{hasStarted && startupState === 'timeout' ? (
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
surface="platform"
|
||||
className="game-status-message game-player-timeout"
|
||||
>
|
||||
<span>游戏启动超时,可能是网络较慢或发行包暂时不可用。</span>
|
||||
<button type="button" onClick={retryGame}>
|
||||
重试
|
||||
</button>
|
||||
<button type="button" onClick={onBack}>
|
||||
返回详情
|
||||
</button>
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
{hasStarted ? (
|
||||
<iframe
|
||||
ref={frameRef}
|
||||
key={`${game.currentVersion.id}:${frameRunId}`}
|
||||
title={`${game.title} 在线游玩`}
|
||||
src={game.currentVersion.entryUrl}
|
||||
className={`game-player-frame ${startupState === 'ready' ? 'game-player-frame--ready' : ''}`}
|
||||
sandbox="allow-scripts"
|
||||
allow="fullscreen"
|
||||
referrerPolicy="no-referrer"
|
||||
onLoad={() => setStartupState('ready')}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="game-play-safety-note">
|
||||
游戏运行在隔离容器中,不会读取你的账号信息或平台数据。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GamePlayPage;
|
||||
@@ -0,0 +1,413 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import JSZip from 'jszip';
|
||||
import type { ContextType } from 'react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createGame,
|
||||
createGameVersion,
|
||||
getGameVersion,
|
||||
listMyGames,
|
||||
submitGameVersion,
|
||||
uploadGamePackage,
|
||||
} from '../../services/gameDistributionClient';
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import { resolvePublishMetadataError } from './gamePublishMetadata';
|
||||
import { GamePublishPage } from './GamePublishPage';
|
||||
|
||||
vi.mock('../../services/gameDistributionClient', () => ({
|
||||
createGame: vi.fn(),
|
||||
createGameVersion: vi.fn(),
|
||||
getGameVersion: vi.fn(),
|
||||
listMyGames: vi.fn(),
|
||||
submitGameVersion: vi.fn(),
|
||||
uploadGamePackage: vi.fn(),
|
||||
}));
|
||||
|
||||
type AuthValue = NonNullable<ContextType<typeof AuthUiContext>>;
|
||||
|
||||
function createAuthValue(overrides: Partial<AuthValue> = {}): AuthValue {
|
||||
return {
|
||||
user: {
|
||||
id: 'user-1',
|
||||
publicUserCode: '100001',
|
||||
displayName: '测试作者',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
canAccessProtectedData: true,
|
||||
openLoginModal: vi.fn(),
|
||||
requireAuth: vi.fn((action: () => void) => action()),
|
||||
openSettingsModal: vi.fn(),
|
||||
openAccountModal: vi.fn(),
|
||||
setCurrentUser: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
musicVolume: 0.5,
|
||||
setMusicVolume: vi.fn(),
|
||||
platformTheme: 'light',
|
||||
setPlatformTheme: vi.fn(),
|
||||
isHydratingSettings: false,
|
||||
isPersistingSettings: false,
|
||||
settingsError: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildZipFile() {
|
||||
const zip = new JSZip();
|
||||
zip.file('index.html', '<html><body>ok</body></html>');
|
||||
zip.file('assets/app.js', 'console.log(1)');
|
||||
const bytes = await zip.generateAsync({ type: 'uint8array' });
|
||||
return new File([bytes as unknown as BlobPart], 'game.zip', {
|
||||
type: 'application/zip',
|
||||
});
|
||||
}
|
||||
|
||||
function renderPage(authValue: AuthValue | null = createAuthValue()) {
|
||||
return render(
|
||||
<AuthUiContext.Provider value={authValue}>
|
||||
<GamePublishPage onBack={vi.fn()} onOpenMyGames={vi.fn()} />
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(createGame).mockReset();
|
||||
vi.mocked(createGameVersion).mockReset();
|
||||
vi.mocked(getGameVersion).mockReset();
|
||||
vi.mocked(listMyGames).mockReset();
|
||||
vi.mocked(uploadGamePackage).mockReset();
|
||||
vi.mocked(submitGameVersion).mockReset();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
function writeDraft(ownerUserId: string) {
|
||||
window.localStorage.setItem(
|
||||
'genarrative.game-distribution.publish-draft.v1',
|
||||
JSON.stringify({
|
||||
ownerUserId,
|
||||
gameId: 'game-1',
|
||||
versionId: 'gamever-1',
|
||||
versionNumber: 1,
|
||||
title: '上次的游戏',
|
||||
updatedAt: '2026-09-20T09:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test('元数据校验与服务端口径一致', () => {
|
||||
expect(
|
||||
resolvePublishMetadataError({
|
||||
title: ' ',
|
||||
summary: '简介',
|
||||
deviceSupport: { desktop: true, mobile: false, touch: false },
|
||||
}),
|
||||
).toBe('游戏标题必须为 1 到 40 个字符');
|
||||
expect(
|
||||
resolvePublishMetadataError({
|
||||
title: '游戏',
|
||||
summary: '简介',
|
||||
deviceSupport: { desktop: false, mobile: true, touch: false },
|
||||
}),
|
||||
).toBe('选择移动端时必须支持触控');
|
||||
expect(
|
||||
resolvePublishMetadataError({
|
||||
title: '游戏',
|
||||
summary: '简介',
|
||||
deviceSupport: { desktop: false, mobile: false, touch: false },
|
||||
}),
|
||||
).toBe('至少选择桌面端或移动端');
|
||||
expect(
|
||||
resolvePublishMetadataError({
|
||||
title: '游戏',
|
||||
summary: '简介',
|
||||
deviceSupport: { desktop: true, mobile: false, touch: false },
|
||||
}),
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
test('未登录时不提交且提示登录', () => {
|
||||
renderPage(null);
|
||||
expect(screen.getByText('登录后才能发布游戏。')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: /提交审核/u }));
|
||||
expect(createGame).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('缺少发行包时不上传', async () => {
|
||||
renderPage();
|
||||
fireEvent.change(screen.getByLabelText('游戏名称'), {
|
||||
target: { value: '测试游戏' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('一句话简介'), {
|
||||
target: { value: '测试简介' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /提交审核/u }));
|
||||
expect(await screen.findByText('请选择发行包 ZIP')).toBeTruthy();
|
||||
expect(createGame).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('按创建游戏、创建版本、上传、送审顺序提交并展示审核中', async () => {
|
||||
vi.mocked(createGame).mockResolvedValue({
|
||||
id: 'game-1',
|
||||
publicationRevision: 0,
|
||||
} as never);
|
||||
vi.mocked(createGameVersion).mockResolvedValue({
|
||||
gameId: 'game-1',
|
||||
versionId: 'gamever-1',
|
||||
versionNumber: 1,
|
||||
status: 'awaiting_upload',
|
||||
});
|
||||
vi.mocked(uploadGamePackage).mockResolvedValue({
|
||||
versionId: 'gamever-1',
|
||||
status: 'uploaded',
|
||||
});
|
||||
vi.mocked(submitGameVersion).mockResolvedValue({
|
||||
version: { status: 'pending_review' },
|
||||
});
|
||||
|
||||
renderPage();
|
||||
const file = await buildZipFile();
|
||||
fireEvent.change(screen.getByLabelText('游戏名称'), {
|
||||
target: { value: '测试游戏' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('一句话简介'), {
|
||||
target: { value: '测试简介' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/发行包 ZIP/u), {
|
||||
target: { files: [file] },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /提交审核/u }));
|
||||
|
||||
await waitFor(() => expect(submitGameVersion).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(createGame).mock.calls[0]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
title: '测试游戏',
|
||||
summary: '测试简介',
|
||||
category: '休闲',
|
||||
inputModes: ['keyboard', 'mouse', 'touch'],
|
||||
}),
|
||||
);
|
||||
expect(vi.mocked(createGameVersion).mock.calls[0]?.[0]).toBe('game-1');
|
||||
const versionPayload = vi.mocked(createGameVersion).mock.calls[0]?.[1];
|
||||
expect(versionPayload?.packageEntryPath).toBe('index.html');
|
||||
expect(versionPayload?.packageFileCount).toBe(2);
|
||||
expect(versionPayload?.packageSha256).toMatch(/^[0-9a-f]{64}$/u);
|
||||
expect(vi.mocked(uploadGamePackage).mock.calls[0]?.[0]).toBe('gamever-1');
|
||||
expect(vi.mocked(submitGameVersion).mock.calls[0]?.[1]).toBe(0);
|
||||
|
||||
expect(
|
||||
await screen.findByText('已提交审核,审核通过后会自动出现在游戏广场。'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('后端失败时不伪造成功', async () => {
|
||||
vi.mocked(createGame).mockRejectedValue(
|
||||
new Error('游戏分发服务暂不可用(503)'),
|
||||
);
|
||||
renderPage();
|
||||
const file = await buildZipFile();
|
||||
fireEvent.change(screen.getByLabelText('游戏名称'), {
|
||||
target: { value: '测试游戏' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('一句话简介'), {
|
||||
target: { value: '测试简介' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/发行包 ZIP/u), {
|
||||
target: { files: [file] },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /提交审核/u }));
|
||||
|
||||
expect(await screen.findByText('游戏分发服务暂不可用(503)')).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByText('已提交审核,审核通过后会自动出现在游戏广场。'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('同一账号回到发布页可以沿用原版本继续送审', async () => {
|
||||
writeDraft('user-1');
|
||||
vi.mocked(getGameVersion).mockResolvedValue({
|
||||
game: {
|
||||
id: 'game-1',
|
||||
title: '上次的游戏',
|
||||
summary: '上次的简介',
|
||||
description: '上次的详细介绍',
|
||||
publicationRevision: 2,
|
||||
},
|
||||
version: {
|
||||
versionId: 'gamever-1',
|
||||
versionNumber: 1,
|
||||
status: 'uploaded',
|
||||
recoveryAction: 'submit',
|
||||
},
|
||||
} as never);
|
||||
vi.mocked(submitGameVersion).mockResolvedValue({
|
||||
version: { status: 'pending_review' },
|
||||
});
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText(/上次发布未完成|发行包已上传/u)).toBeTruthy();
|
||||
fireEvent.click(await screen.findByRole('button', { name: '继续送审' }));
|
||||
|
||||
// 已上传的版本不再重复创建游戏、创建版本或上传发行包。
|
||||
expect(createGame).not.toHaveBeenCalled();
|
||||
expect(createGameVersion).not.toHaveBeenCalled();
|
||||
expect(uploadGamePackage).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(submitGameVersion).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(submitGameVersion).mock.calls[0]?.[0]).toBe('gamever-1');
|
||||
expect(vi.mocked(submitGameVersion).mock.calls[0]?.[1]).toBe(2);
|
||||
expect(
|
||||
await screen.findByText('已提交审核,审核通过后会自动出现在游戏广场。'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
window.localStorage.getItem(
|
||||
'genarrative.game-distribution.publish-draft.v1',
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('换账号不回读上一账号的发布草稿', async () => {
|
||||
writeDraft('user-1');
|
||||
renderPage(
|
||||
createAuthValue({
|
||||
user: { ...createAuthValue().user!, id: 'user-2' },
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText(/上次发布未完成/u)).toBeNull());
|
||||
expect(getGameVersion).not.toHaveBeenCalled();
|
||||
// 草稿仍属于原账号,换账号只忽略、不删除。
|
||||
expect(
|
||||
window.localStorage.getItem(
|
||||
'genarrative.game-distribution.publish-draft.v1',
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
test('更新模式在既有 gameId 下创建新版本并沿用公开修订号', async () => {
|
||||
vi.mocked(listMyGames).mockResolvedValue([
|
||||
{
|
||||
id: 'game-1',
|
||||
title: '星轨防线',
|
||||
summary: '守住轨道城',
|
||||
description: '旧版资料',
|
||||
category: '动作',
|
||||
tags: ['塔防'],
|
||||
publicationRevision: 4,
|
||||
latestVersion: {
|
||||
versionId: 'gamever-2',
|
||||
gameId: 'game-1',
|
||||
versionNumber: 2,
|
||||
packageSha256: 'd'.repeat(64),
|
||||
packageBytes: 1024,
|
||||
status: 'published',
|
||||
publicationRevision: 4,
|
||||
reviewReason: null,
|
||||
createdAt: '2026-09-20T08:00:00Z',
|
||||
updatedAt: '2026-09-20T09:00:00Z',
|
||||
},
|
||||
versions: [],
|
||||
} as never,
|
||||
]);
|
||||
vi.mocked(getGameVersion).mockResolvedValue({
|
||||
game: {
|
||||
id: 'game-1',
|
||||
title: '星轨防线',
|
||||
summary: '守住轨道城',
|
||||
description: '旧版资料',
|
||||
category: '动作',
|
||||
tags: ['塔防'],
|
||||
publicationRevision: 4,
|
||||
},
|
||||
version: {
|
||||
versionId: 'gamever-2',
|
||||
versionNumber: 2,
|
||||
status: 'published',
|
||||
publicationRevision: 4,
|
||||
recoveryAction: 'none',
|
||||
},
|
||||
} as never);
|
||||
vi.mocked(createGameVersion).mockResolvedValue({
|
||||
gameId: 'game-1',
|
||||
versionId: 'gamever-3',
|
||||
versionNumber: 3,
|
||||
status: 'awaiting_upload',
|
||||
});
|
||||
vi.mocked(uploadGamePackage).mockResolvedValue({
|
||||
versionId: 'gamever-3',
|
||||
status: 'uploaded',
|
||||
});
|
||||
vi.mocked(submitGameVersion).mockResolvedValue({
|
||||
version: { status: 'pending_review' },
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthUiContext.Provider value={createAuthValue()}>
|
||||
<GamePublishPage
|
||||
onBack={vi.fn()}
|
||||
onOpenMyGames={vi.fn()}
|
||||
updateGameId="game-1"
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/正在为《星轨防线》发布新版本 v3/u),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByLabelText('游戏名称')).toHaveProperty('value', '星轨防线');
|
||||
expect(screen.getByLabelText('分类')).toHaveProperty('value', '动作');
|
||||
|
||||
const file = await buildZipFile();
|
||||
fireEvent.change(screen.getByLabelText(/发行包 ZIP/u), {
|
||||
target: { files: [file] },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交新版本审核' }));
|
||||
|
||||
await waitFor(() => expect(submitGameVersion).toHaveBeenCalledTimes(1));
|
||||
// 更新模式不得新建游戏,必须在同一个 gameId 下创建新版本。
|
||||
expect(createGame).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(createGameVersion).mock.calls[0]?.[0]).toBe('game-1');
|
||||
expect(vi.mocked(createGameVersion).mock.calls[0]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
packageEntryPath: 'index.html',
|
||||
gameMetadata: expect.objectContaining({
|
||||
title: '星轨防线',
|
||||
category: '动作',
|
||||
tags: ['塔防'],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(vi.mocked(submitGameVersion).mock.calls[0]?.[0]).toBe('gamever-3');
|
||||
expect(vi.mocked(submitGameVersion).mock.calls[0]?.[1]).toBe(4);
|
||||
expect(
|
||||
await screen.findByText('已提交审核,审核通过后会自动出现在游戏广场。'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('更新模式读不到归属游戏时失败关闭且不提交', async () => {
|
||||
vi.mocked(listMyGames).mockResolvedValue([]);
|
||||
|
||||
render(
|
||||
<AuthUiContext.Provider value={createAuthValue()}>
|
||||
<GamePublishPage
|
||||
onBack={vi.fn()}
|
||||
onOpenMyGames={vi.fn()}
|
||||
updateGameId="game-missing"
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText('找不到这个游戏,或它不属于当前账号'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '提交新版本审核' })).toHaveProperty(
|
||||
'disabled',
|
||||
true,
|
||||
);
|
||||
expect(createGameVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,252 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import type { ContextType } from 'react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
cancelGameVersion,
|
||||
type GameDistributionMyGame,
|
||||
listMyGames,
|
||||
unpublishGame,
|
||||
} from '../../services/gameDistributionClient';
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import { MyGamesPage } from './MyGamesPage';
|
||||
|
||||
vi.mock('../../services/gameDistributionClient', () => ({
|
||||
cancelGameVersion: vi.fn(),
|
||||
listMyGames: vi.fn(),
|
||||
unpublishGame: vi.fn(),
|
||||
}));
|
||||
|
||||
type AuthValue = NonNullable<ContextType<typeof AuthUiContext>>;
|
||||
|
||||
function createAuthValue(overrides: Partial<AuthValue> = {}): AuthValue {
|
||||
return {
|
||||
user: {
|
||||
id: 'user-1',
|
||||
publicUserCode: '100001',
|
||||
displayName: '测试作者',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
canAccessProtectedData: true,
|
||||
openLoginModal: vi.fn(),
|
||||
requireAuth: vi.fn((action: () => void) => action()),
|
||||
openSettingsModal: vi.fn(),
|
||||
openAccountModal: vi.fn(),
|
||||
setCurrentUser: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
musicVolume: 0.5,
|
||||
setMusicVolume: vi.fn(),
|
||||
platformTheme: 'light',
|
||||
setPlatformTheme: vi.fn(),
|
||||
isHydratingSettings: false,
|
||||
isPersistingSettings: false,
|
||||
settingsError: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createGame(
|
||||
overrides: Partial<GameDistributionMyGame> = {},
|
||||
): GameDistributionMyGame {
|
||||
return {
|
||||
id: 'game-1',
|
||||
title: '我的游戏',
|
||||
summary: '作者自有游戏',
|
||||
description: '',
|
||||
category: '益智',
|
||||
tags: [],
|
||||
coverColor: '#49316d',
|
||||
icon: '✦',
|
||||
author: { id: 'user-1', name: '测试作者' },
|
||||
deviceSupport: { desktop: true, mobile: false, touch: false },
|
||||
status: 'unpublished',
|
||||
publicationRevision: 2,
|
||||
currentVersion: null,
|
||||
playCount: 3,
|
||||
createdAt: '2026-09-20T08:00:00Z',
|
||||
...overrides,
|
||||
} as GameDistributionMyGame;
|
||||
}
|
||||
|
||||
function renderPage(
|
||||
authValue: AuthValue | null,
|
||||
overrides: { onPublishVersion?: (gameId: string) => void } = {},
|
||||
) {
|
||||
return render(
|
||||
<AuthUiContext.Provider value={authValue}>
|
||||
<MyGamesPage
|
||||
onBack={vi.fn()}
|
||||
onOpenDetail={vi.fn()}
|
||||
onPublishVersion={overrides.onPublishVersion}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(cancelGameVersion).mockReset();
|
||||
vi.mocked(listMyGames).mockReset();
|
||||
vi.mocked(unpublishGame).mockReset();
|
||||
vi.mocked(listMyGames).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
test('未登录时提示登录且不请求作者接口', () => {
|
||||
renderPage(null);
|
||||
expect(
|
||||
screen.getByText('登录后可以查看已发布的游戏与审核状态。'),
|
||||
).toBeTruthy();
|
||||
expect(listMyGames).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('展示审核中状态与驳回理由', async () => {
|
||||
vi.mocked(listMyGames).mockResolvedValue([
|
||||
createGame({
|
||||
status: 'unpublished',
|
||||
latestVersion: {
|
||||
versionId: 'version-1',
|
||||
gameId: 'game-1',
|
||||
versionNumber: 3,
|
||||
packageSha256: 'a'.repeat(64),
|
||||
packageBytes: 1024,
|
||||
status: 'rejected',
|
||||
publicationRevision: 2,
|
||||
reviewReason: '封面需要替换',
|
||||
createdAt: '2026-09-20T08:00:00Z',
|
||||
updatedAt: '2026-09-20T09:00:00Z',
|
||||
},
|
||||
versions: [],
|
||||
}),
|
||||
]);
|
||||
|
||||
renderPage(createAuthValue());
|
||||
|
||||
expect(await screen.findByText('我的游戏')).toBeTruthy();
|
||||
expect(screen.getByText(/版本 v3 · 已拒绝/)).toBeTruthy();
|
||||
expect(screen.getByText('驳回理由:封面需要替换')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('下架已公开游戏会带上当前 publicationRevision', async () => {
|
||||
const game = createGame({
|
||||
status: 'published',
|
||||
publicationRevision: 7,
|
||||
latestVersion: null,
|
||||
versions: [],
|
||||
});
|
||||
vi.mocked(listMyGames).mockResolvedValue([game]);
|
||||
vi.mocked(unpublishGame).mockResolvedValue({
|
||||
game: { id: 'game-1', status: 'unpublished' },
|
||||
});
|
||||
|
||||
renderPage(createAuthValue());
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '下架' }));
|
||||
|
||||
await waitFor(() => expect(unpublishGame).toHaveBeenCalledTimes(1));
|
||||
const [gameId, revision, idempotencyKey] =
|
||||
vi.mocked(unpublishGame).mock.calls[0] ?? [];
|
||||
expect(gameId).toBe('game-1');
|
||||
expect(revision).toBe(7);
|
||||
expect(String(idempotencyKey)).toContain('game-1');
|
||||
await waitFor(() => expect(listMyGames).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
test('未公开游戏不提供下架动作', async () => {
|
||||
vi.mocked(listMyGames).mockResolvedValue([
|
||||
createGame({ status: 'unpublished' }),
|
||||
]);
|
||||
renderPage(createAuthValue());
|
||||
await screen.findByText('我的游戏');
|
||||
expect(screen.queryByRole('button', { name: '下架' })).toBeNull();
|
||||
});
|
||||
|
||||
test('审核中的版本二次确认后撤回并带上 publicationRevision', async () => {
|
||||
vi.mocked(listMyGames).mockResolvedValue([
|
||||
createGame({
|
||||
status: 'unpublished',
|
||||
publicationRevision: 5,
|
||||
latestVersion: {
|
||||
versionId: 'version-9',
|
||||
gameId: 'game-1',
|
||||
versionNumber: 2,
|
||||
packageSha256: 'b'.repeat(64),
|
||||
packageBytes: 2048,
|
||||
status: 'pending_review',
|
||||
publicationRevision: 5,
|
||||
reviewReason: null,
|
||||
createdAt: '2026-09-20T08:00:00Z',
|
||||
updatedAt: '2026-09-20T09:00:00Z',
|
||||
},
|
||||
versions: [],
|
||||
}),
|
||||
]);
|
||||
vi.mocked(cancelGameVersion).mockResolvedValue({
|
||||
game: {} as never,
|
||||
version: {} as never,
|
||||
replayed: false,
|
||||
});
|
||||
|
||||
renderPage(createAuthValue());
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '撤回审核' }));
|
||||
// 首次点击只进入确认态,不发送请求。
|
||||
expect(cancelGameVersion).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('button', { name: '取消' })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认撤回' }));
|
||||
|
||||
await waitFor(() => expect(cancelGameVersion).toHaveBeenCalledTimes(1));
|
||||
const [versionId, revision, idempotencyKey] =
|
||||
vi.mocked(cancelGameVersion).mock.calls[0] ?? [];
|
||||
expect(versionId).toBe('version-9');
|
||||
expect(revision).toBe(5);
|
||||
expect(String(idempotencyKey)).toContain('version-9');
|
||||
await waitFor(() => expect(listMyGames).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
test('已公开版本只能下架,不显示撤回审核', async () => {
|
||||
vi.mocked(listMyGames).mockResolvedValue([
|
||||
createGame({
|
||||
status: 'published',
|
||||
publicationRevision: 4,
|
||||
latestVersion: {
|
||||
versionId: 'version-3',
|
||||
gameId: 'game-1',
|
||||
versionNumber: 3,
|
||||
packageSha256: 'c'.repeat(64),
|
||||
packageBytes: 4096,
|
||||
status: 'published',
|
||||
publicationRevision: 4,
|
||||
reviewReason: null,
|
||||
createdAt: '2026-09-20T08:00:00Z',
|
||||
updatedAt: '2026-09-20T09:00:00Z',
|
||||
},
|
||||
versions: [],
|
||||
}),
|
||||
]);
|
||||
|
||||
renderPage(createAuthValue());
|
||||
|
||||
await screen.findByText('我的游戏');
|
||||
expect(screen.queryByRole('button', { name: '撤回审核' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: '下架' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('作者中心提供发布新版本入口并回传同一个 gameId', async () => {
|
||||
vi.mocked(listMyGames).mockResolvedValue([
|
||||
createGame({ id: 'game-42', status: 'published', publicationRevision: 3 }),
|
||||
]);
|
||||
const onPublishVersion = vi.fn();
|
||||
|
||||
renderPage(createAuthValue(), { onPublishVersion });
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '发布新版本' }));
|
||||
|
||||
expect(onPublishVersion).toHaveBeenCalledWith('game-42');
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
import './gameDistribution.css';
|
||||
|
||||
import { ArrowLeft, RefreshCcw } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
cancelGameVersion,
|
||||
type GameDistributionMyGame,
|
||||
type GameDistributionVersionStatusEntry,
|
||||
listMyGames,
|
||||
unpublishGame,
|
||||
} from '../../services/gameDistributionClient';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { GameCover } from './GameGalleryPage';
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
unpublished: '未公开',
|
||||
published: '已公开',
|
||||
suspended: '已被封禁',
|
||||
};
|
||||
|
||||
const VERSION_STATUS_LABEL: Record<string, string> = {
|
||||
awaiting_upload: '待上传',
|
||||
uploaded: '已上传',
|
||||
validating: '校验中',
|
||||
pending_review: '审核中',
|
||||
published: '已发布',
|
||||
upload_failed: '上传失败',
|
||||
validation_failed: '校验失败',
|
||||
rejected: '已拒绝',
|
||||
cancelled: '已撤回',
|
||||
revoked: '已撤回',
|
||||
};
|
||||
|
||||
type MyGamesPageProps = {
|
||||
onBack: () => void;
|
||||
onOpenDetail: (gameId: string) => void;
|
||||
/** 进入发布页的“新版本”模式;回调缺失时不显示该动作。 */
|
||||
onPublishVersion?: (gameId: string) => void;
|
||||
};
|
||||
|
||||
let unpublishKeySeed = 0;
|
||||
function createUnpublishKey(gameId: string) {
|
||||
unpublishKeySeed += 1;
|
||||
return `game-unpublish-${gameId}-${Date.now()}-${unpublishKeySeed}`;
|
||||
}
|
||||
|
||||
/** 与服务端 `can_cancel` 保持一致:已公开版本只能下架,不能撤回。 */
|
||||
const CANCELLABLE_VERSION_STATUS = new Set([
|
||||
'awaiting_upload',
|
||||
'uploaded',
|
||||
'validating',
|
||||
'pending_review',
|
||||
'upload_failed',
|
||||
'validation_failed',
|
||||
]);
|
||||
|
||||
let cancelKeySeed = 0;
|
||||
function createCancelKey(versionId: string) {
|
||||
cancelKeySeed += 1;
|
||||
return `game-cancel-${versionId}-${Date.now()}-${cancelKeySeed}`;
|
||||
}
|
||||
|
||||
function resolveVersionStatus(game: GameDistributionMyGame) {
|
||||
const latest = game.latestVersion;
|
||||
if (!latest) return null;
|
||||
return {
|
||||
label: VERSION_STATUS_LABEL[latest.status] ?? latest.status,
|
||||
versionNumber: latest.versionNumber,
|
||||
reviewReason: latest.reviewReason?.trim() || '',
|
||||
};
|
||||
}
|
||||
|
||||
export function MyGamesPage({
|
||||
onBack,
|
||||
onOpenDetail,
|
||||
onPublishVersion,
|
||||
}: MyGamesPageProps) {
|
||||
const authUi = useAuthUi();
|
||||
const [games, setGames] = useState<GameDistributionMyGame[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [busyGameId, setBusyGameId] = useState('');
|
||||
const [cancellingVersionId, setCancellingVersionId] = useState('');
|
||||
const [confirmingVersionId, setConfirmingVersionId] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const canLoad = Boolean(authUi?.user && authUi.canAccessProtectedData);
|
||||
|
||||
const loadGames = useCallback(async () => {
|
||||
if (!canLoad) {
|
||||
setGames([]);
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
setGames(await listMyGames());
|
||||
} catch (loadError: unknown) {
|
||||
setError(
|
||||
loadError instanceof Error ? loadError.message : '读取我的游戏失败',
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [canLoad]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadGames();
|
||||
}, [loadGames]);
|
||||
|
||||
async function handleCancelVersion(
|
||||
game: GameDistributionMyGame,
|
||||
version: GameDistributionVersionStatusEntry,
|
||||
) {
|
||||
setCancellingVersionId(version.versionId);
|
||||
setConfirmingVersionId('');
|
||||
setError('');
|
||||
setNotice('');
|
||||
try {
|
||||
await cancelGameVersion(
|
||||
version.versionId,
|
||||
version.publicationRevision,
|
||||
createCancelKey(version.versionId),
|
||||
);
|
||||
setNotice(`版本 v${version.versionNumber} 已撤回,可以重新上传发行包。`);
|
||||
await loadGames();
|
||||
} catch (cancelError: unknown) {
|
||||
setError(
|
||||
cancelError instanceof Error ? cancelError.message : '撤回版本失败',
|
||||
);
|
||||
} finally {
|
||||
setCancellingVersionId('');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnpublish(game: GameDistributionMyGame) {
|
||||
setBusyGameId(game.id);
|
||||
setError('');
|
||||
try {
|
||||
await unpublishGame(
|
||||
game.id,
|
||||
game.publicationRevision,
|
||||
createUnpublishKey(game.id),
|
||||
);
|
||||
await loadGames();
|
||||
} catch (unpublishError: unknown) {
|
||||
setError(
|
||||
unpublishError instanceof Error
|
||||
? unpublishError.message
|
||||
: '下架游戏失败',
|
||||
);
|
||||
} finally {
|
||||
setBusyGameId('');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="game-page">
|
||||
<button type="button" className="game-back-button" onClick={onBack}>
|
||||
<ArrowLeft /> 返回游戏广场
|
||||
</button>
|
||||
<div className="game-toolbar">
|
||||
<div className="game-toolbar__title">
|
||||
<span className="game-eyebrow">作者中心</span>
|
||||
<h2>我的游戏</h2>
|
||||
</div>
|
||||
{canLoad ? (
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => void loadGames()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCcw aria-hidden="true" /> 刷新
|
||||
</PlatformActionButton>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<PlatformStatusMessage tone="error" surface="platform">
|
||||
{error}
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
|
||||
{notice ? (
|
||||
<PlatformStatusMessage tone="success" surface="platform">
|
||||
{notice}
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
|
||||
{!canLoad ? (
|
||||
<PlatformEmptyState surface="subpanel" size="panel">
|
||||
<span>登录后可以查看已发布的游戏与审核状态。</span>
|
||||
<PlatformActionButton
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => authUi?.openLoginModal(() => void loadGames())}
|
||||
>
|
||||
登录
|
||||
</PlatformActionButton>
|
||||
</PlatformEmptyState>
|
||||
) : null}
|
||||
|
||||
{canLoad && isLoading ? (
|
||||
<div className="game-loading-panel">正在加载我的游戏…</div>
|
||||
) : null}
|
||||
|
||||
{canLoad && !isLoading && games.length === 0 && !error ? (
|
||||
<PlatformEmptyState surface="subpanel" size="panel">
|
||||
<span>还没有在平台上发布过游戏。</span>
|
||||
</PlatformEmptyState>
|
||||
) : null}
|
||||
|
||||
{canLoad && games.length > 0 ? (
|
||||
<div className="my-game-list">
|
||||
{games.map((game) => {
|
||||
const version = resolveVersionStatus(game);
|
||||
const latestVersion = game.latestVersion;
|
||||
return (
|
||||
<article key={game.id} className="my-game-card">
|
||||
<GameCover game={game} compact />
|
||||
<div className="my-game-card__body">
|
||||
<div className="my-game-card__heading">
|
||||
<button
|
||||
type="button"
|
||||
className="my-game-card__title"
|
||||
onClick={() => onOpenDetail(game.id)}
|
||||
>
|
||||
{game.title}
|
||||
</button>
|
||||
<span
|
||||
className={`my-game-status my-game-status--${game.status}`}
|
||||
>
|
||||
{STATUS_LABEL[game.status] ?? game.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="my-game-card__summary">{game.summary}</p>
|
||||
<div className="my-game-card__meta">
|
||||
<span>{game.category}</span>
|
||||
<span>·</span>
|
||||
<span>{game.playCount.toLocaleString()} 次游玩</span>
|
||||
{version ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>
|
||||
版本 v{version.versionNumber} · {version.label}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{version?.reviewReason ? (
|
||||
<p className="my-game-card__reason">
|
||||
驳回理由:{version.reviewReason}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="my-game-card__actions">
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => onOpenDetail(game.id)}
|
||||
>
|
||||
查看详情
|
||||
</PlatformActionButton>
|
||||
{onPublishVersion ? (
|
||||
<PlatformActionButton
|
||||
tone="primary"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => onPublishVersion(game.id)}
|
||||
>
|
||||
发布新版本
|
||||
</PlatformActionButton>
|
||||
) : null}
|
||||
{game.status === 'published' ? (
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
disabled={busyGameId === game.id}
|
||||
onClick={() => void handleUnpublish(game)}
|
||||
>
|
||||
下架
|
||||
</PlatformActionButton>
|
||||
) : null}
|
||||
{latestVersion &&
|
||||
CANCELLABLE_VERSION_STATUS.has(latestVersion.status) ? (
|
||||
confirmingVersionId === latestVersion.versionId ? (
|
||||
<>
|
||||
<PlatformActionButton
|
||||
tone="danger"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
disabled={
|
||||
cancellingVersionId === latestVersion.versionId
|
||||
}
|
||||
onClick={() =>
|
||||
void handleCancelVersion(game, latestVersion)
|
||||
}
|
||||
>
|
||||
确认撤回
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
disabled={
|
||||
cancellingVersionId === latestVersion.versionId
|
||||
}
|
||||
onClick={() => setConfirmingVersionId('')}
|
||||
>
|
||||
取消
|
||||
</PlatformActionButton>
|
||||
</>
|
||||
) : (
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
disabled={
|
||||
cancellingVersionId === latestVersion.versionId
|
||||
}
|
||||
onClick={() =>
|
||||
setConfirmingVersionId(latestVersion.versionId)
|
||||
}
|
||||
>
|
||||
撤回审核
|
||||
</PlatformActionButton>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MyGamesPage;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const MAX_GAME_ID_LENGTH = 128;
|
||||
const MAX_ENTRY_URL_LENGTH = 4096;
|
||||
|
||||
function containsControlCharacter(value: string) {
|
||||
for (const character of value) {
|
||||
const codePoint = character.codePointAt(0);
|
||||
if (codePoint !== undefined && (codePoint < 0x20 || codePoint === 0x7f)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route query values are untrusted input. Keep the client side guard small and
|
||||
* permissive enough for server assigned ids while rejecting values that can
|
||||
* become ambiguous path segments or control characters in diagnostics.
|
||||
*/
|
||||
export function normalizeGameId(value: string | null | undefined) {
|
||||
const normalized = value?.trim() ?? '';
|
||||
if (
|
||||
!normalized ||
|
||||
normalized.length > MAX_GAME_ID_LENGTH ||
|
||||
containsControlCharacter(normalized)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* The API is the authority for which release host is allowed. The browser
|
||||
* still refuses executable URL schemes and credential-bearing URLs before a
|
||||
* value can reach an iframe.
|
||||
*/
|
||||
export function normalizeGameEntryUrl(value: string | null | undefined) {
|
||||
const normalized = value?.trim() ?? '';
|
||||
if (
|
||||
!normalized ||
|
||||
normalized.length > MAX_ENTRY_URL_LENGTH ||
|
||||
containsControlCharacter(normalized)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(
|
||||
normalized,
|
||||
typeof window === 'undefined'
|
||||
? 'http://localhost'
|
||||
: window.location.origin,
|
||||
);
|
||||
const isLocalDevelopmentHttp =
|
||||
url.protocol === 'http:' &&
|
||||
(url.hostname === 'localhost' ||
|
||||
url.hostname === '127.0.0.1' ||
|
||||
url.hostname === '[::1]');
|
||||
if (
|
||||
(url.protocol !== 'https:' && !isLocalDevelopmentHttp) ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
(typeof window !== 'undefined' &&
|
||||
url.origin === window.location.origin &&
|
||||
!import.meta.env.DEV)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return url.href;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readMobileViewport() {
|
||||
if (typeof window === 'undefined') return false;
|
||||
if (typeof window.matchMedia === 'function') {
|
||||
return window.matchMedia('(max-width: 820px)').matches;
|
||||
}
|
||||
return window.innerWidth <= 820;
|
||||
}
|
||||
|
||||
export function useIsMobileViewport() {
|
||||
const [isMobileViewport, setIsMobileViewport] = useState(readMobileViewport);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return undefined;
|
||||
|
||||
const mediaQuery =
|
||||
typeof window.matchMedia === 'function'
|
||||
? window.matchMedia('(max-width: 820px)')
|
||||
: null;
|
||||
const update = () => setIsMobileViewport(readMobileViewport());
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
mediaQuery?.addEventListener?.('change', update);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', update);
|
||||
mediaQuery?.removeEventListener?.('change', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isMobileViewport;
|
||||
}
|
||||
|
||||
function readPortraitViewport() {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const { innerHeight, innerWidth } = window;
|
||||
if (
|
||||
typeof innerHeight === 'number' &&
|
||||
typeof innerWidth === 'number' &&
|
||||
innerHeight > 0 &&
|
||||
innerWidth > 0 &&
|
||||
innerHeight !== innerWidth
|
||||
) {
|
||||
return innerHeight > innerWidth;
|
||||
}
|
||||
if (typeof window.matchMedia === 'function') {
|
||||
return window.matchMedia('(orientation: portrait)').matches;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 横屏游戏的旋转提示只在竖屏视口出现;视口比例优先,matchMedia 作为兜底,
|
||||
* 让没有方向查询能力的嵌入浏览器也能给出提示。
|
||||
*/
|
||||
export function useIsPortraitViewport() {
|
||||
const [isPortrait, setIsPortrait] = useState(readPortraitViewport);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return undefined;
|
||||
|
||||
const mediaQuery =
|
||||
typeof window.matchMedia === 'function'
|
||||
? window.matchMedia('(orientation: portrait)')
|
||||
: null;
|
||||
const update = () => setIsPortrait(readPortraitViewport());
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
mediaQuery?.addEventListener?.('change', update);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', update);
|
||||
mediaQuery?.removeEventListener?.('change', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isPortrait;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import type {
|
||||
GameCategory,
|
||||
GameDistributionGame,
|
||||
} from '../../services/gameDistributionClient';
|
||||
|
||||
export const GAME_GALLERY_CATEGORIES: Array<GameCategory | '全部'> = [
|
||||
'全部',
|
||||
'动作',
|
||||
'益智',
|
||||
'冒险',
|
||||
'休闲',
|
||||
'模拟',
|
||||
'策略',
|
||||
'其他',
|
||||
];
|
||||
|
||||
export type GameDeviceFilter = '全部' | '桌面' | '移动';
|
||||
|
||||
export const GAME_GALLERY_DEVICE_FILTERS: GameDeviceFilter[] = [
|
||||
'全部',
|
||||
'桌面',
|
||||
'移动',
|
||||
];
|
||||
|
||||
/** 目录筛选写入地址栏,分享和刷新都能回到同一份上下文。 */
|
||||
const GALLERY_QUERY_KEYS = {
|
||||
keyword: 'keyword',
|
||||
category: 'category',
|
||||
device: 'device',
|
||||
} as const;
|
||||
|
||||
const GALLERY_STORAGE_KEYS = {
|
||||
filters: 'game-gallery-filters',
|
||||
scroll: 'game-gallery-scroll',
|
||||
} as const;
|
||||
|
||||
/** 画廊挂在平台页签的滚动容器里,返回时按同一容器恢复位置。 */
|
||||
export const GAME_GALLERY_SCROLL_CONTAINER_SELECTOR = '.platform-tab-panel';
|
||||
|
||||
export type GameGalleryFilters = {
|
||||
keyword: string;
|
||||
category: GameCategory | '全部';
|
||||
device: GameDeviceFilter;
|
||||
};
|
||||
|
||||
function readGalleryStorage() {
|
||||
try {
|
||||
return globalThis.sessionStorage ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCategory(value: string | null | undefined) {
|
||||
const normalized = value?.trim() ?? '';
|
||||
return (
|
||||
GAME_GALLERY_CATEGORIES.find(
|
||||
(item) => item !== '全部' && item === normalized,
|
||||
) ?? '全部'
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDevice(value: string | null | undefined) {
|
||||
const normalized = value?.trim() ?? '';
|
||||
return (
|
||||
GAME_GALLERY_DEVICE_FILTERS.find((item) => item === normalized) ?? '全部'
|
||||
);
|
||||
}
|
||||
|
||||
export function gameGalleryScrollKey(filters: GameGalleryFilters) {
|
||||
return `${GALLERY_STORAGE_KEYS.scroll}:${filters.keyword.trim()}|${filters.category}|${filters.device}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 只有地址栏里明确出现的筛选项才覆盖返回上下文,避免把用户分享的
|
||||
* `?category=动作` 当成“其他筛选项已被清空”。
|
||||
*/
|
||||
export function readGameGalleryQueryFilters(
|
||||
search: string,
|
||||
): Partial<GameGalleryFilters> {
|
||||
const params = new URLSearchParams(search);
|
||||
const filters: Partial<GameGalleryFilters> = {};
|
||||
const keyword = params.get(GALLERY_QUERY_KEYS.keyword);
|
||||
if (keyword !== null) filters.keyword = keyword;
|
||||
const category = params.get(GALLERY_QUERY_KEYS.category);
|
||||
if (category !== null) filters.category = normalizeCategory(category);
|
||||
const device = params.get(GALLERY_QUERY_KEYS.device);
|
||||
if (device !== null) filters.device = normalizeDevice(device);
|
||||
return filters;
|
||||
}
|
||||
|
||||
export function readStoredGameGalleryFilters(): Partial<GameGalleryFilters> {
|
||||
const storage = readGalleryStorage();
|
||||
if (!storage) return {};
|
||||
try {
|
||||
const raw = storage.getItem(GALLERY_STORAGE_KEYS.filters);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw) as Partial<GameGalleryFilters> | null;
|
||||
if (!parsed || typeof parsed !== 'object') return {};
|
||||
const filters: Partial<GameGalleryFilters> = {};
|
||||
if (typeof parsed.keyword === 'string') filters.keyword = parsed.keyword;
|
||||
if (typeof parsed.category === 'string') {
|
||||
filters.category = normalizeCategory(parsed.category);
|
||||
}
|
||||
if (typeof parsed.device === 'string') {
|
||||
filters.device = normalizeDevice(parsed.device);
|
||||
}
|
||||
return filters;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredGameGalleryFilters(filters: GameGalleryFilters) {
|
||||
const storage = readGalleryStorage();
|
||||
if (!storage) return;
|
||||
try {
|
||||
storage.setItem(GALLERY_STORAGE_KEYS.filters, JSON.stringify(filters));
|
||||
} catch {
|
||||
// 存储被禁用时筛选仍在本页生效,只是刷新后不再恢复。
|
||||
}
|
||||
}
|
||||
|
||||
export function readStoredGameGalleryScrollTop(key: string) {
|
||||
const storage = readGalleryStorage();
|
||||
if (!storage) return 0;
|
||||
try {
|
||||
const value = Number(storage.getItem(key));
|
||||
return Number.isFinite(value) && value > 0 ? value : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredGameGalleryScrollTop(
|
||||
key: string,
|
||||
scrollTop: number,
|
||||
) {
|
||||
const storage = readGalleryStorage();
|
||||
if (!storage) return;
|
||||
try {
|
||||
if (scrollTop > 0) storage.setItem(key, String(Math.round(scrollTop)));
|
||||
else storage.removeItem(key);
|
||||
} catch {
|
||||
// 忽略存储失败,滚动位置不是业务状态。
|
||||
}
|
||||
}
|
||||
|
||||
export function syncGameGalleryQuery(filters: GameGalleryFilters) {
|
||||
if (typeof window === 'undefined' || !window.history?.replaceState) return;
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
const assign = (key: string, value: string) => {
|
||||
if (value) url.searchParams.set(key, value);
|
||||
else url.searchParams.delete(key);
|
||||
};
|
||||
assign(GALLERY_QUERY_KEYS.keyword, filters.keyword.trim());
|
||||
assign(
|
||||
GALLERY_QUERY_KEYS.category,
|
||||
filters.category === '全部' ? '' : filters.category,
|
||||
);
|
||||
assign(
|
||||
GALLERY_QUERY_KEYS.device,
|
||||
filters.device === '全部' ? '' : filters.device,
|
||||
);
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
'',
|
||||
`${url.pathname}${url.search}${url.hash}`,
|
||||
);
|
||||
} catch {
|
||||
// 地址栏同步失败不影响筛选本身。
|
||||
}
|
||||
}
|
||||
|
||||
export function matchesGameDeviceFilter(
|
||||
game: GameDistributionGame,
|
||||
device: GameDeviceFilter,
|
||||
) {
|
||||
if (device === '桌面') return game.deviceSupport.desktop;
|
||||
if (device === '移动') return game.deviceSupport.mobile;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { GameDistributionRecoveryAction } from '../../../packages/shared/src/contracts/gameDistribution';
|
||||
|
||||
/**
|
||||
* 网页发布的恢复标识。
|
||||
*
|
||||
* 只保存服务端已经分配的游戏/版本标识与本地标题,用于窗口关闭或网络中断后回到同一
|
||||
* 版本继续上传;不保存 ZIP 字节、凭据或账号资料。`ownerUserId` 用于换账号时拒绝恢复,
|
||||
* 避免把上一个账号的私有状态展示给新账号。
|
||||
*/
|
||||
export type GamePublishDraft = {
|
||||
ownerUserId: string;
|
||||
gameId: string;
|
||||
versionId: string;
|
||||
versionNumber: number;
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export const GAME_PUBLISH_DRAFT_STORAGE_KEY =
|
||||
'genarrative.game-distribution.publish-draft.v1';
|
||||
|
||||
type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
||||
|
||||
function resolveStorage(storage?: StorageLike): StorageLike | null {
|
||||
if (storage) return storage;
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
// 某些隐私模式会直接抛 SecurityError,读不到存储时放弃恢复能力而不是让页面崩溃。
|
||||
return window.localStorage ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isDraft(value: unknown): value is GamePublishDraft {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const draft = value as Partial<GamePublishDraft>;
|
||||
return Boolean(
|
||||
draft.ownerUserId &&
|
||||
draft.gameId &&
|
||||
draft.versionId &&
|
||||
typeof draft.versionNumber === 'number' &&
|
||||
typeof draft.title === 'string',
|
||||
);
|
||||
}
|
||||
|
||||
export function readPublishDraft(
|
||||
storage?: StorageLike,
|
||||
): GamePublishDraft | null {
|
||||
const target = resolveStorage(storage);
|
||||
if (!target) return null;
|
||||
try {
|
||||
const raw = target.getItem(GAME_PUBLISH_DRAFT_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return isDraft(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writePublishDraft(
|
||||
draft: GamePublishDraft,
|
||||
storage?: StorageLike,
|
||||
): void {
|
||||
const target = resolveStorage(storage);
|
||||
if (!target) return;
|
||||
try {
|
||||
target.setItem(GAME_PUBLISH_DRAFT_STORAGE_KEY, JSON.stringify(draft));
|
||||
} catch {
|
||||
// 隐私模式或配额不足时放弃恢复能力,不影响本次发布。
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPublishDraft(storage?: StorageLike): void {
|
||||
const target = resolveStorage(storage);
|
||||
if (!target) return;
|
||||
try {
|
||||
target.removeItem(GAME_PUBLISH_DRAFT_STORAGE_KEY);
|
||||
} catch {
|
||||
// 清理失败时保留草稿,最多多显示一次恢复提示。
|
||||
}
|
||||
}
|
||||
|
||||
export type PublishDraftRecoveryCopy = {
|
||||
title: string;
|
||||
description: string;
|
||||
actionLabel: string | null;
|
||||
};
|
||||
|
||||
/** 恢复提示的文案完全由服务端 recoveryAction 派生,前端不推断版本状态。 */
|
||||
export function resolvePublishDraftRecoveryCopy(
|
||||
action: GameDistributionRecoveryAction,
|
||||
versionNumber: number,
|
||||
): PublishDraftRecoveryCopy {
|
||||
switch (action) {
|
||||
case 'upload':
|
||||
case 'reupload':
|
||||
return {
|
||||
title: `上次发布未完成(版本 v${versionNumber})`,
|
||||
description: '重新选择同一个发行包 ZIP,会沿用原版本继续上传并送审。',
|
||||
actionLabel: '继续上传',
|
||||
};
|
||||
case 'submit':
|
||||
return {
|
||||
title: `发行包已上传(版本 v${versionNumber})`,
|
||||
description: '可以直接继续提交审核,不需要重新上传。',
|
||||
actionLabel: '继续送审',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: `上次发布已交接(版本 v${versionNumber})`,
|
||||
description:
|
||||
'该版本已经进入服务端流程,可以在「我的游戏」查看最新状态。',
|
||||
actionLabel: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { GameDistributionDeviceSupport } from '../../../packages/shared/src/contracts/gameDistribution';
|
||||
|
||||
/** 作者侧元数据校验;口径与服务端 `validate_game_metadata` 保持一致。 */
|
||||
export function resolvePublishMetadataError(input: {
|
||||
title: string;
|
||||
summary: string;
|
||||
deviceSupport: GameDistributionDeviceSupport;
|
||||
}) {
|
||||
const title = input.title.trim();
|
||||
const summary = input.summary.trim();
|
||||
if (!title || Array.from(title).length > 40) {
|
||||
return '游戏标题必须为 1 到 40 个字符';
|
||||
}
|
||||
if (!summary || Array.from(summary).length > 120) {
|
||||
return '游戏简介必须为 1 到 120 个字符';
|
||||
}
|
||||
if (!input.deviceSupport.desktop && !input.deviceSupport.mobile) {
|
||||
return '至少选择桌面端或移动端';
|
||||
}
|
||||
if (input.deviceSupport.mobile && !input.deviceSupport.touch) {
|
||||
return '选择移动端时必须支持触控';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import JSZip from 'jszip';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { prepareGamePackage } from './gameZipPackage';
|
||||
|
||||
async function buildZip(files: Record<string, string>) {
|
||||
const zip = new JSZip();
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
zip.file(name, content);
|
||||
}
|
||||
const bytes = await zip.generateAsync({ type: 'uint8array' });
|
||||
return new File([bytes as unknown as BlobPart], 'game.zip', {
|
||||
type: 'application/zip',
|
||||
});
|
||||
}
|
||||
|
||||
describe('prepareGamePackage', () => {
|
||||
it('读取根 index.html、统计文件数并给出稳定摘要', async () => {
|
||||
const file = await buildZip({
|
||||
'index.html': '<html></html>',
|
||||
'assets/app.js': 'console.log(1)',
|
||||
});
|
||||
const prepared = await prepareGamePackage(file);
|
||||
expect(prepared.fileCount).toBe(2);
|
||||
expect(prepared.totalBytes).toBe(file.size);
|
||||
expect(prepared.sha256).toMatch(/^[0-9a-f]{64}$/u);
|
||||
const again = await prepareGamePackage(file);
|
||||
expect(again.sha256).toBe(prepared.sha256);
|
||||
});
|
||||
|
||||
it('缺少根 index.html 时失败关闭', async () => {
|
||||
const file = await buildZip({ 'game/index.html': '<html></html>' });
|
||||
await expect(prepareGamePackage(file)).rejects.toThrow(
|
||||
'发行包根目录必须包含 index.html',
|
||||
);
|
||||
});
|
||||
|
||||
it('拒绝空文件与非 ZIP 内容', async () => {
|
||||
await expect(
|
||||
prepareGamePackage(
|
||||
new File([], 'empty.zip', { type: 'application/zip' }),
|
||||
),
|
||||
).rejects.toThrow('请选择非空的发行包 ZIP');
|
||||
|
||||
await expect(
|
||||
prepareGamePackage(
|
||||
new File(['not a zip' as unknown as BlobPart], 'bad.zip', {
|
||||
type: 'application/zip',
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow('发行包不是有效的 ZIP');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import JSZip from 'jszip';
|
||||
|
||||
export const GAME_PACKAGE_MAX_BYTES = 100 * 1024 * 1024;
|
||||
export const GAME_PACKAGE_MAX_FILE_COUNT = 10_000;
|
||||
|
||||
export type PreparedGamePackage = {
|
||||
bytes: Uint8Array;
|
||||
sha256: string;
|
||||
fileCount: number;
|
||||
totalBytes: number;
|
||||
};
|
||||
|
||||
function toHex(buffer: ArrayBuffer) {
|
||||
return Array.from(new Uint8Array(buffer))
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/** 浏览器端发行包摘要;与服务端 `packageSha256` 同口径。 */
|
||||
export async function computeGamePackageSha256(bytes: Uint8Array) {
|
||||
const digest = globalThis.crypto?.subtle;
|
||||
if (!digest) {
|
||||
throw new Error('当前浏览器无法计算发行包摘要,请升级浏览器后重试');
|
||||
}
|
||||
const copy = new Uint8Array(bytes);
|
||||
return toHex(await digest.digest('SHA-256', copy.buffer as ArrayBuffer));
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传前读取并校验 ZIP。
|
||||
*
|
||||
* 这里只做作者侧的前置校验,服务端仍会独立重算摘要与文件清单;两边口径必须一致,
|
||||
* 前端校验通过不代表服务端一定接受。
|
||||
*/
|
||||
/** 读取 Blob 字节;FileReader 兜底覆盖不实现 `Blob.arrayBuffer()` 的旧 WebView 与 jsdom。 */
|
||||
export function readGamePackageBytes(blob: Blob): Promise<Uint8Array> {
|
||||
if (typeof blob.arrayBuffer === 'function') {
|
||||
return blob.arrayBuffer().then((buffer) => new Uint8Array(buffer));
|
||||
}
|
||||
return new Promise<Uint8Array>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result;
|
||||
if (result instanceof ArrayBuffer) {
|
||||
resolve(new Uint8Array(result));
|
||||
return;
|
||||
}
|
||||
reject(new Error('读取发行包失败'));
|
||||
};
|
||||
reader.onerror = () => reject(new Error('读取发行包失败'));
|
||||
reader.readAsArrayBuffer(blob);
|
||||
});
|
||||
}
|
||||
|
||||
export async function prepareGamePackage(
|
||||
file: Blob,
|
||||
): Promise<PreparedGamePackage> {
|
||||
if (!file || file.size === 0) {
|
||||
throw new Error('请选择非空的发行包 ZIP');
|
||||
}
|
||||
if (file.size > GAME_PACKAGE_MAX_BYTES) {
|
||||
throw new Error('发行包不能超过 100 MiB');
|
||||
}
|
||||
const bytes = await readGamePackageBytes(file);
|
||||
let archive: JSZip;
|
||||
try {
|
||||
archive = await JSZip.loadAsync(bytes);
|
||||
} catch {
|
||||
throw new Error('发行包不是有效的 ZIP');
|
||||
}
|
||||
|
||||
const entries = Object.values(archive.files).filter((entry) => !entry.dir);
|
||||
if (entries.length === 0) {
|
||||
throw new Error('发行包至少需要一个文件');
|
||||
}
|
||||
if (entries.length > GAME_PACKAGE_MAX_FILE_COUNT) {
|
||||
throw new Error(`发行包文件数量不能超过 ${GAME_PACKAGE_MAX_FILE_COUNT} 个`);
|
||||
}
|
||||
if (!entries.some((entry) => entry.name === 'index.html')) {
|
||||
throw new Error('发行包根目录必须包含 index.html');
|
||||
}
|
||||
|
||||
// 展开总量、单文件上限与压缩比由服务端 ZIP 校验独立判定;前端不在浏览器里解压
|
||||
// 整包,避免大包在客户端产生额外内存与耗时。
|
||||
|
||||
return {
|
||||
bytes,
|
||||
sha256: await computeGamePackageSha256(bytes),
|
||||
fileCount: entries.length,
|
||||
totalBytes: bytes.byteLength,
|
||||
};
|
||||
}
|
||||
@@ -43,6 +43,21 @@ const profileCenterMock = vi.hoisted(() => ({
|
||||
rechargeCenter: null as { walletBalance: number } | null,
|
||||
}));
|
||||
|
||||
const gameDistributionMock = vi.hoisted(() => ({
|
||||
cancelGameVersion: vi.fn(),
|
||||
createGame: vi.fn(),
|
||||
createGameVersion: vi.fn(),
|
||||
getGame: vi.fn(),
|
||||
getGameVersion: vi.fn(),
|
||||
listGames: vi.fn(),
|
||||
listMyGames: vi.fn(),
|
||||
submitGameVersion: vi.fn(),
|
||||
unpublishGame: vi.fn(),
|
||||
uploadGamePackage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/gameDistributionClient', () => gameDistributionMock);
|
||||
|
||||
vi.mock('../auth/AuthUiContext', () => ({
|
||||
useAuthUi: () => authUiMock.value,
|
||||
}));
|
||||
@@ -187,6 +202,10 @@ describe('PlatformEntryActiveFlowShell', () => {
|
||||
profileCenterMock.rechargeCenter = null;
|
||||
authUiMock.value.user = null;
|
||||
authUiMock.value.canAccessProtectedData = false;
|
||||
window.history.replaceState(null, '', '/creation');
|
||||
for (const mock of Object.values(gameDistributionMock)) {
|
||||
mock.mockReset();
|
||||
}
|
||||
authUiMock.value.openLoginModal.mockReset();
|
||||
responsiveMock.isDesktopLayout = true;
|
||||
});
|
||||
@@ -417,7 +436,7 @@ describe('PlatformEntryActiveFlowShell', () => {
|
||||
within(navigation)
|
||||
.getAllByRole('button')
|
||||
.map((button) => button.getAttribute('aria-label')),
|
||||
).toEqual(['创作', '项目', '我的']);
|
||||
).toEqual(['创作', '项目', '游戏', '我的']);
|
||||
expect(
|
||||
within(navigation)
|
||||
.getByRole('button', { name: '创作' })
|
||||
@@ -466,7 +485,7 @@ describe('PlatformEntryActiveFlowShell', () => {
|
||||
).toBe('page');
|
||||
});
|
||||
|
||||
it('keeps only profile reachable from the mobile dock', async () => {
|
||||
it('keeps only games and profile reachable from the mobile dock', async () => {
|
||||
responsiveMock.isDesktopLayout = false;
|
||||
const setSelectionStage = vi.fn();
|
||||
render(
|
||||
@@ -483,7 +502,7 @@ describe('PlatformEntryActiveFlowShell', () => {
|
||||
within(navigation)
|
||||
.getAllByRole('button')
|
||||
.map((button) => button.getAttribute('aria-label')),
|
||||
).toEqual(['我的']);
|
||||
).toEqual(['游戏', '我的']);
|
||||
expect(await screen.findByRole('main', { name: '我的' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '下载客户端' })).toBeTruthy();
|
||||
expect(
|
||||
@@ -568,7 +587,7 @@ describe('PlatformEntryActiveFlowShell', () => {
|
||||
within(navigation)
|
||||
.getAllByRole('button')
|
||||
.map((button) => button.getAttribute('aria-label')),
|
||||
).toEqual(['我的']);
|
||||
).toEqual(['游戏', '我的']);
|
||||
expect(
|
||||
within(navigation)
|
||||
.getByRole('button', { name: '我的' })
|
||||
@@ -653,6 +672,146 @@ describe('PlatformEntryActiveFlowShell', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('在我的游戏里进入发布新版本时沿用同一个 gameId', async () => {
|
||||
authUiMock.value.user = {
|
||||
id: 'user-1',
|
||||
publicUserCode: '100001',
|
||||
displayName: '测试用户',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
};
|
||||
authUiMock.value.canAccessProtectedData = true;
|
||||
gameDistributionMock.listMyGames.mockResolvedValue([
|
||||
{
|
||||
id: 'game-1',
|
||||
title: '星轨防线',
|
||||
summary: '守住轨道城',
|
||||
description: '旧版资料',
|
||||
category: '动作',
|
||||
tags: ['塔防'],
|
||||
coverColor: '#d77a51',
|
||||
icon: '✦',
|
||||
author: { id: 'user-1', name: '测试用户' },
|
||||
deviceSupport: { desktop: true, mobile: false, touch: false },
|
||||
status: 'published',
|
||||
publicationRevision: 3,
|
||||
currentVersion: null,
|
||||
playCount: 1,
|
||||
createdAt: '2026-09-20T08:00:00Z',
|
||||
latestVersion: {
|
||||
versionId: 'gamever-2',
|
||||
gameId: 'game-1',
|
||||
versionNumber: 2,
|
||||
packageSha256: 'e'.repeat(64),
|
||||
packageBytes: 1024,
|
||||
status: 'published',
|
||||
publicationRevision: 3,
|
||||
reviewReason: null,
|
||||
createdAt: '2026-09-20T08:00:00Z',
|
||||
updatedAt: '2026-09-20T09:00:00Z',
|
||||
},
|
||||
versions: [],
|
||||
},
|
||||
]);
|
||||
const setSelectionStage = vi.fn();
|
||||
|
||||
render(
|
||||
<PlatformEntryFlowShellImpl
|
||||
selectionStage="game-mine"
|
||||
setSelectionStage={setSelectionStage}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '发布新版本' }));
|
||||
|
||||
expect(setSelectionStage).toHaveBeenCalledWith('game-publish', {
|
||||
path: '/games/publish?game=game-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('发布页带 game 参数时进入更新模式并预填既有资料', async () => {
|
||||
authUiMock.value.user = {
|
||||
id: 'user-1',
|
||||
publicUserCode: '100001',
|
||||
displayName: '测试用户',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
};
|
||||
authUiMock.value.canAccessProtectedData = true;
|
||||
gameDistributionMock.listMyGames.mockResolvedValue([
|
||||
{
|
||||
id: 'game-9',
|
||||
title: '星轨防线',
|
||||
summary: '守住轨道城',
|
||||
description: '旧版资料',
|
||||
category: '动作',
|
||||
tags: ['塔防'],
|
||||
coverColor: '#d77a51',
|
||||
icon: '✦',
|
||||
author: { id: 'user-1', name: '测试用户' },
|
||||
deviceSupport: { desktop: true, mobile: false, touch: false },
|
||||
status: 'published',
|
||||
publicationRevision: 5,
|
||||
currentVersion: null,
|
||||
playCount: 1,
|
||||
createdAt: '2026-09-20T08:00:00Z',
|
||||
latestVersion: {
|
||||
versionId: 'gamever-9',
|
||||
gameId: 'game-9',
|
||||
versionNumber: 4,
|
||||
packageSha256: 'f'.repeat(64),
|
||||
packageBytes: 2048,
|
||||
status: 'published',
|
||||
publicationRevision: 5,
|
||||
reviewReason: null,
|
||||
createdAt: '2026-09-20T08:00:00Z',
|
||||
updatedAt: '2026-09-20T09:00:00Z',
|
||||
},
|
||||
versions: [],
|
||||
},
|
||||
]);
|
||||
gameDistributionMock.getGameVersion.mockResolvedValue({
|
||||
game: {
|
||||
id: 'game-9',
|
||||
title: '星轨防线',
|
||||
summary: '守住轨道城',
|
||||
description: '旧版资料',
|
||||
category: '动作',
|
||||
tags: ['塔防'],
|
||||
publicationRevision: 5,
|
||||
},
|
||||
version: {
|
||||
versionId: 'gamever-9',
|
||||
versionNumber: 4,
|
||||
status: 'published',
|
||||
publicationRevision: 5,
|
||||
recoveryAction: 'none',
|
||||
},
|
||||
});
|
||||
window.history.replaceState(null, '', '/games/publish?game=game-9');
|
||||
|
||||
render(
|
||||
<PlatformEntryFlowShellImpl
|
||||
selectionStage="game-publish"
|
||||
setSelectionStage={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/正在为《星轨防线》发布新版本 v5/u),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByLabelText('游戏名称')).toHaveProperty(
|
||||
'value',
|
||||
'星轨防线',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps guide and tool intent in editor navigation URLs', async () => {
|
||||
const setSelectionStage = vi.fn();
|
||||
const { rerender } = render(
|
||||
|
||||
@@ -402,6 +402,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
setSelectionStage('game-publish', { path: '/games/publish' });
|
||||
}, [setSelectionStage]);
|
||||
|
||||
const openGamePublishVersion = useCallback(
|
||||
(gameId: string) => {
|
||||
const path = `/games/publish?game=${encodeURIComponent(gameId)}`;
|
||||
setSelectionStage('game-publish', { path });
|
||||
},
|
||||
[setSelectionStage],
|
||||
);
|
||||
|
||||
const openMyGames = useCallback(() => {
|
||||
setSelectionStage('game-mine', { path: '/games/mine' });
|
||||
}, [setSelectionStage]);
|
||||
@@ -505,9 +513,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
selectionStage === 'game-play' ||
|
||||
selectionStage === 'game-mine' ||
|
||||
selectionStage === 'game-publish';
|
||||
const gameId = new URLSearchParams(
|
||||
const gameSearchParams = new URLSearchParams(
|
||||
typeof window === 'undefined' ? '' : window.location.search,
|
||||
).get('id');
|
||||
);
|
||||
const gameId = gameSearchParams.get('id');
|
||||
const publishGameId = gameSearchParams.get('game');
|
||||
const avatarUrl = authUi?.user?.avatarUrl?.trim() || null;
|
||||
const avatarLabel = resolveActiveUserAvatarLabel(authUi?.user);
|
||||
const publicUserCode = resolveActivePublicUserCode(authUi?.user);
|
||||
@@ -727,11 +737,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
<GamePublishPage
|
||||
onBack={openGames}
|
||||
onOpenMyGames={openMyGames}
|
||||
updateGameId={publishGameId}
|
||||
/>
|
||||
) : selectionStage === 'game-mine' ? (
|
||||
<MyGamesPage
|
||||
onBack={openGames}
|
||||
onOpenDetail={openGameDetail}
|
||||
onPublishVersion={openGamePublishVersion}
|
||||
/>
|
||||
) : selectionStage === 'game-detail' ? (
|
||||
<GameDetailPage
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { clearStoredAccessToken, setStoredAccessToken } from './apiClient';
|
||||
|
||||
import {
|
||||
cancelGameVersion,
|
||||
createGameVersion,
|
||||
getGame,
|
||||
getGameVersion,
|
||||
listGames,
|
||||
listMyGames,
|
||||
unpublishGame,
|
||||
uploadGamePackage,
|
||||
} from './gameDistributionClient';
|
||||
|
||||
describe('gameDistributionClient', () => {
|
||||
beforeEach(() => {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
});
|
||||
|
||||
it('按关键词和分类读取后端目录', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
games: [
|
||||
{
|
||||
id: 'game-1',
|
||||
title: '星轨防线',
|
||||
summary: '测试游戏',
|
||||
currentVersion: { id: 'v1' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
),
|
||||
);
|
||||
const games = await listGames({ keyword: '星轨', category: '动作' });
|
||||
expect(games).toHaveLength(1);
|
||||
expect(games[0]?.id).toBe('game-1');
|
||||
});
|
||||
|
||||
it('读取详情时拒绝空 id', async () => {
|
||||
expect(await getGame('')).toBeNull();
|
||||
});
|
||||
|
||||
it('版本创建携带幂等键并保留后端失败状态', async () => {
|
||||
setStoredAccessToken('test-access-token', { emit: false });
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Response(JSON.stringify({ message: '包校验失败' }), {
|
||||
status: 422,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
createGameVersion(
|
||||
'game-1',
|
||||
{
|
||||
packageSha256: 'x',
|
||||
packageBytes: 1,
|
||||
packageFileCount: 1,
|
||||
packageEntryPath: 'index.html',
|
||||
gameMetadata: {
|
||||
title: '测试游戏',
|
||||
summary: '测试',
|
||||
category: '益智',
|
||||
deviceSupport: { desktop: true, mobile: false, touch: false },
|
||||
inputModes: ['keyboard'],
|
||||
orientation: 'responsive',
|
||||
},
|
||||
},
|
||||
'idem-1',
|
||||
),
|
||||
).rejects.toMatchObject({ status: 422 });
|
||||
});
|
||||
|
||||
it('发行包上传使用独立版本资源和幂等键', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Response(
|
||||
JSON.stringify({ versionId: 'version-1', status: 'uploaded' }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
setStoredAccessToken('test-access-token', { emit: false });
|
||||
await uploadGamePackage('version-1', new ArrayBuffer(2), 'idem-upload');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/game-distribution/versions/version-1/package',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: expect.any(ArrayBuffer),
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/zip',
|
||||
'Idempotency-Key': 'idem-upload',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gameDistributionClient 作者接口', () => {
|
||||
beforeEach(() => {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
});
|
||||
|
||||
it('读取我的游戏只返回带 id 的条目', async () => {
|
||||
setStoredAccessToken('author-token', { emit: false });
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: { games: [{ id: 'game-1' }, {}, null] },
|
||||
error: null,
|
||||
meta: { apiVersion: 'v1' },
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
const games = await listMyGames();
|
||||
expect(games.map((game) => game.id)).toEqual(['game-1']);
|
||||
const myGamesCall = vi
|
||||
.mocked(fetch)
|
||||
.mock.calls.find(([url]) => String(url).includes('/my-games'));
|
||||
expect(myGamesCall?.[0]).toBe('/api/game-distribution/my-games');
|
||||
});
|
||||
|
||||
it('下架请求携带 CAS 版本与幂等键,空入参在本地失败关闭', async () => {
|
||||
setStoredAccessToken('author-token', { emit: false });
|
||||
const fetchMock = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: { game: { id: 'game-1', status: 'unpublished' } },
|
||||
error: null,
|
||||
meta: { apiVersion: 'v1' },
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await unpublishGame('game-1', 4, 'unpublish-key-1');
|
||||
const unpublishCalls = () =>
|
||||
fetchMock.mock.calls.filter(([url]) =>
|
||||
String(url).includes('/unpublish'),
|
||||
);
|
||||
expect(unpublishCalls()).toHaveLength(1);
|
||||
expect(unpublishCalls()[0]?.[0]).toBe(
|
||||
'/api/game-distribution/games/game-1/unpublish',
|
||||
);
|
||||
expect(unpublishCalls()[0]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ expectedPublicationRevision: 4 }),
|
||||
headers: expect.objectContaining({
|
||||
'Idempotency-Key': 'unpublish-key-1',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(unpublishGame(' ', 1, 'k')).rejects.toThrow('缺少游戏编号');
|
||||
await expect(unpublishGame('game-1', 1, ' ')).rejects.toThrow(
|
||||
'缺少幂等键',
|
||||
);
|
||||
// 本地校验失败不得发出任何下架请求。
|
||||
expect(unpublishCalls()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('回读单个版本状态与服务端恢复动作', async () => {
|
||||
setStoredAccessToken('test-access-token', { emit: false });
|
||||
const fetchMock = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
game: { id: 'game-1' },
|
||||
version: {
|
||||
versionId: 'version-1',
|
||||
status: 'upload_failed',
|
||||
recoveryAction: 'reupload',
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
),
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const detail = await getGameVersion('version-1');
|
||||
expect(detail.version.recoveryAction).toBe('reupload');
|
||||
const readCall = fetchMock.mock.calls.find(([url]) =>
|
||||
String(url).includes('/api/game-distribution/versions/version-1'),
|
||||
);
|
||||
expect(readCall?.[0]).toBe('/api/game-distribution/versions/version-1');
|
||||
expect(readCall?.[1]?.method).toBe('GET');
|
||||
});
|
||||
|
||||
it('撤回版本带幂等键与公开修订号,并拒绝空标识', async () => {
|
||||
setStoredAccessToken('test-access-token', { emit: false });
|
||||
const fetchMock = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ replayed: false }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await cancelGameVersion('version-1', 3, 'idem-1', '看错了');
|
||||
const cancelCalls = () =>
|
||||
fetchMock.mock.calls.filter(([url]) =>
|
||||
String(url).includes('/versions/version-1/cancel'),
|
||||
);
|
||||
expect(cancelCalls()).toHaveLength(1);
|
||||
const cancelCall = cancelCalls()[0];
|
||||
expect(cancelCall?.[1]?.method).toBe('POST');
|
||||
expect(
|
||||
(cancelCall?.[1]?.headers as Record<string, string>)['Idempotency-Key'],
|
||||
).toBe('idem-1');
|
||||
expect(JSON.parse(String(cancelCall?.[1]?.body))).toEqual({
|
||||
expectedPublicationRevision: 3,
|
||||
reason: '看错了',
|
||||
});
|
||||
|
||||
await expect(cancelGameVersion('', 1, 'idem-2')).rejects.toThrow(
|
||||
'撤回版本缺少版本编号',
|
||||
);
|
||||
await expect(cancelGameVersion('version-1', 1, ' ')).rejects.toThrow(
|
||||
'撤回版本缺少幂等键',
|
||||
);
|
||||
// 本地校验失败不得发出任何撤回请求。
|
||||
expect(cancelCalls()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
import type {
|
||||
GameDistributionAuthor,
|
||||
GameDistributionCreateGameRequest,
|
||||
GameDistributionCategory,
|
||||
GameDistributionCreateVersionRequest,
|
||||
GameDistributionDeviceSupport,
|
||||
GameDistributionInputMode,
|
||||
GameDistributionGame,
|
||||
GameDistributionCancelVersionResponse,
|
||||
GameDistributionRecoveryAction,
|
||||
GameDistributionVersionDetail,
|
||||
GameDistributionVersionStatus as GameDistributionVersionStatusValue,
|
||||
GameDistributionVersionSummary,
|
||||
} from '../../packages/shared/src/contracts/gameDistribution';
|
||||
import {
|
||||
requestJson as requestApiJson,
|
||||
type ApiRequestOptions,
|
||||
} from './apiClient';
|
||||
|
||||
export type {
|
||||
GameDistributionAuthor,
|
||||
GameDistributionDeviceSupport,
|
||||
GameDistributionGame,
|
||||
GameDistributionInputMode,
|
||||
GameDistributionRecoveryAction,
|
||||
GameDistributionVersionDetail,
|
||||
GameDistributionVersionSummary,
|
||||
} from '../../packages/shared/src/contracts/gameDistribution';
|
||||
|
||||
export type GameCategory = GameDistributionCategory;
|
||||
export type GameDeviceSupport = GameDistributionDeviceSupport;
|
||||
export type GameAuthor = GameDistributionAuthor;
|
||||
export type GameVersionSummary = GameDistributionVersionSummary;
|
||||
|
||||
export type GameListQuery = {
|
||||
keyword?: string;
|
||||
category?: GameCategory | '全部';
|
||||
};
|
||||
|
||||
export type GameVersionCreateResult = {
|
||||
gameId: string;
|
||||
versionId: string;
|
||||
versionNumber: number;
|
||||
status: 'awaiting_upload';
|
||||
};
|
||||
|
||||
const API_BASE = '/api/game-distribution';
|
||||
|
||||
function normalizeGameList(value: unknown): GameDistributionGame[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((item): item is GameDistributionGame => {
|
||||
if (!item || typeof item !== 'object') return false;
|
||||
const game = item as Partial<GameDistributionGame>;
|
||||
return Boolean(game.id && game.title && game.currentVersion);
|
||||
});
|
||||
}
|
||||
|
||||
const PUBLIC_GAME_REQUEST_OPTIONS: ApiRequestOptions = {
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
};
|
||||
|
||||
async function requestJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options?: ApiRequestOptions,
|
||||
) {
|
||||
return requestApiJson<T>(
|
||||
`${API_BASE}${path}`,
|
||||
{
|
||||
...init,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(init.body instanceof FormData ||
|
||||
(typeof Blob !== 'undefined' && init.body instanceof Blob) ||
|
||||
init.body instanceof ArrayBuffer
|
||||
? {}
|
||||
: init.body
|
||||
? { 'Content-Type': 'application/json' }
|
||||
: {}),
|
||||
...init.headers,
|
||||
},
|
||||
},
|
||||
fallbackMessage,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function listGames(query: GameListQuery = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (query.keyword?.trim()) params.set('keyword', query.keyword.trim());
|
||||
if (query.category && query.category !== '全部')
|
||||
params.set('category', query.category);
|
||||
const payload = await requestJson<unknown>(
|
||||
`/games${params.size ? `?${params.toString()}` : ''}`,
|
||||
{ method: 'GET' },
|
||||
'读取游戏目录失败',
|
||||
PUBLIC_GAME_REQUEST_OPTIONS,
|
||||
);
|
||||
return normalizeGameList(
|
||||
Array.isArray(payload) ? payload : (payload as { games?: unknown[] }).games,
|
||||
);
|
||||
}
|
||||
|
||||
export type GameDistributionMyGame = GameDistributionGame & {
|
||||
latestVersion: GameDistributionVersionStatusEntry | null;
|
||||
versions: GameDistributionVersionStatusEntry[];
|
||||
};
|
||||
|
||||
export type GameDistributionVersionStatusEntry = {
|
||||
versionId: string;
|
||||
gameId: string;
|
||||
versionNumber: number;
|
||||
packageSha256: string;
|
||||
packageBytes: number;
|
||||
status: GameDistributionVersionStatusValue;
|
||||
publicationRevision: number;
|
||||
reviewReason?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export async function listMyGames() {
|
||||
const payload = await requestJson<{ games?: unknown[] }>(
|
||||
'/my-games',
|
||||
{ method: 'GET' },
|
||||
'读取我的游戏失败',
|
||||
);
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
export async function unpublishGame(
|
||||
gameId: string,
|
||||
expectedPublicationRevision: number,
|
||||
idempotencyKey: string,
|
||||
) {
|
||||
const normalizedGameId = gameId.trim();
|
||||
const normalizedKey = idempotencyKey.trim();
|
||||
if (!normalizedGameId) throw new Error('缺少游戏编号');
|
||||
if (!normalizedKey) throw new Error('缺少幂等键');
|
||||
return requestJson<{ game: { id: string; status: string } }>(
|
||||
`/games/${encodeURIComponent(normalizedGameId)}/unpublish`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Idempotency-Key': normalizedKey },
|
||||
body: JSON.stringify({ expectedPublicationRevision }),
|
||||
},
|
||||
'下架游戏失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function getGame(gameId: string) {
|
||||
const normalizedId = gameId.trim();
|
||||
if (!normalizedId) return null;
|
||||
return requestJson<GameDistributionGame>(
|
||||
`/games/${encodeURIComponent(normalizedId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取游戏详情失败',
|
||||
PUBLIC_GAME_REQUEST_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createGame(
|
||||
payload: GameDistributionCreateGameRequest,
|
||||
idempotencyKey: string,
|
||||
) {
|
||||
const normalizedKey = idempotencyKey.trim();
|
||||
if (!normalizedKey) throw new Error('创建游戏缺少幂等键');
|
||||
return requestJson<GameDistributionGame>(
|
||||
'/games',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Idempotency-Key': normalizedKey },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'创建游戏失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function createGameVersion(
|
||||
gameId: string,
|
||||
payload: GameDistributionCreateVersionRequest,
|
||||
idempotencyKey: string,
|
||||
) {
|
||||
const normalizedGameId = gameId.trim();
|
||||
const normalizedIdempotencyKey = idempotencyKey.trim();
|
||||
if (!normalizedGameId || !normalizedIdempotencyKey) {
|
||||
throw new Error('游戏版本创建缺少必要标识');
|
||||
}
|
||||
return requestJson<GameVersionCreateResult>(
|
||||
`/games/${encodeURIComponent(normalizedGameId)}/versions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Idempotency-Key': normalizedIdempotencyKey },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'创建游戏版本失败',
|
||||
);
|
||||
}
|
||||
|
||||
/** 回读单个版本状态、驳回理由与服务端给出的恢复动作。 */
|
||||
export async function getGameVersion(versionId: string) {
|
||||
const normalizedVersionId = versionId.trim();
|
||||
if (!normalizedVersionId) throw new Error('缺少版本编号');
|
||||
return requestJson<GameDistributionVersionDetail>(
|
||||
`/versions/${encodeURIComponent(normalizedVersionId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取版本状态失败',
|
||||
);
|
||||
}
|
||||
|
||||
/** 撤回尚未公开的版本;幂等键由调用方生成,重复提交返回同一结果。 */
|
||||
export async function cancelGameVersion(
|
||||
versionId: string,
|
||||
expectedPublicationRevision: number,
|
||||
idempotencyKey: string,
|
||||
reason?: string,
|
||||
) {
|
||||
const normalizedVersionId = versionId.trim();
|
||||
const normalizedKey = idempotencyKey.trim();
|
||||
if (!normalizedVersionId) throw new Error('撤回版本缺少版本编号');
|
||||
if (!normalizedKey) throw new Error('撤回版本缺少幂等键');
|
||||
const normalizedReason = reason?.trim();
|
||||
return requestJson<GameDistributionCancelVersionResponse>(
|
||||
`/versions/${encodeURIComponent(normalizedVersionId)}/cancel`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Idempotency-Key': normalizedKey },
|
||||
body: JSON.stringify({
|
||||
expectedPublicationRevision,
|
||||
...(normalizedReason ? { reason: normalizedReason } : {}),
|
||||
}),
|
||||
},
|
||||
'撤回版本失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function submitGameVersion(
|
||||
versionId: string,
|
||||
expectedPublicationRevision: number,
|
||||
idempotencyKey: string,
|
||||
) {
|
||||
const normalizedVersionId = versionId.trim();
|
||||
const normalizedKey = idempotencyKey.trim();
|
||||
if (!normalizedVersionId) throw new Error('提交审核缺少版本 ID');
|
||||
if (!normalizedKey) throw new Error('提交审核缺少幂等键');
|
||||
return requestJson<{ version?: { status?: string } }>(
|
||||
`/versions/${encodeURIComponent(normalizedVersionId)}/submit`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Idempotency-Key': normalizedKey },
|
||||
body: JSON.stringify({ expectedPublicationRevision }),
|
||||
},
|
||||
'提交审核失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function uploadGamePackage(
|
||||
versionId: string,
|
||||
packageBody: Blob | ArrayBuffer,
|
||||
idempotencyKey: string,
|
||||
) {
|
||||
const normalizedVersionId = versionId.trim();
|
||||
const normalizedIdempotencyKey = idempotencyKey.trim();
|
||||
if (!normalizedVersionId || !normalizedIdempotencyKey) {
|
||||
throw new Error('游戏发行包上传缺少必要标识');
|
||||
}
|
||||
return requestJson<{ versionId: string; status: 'uploaded' }>(
|
||||
`/versions/${encodeURIComponent(normalizedVersionId)}/package`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Idempotency-Key': normalizedIdempotencyKey,
|
||||
},
|
||||
body: packageBody,
|
||||
},
|
||||
'上传游戏发行包失败',
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user