恢复游戏分发完整实现(特性分支)

- 主站:游戏广场、详情、在线游玩、网页发布与作者中心,以及共享契约与客户端服务
- 后端:module-game-distribution 领域层、SpacetimeDB 表/迁移/绑定、spacetime-client facade、api-server 路由与发行网关
- 后台:游戏审核页(待审列表、通过/拒绝、安全下架)
- AGC:发布面板、本地导出包读取命令与发布服务,含默认跳过的真实链路测试
- 运维:发行来源 nginx 模板与门禁、game-distribution:publish 灰度发布开关、OSS PutObject 受控重试
- 文档:主规范、里程碑与实施计划、决策日志与踩坑记录
This commit is contained in:
2026-09-20 20:49:42 +08:00
parent cde34428f9
commit 328ac31844
113 changed files with 17590 additions and 22 deletions
@@ -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
+251
View File
@@ -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);
});
});
+286
View File
@@ -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,
},
'上传游戏发行包失败',
);
}