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

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

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

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

同步 SpacetimeDB schema、bindings、DTO、文档与定向测试
This commit is contained in:
2026-10-01 16:30:40 +08:00
parent 1f055e9902
commit a2b70d92b8
80 changed files with 5836 additions and 676 deletions
@@ -48,12 +48,16 @@ const gameDistributionMock = vi.hoisted(() => ({
cancelGameVersion: vi.fn(),
createGame: vi.fn(),
createGameVersion: vi.fn(),
deleteMyGame: vi.fn(),
getGame: vi.fn(),
getGameVersion: vi.fn(),
getMyGame: vi.fn(),
listGames: vi.fn(),
listMyGames: vi.fn(),
loadVersionRecoveryActions: vi.fn(),
submitGameVersion: vi.fn(),
unpublishGame: vi.fn(),
updateMyGame: vi.fn(),
uploadGamePackage: vi.fn(),
}));
@@ -1012,3 +1016,159 @@ describe('游戏详情返回', () => {
expect(window.location.pathname).toBe('/games');
});
});
describe('作品管理 owner 作用域', () => {
beforeEach(() => {
window.history.replaceState(null, '', '/creation');
authUiMock.value.user = null;
authUiMock.value.canAccessProtectedData = false;
for (const mock of Object.values(gameDistributionMock)) {
mock.mockReset();
}
frontendRuntimeConfigMock.loadFrontendRuntimeConfig.mockReset();
frontendRuntimeConfigMock.loadFrontendRuntimeConfig.mockResolvedValue({
imageEditorAgentSidebarEnabled: false,
agcTemplateLibraryEnabled: false,
gameDistributionPublishEnabled: true,
});
});
const ownerEntry = {
id: 'game-1',
title: '未公开作品',
summary: '还在审核',
description: '作者自有游戏',
category: '动作',
tags: ['塔防'],
coverColor: '#d77a51',
icon: '✦',
author: { id: 'user-1', name: '测试作者' },
deviceSupport: { desktop: true, mobile: true, touch: true },
status: 'unpublished',
publicationRevision: 4,
currentVersion: null,
playCount: 0,
createdAt: '2026-09-20T08:00:00Z',
latestVersion: {
versionId: 'gamever-1',
gameId: 'game-1',
versionNumber: 1,
packageSha256: 'a'.repeat(64),
packageBytes: 1024,
status: 'pending_review',
publicationRevision: 4,
reviewReason: '封面需要替换',
createdAt: '2026-09-20T08:00:00Z',
updatedAt: '2026-09-20T09:00:00Z',
},
versions: [
{
versionId: 'gamever-1',
gameId: 'game-1',
versionNumber: 1,
packageSha256: 'a'.repeat(64),
packageBytes: 1024,
status: 'pending_review',
publicationRevision: 4,
reviewReason: '封面需要替换',
createdAt: '2026-09-20T08:00:00Z',
updatedAt: '2026-09-20T09:00:00Z',
},
],
};
function loginAsAuthor() {
authUiMock.value.user = {
id: 'user-1',
publicUserCode: '100001',
displayName: '测试作者',
avatarUrl: null,
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
};
authUiMock.value.canAccessProtectedData = true;
}
it('从我的作品进入详情时把 owner 作用域编码进地址', async () => {
loginAsAuthor();
gameDistributionMock.listMyGames.mockResolvedValue([ownerEntry]);
gameDistributionMock.loadVersionRecoveryActions.mockResolvedValue({
'gamever-1': 'wait',
});
const setSelectionStage = vi.fn();
render(
<PlatformEntryFlowShellImpl
selectionStage="game-mine"
setSelectionStage={setSelectionStage}
/>,
);
fireEvent.click(await screen.findByRole('button', { name: '查看详情' }));
expect(setSelectionStage).toHaveBeenCalledWith('game-detail', {
path: '/games/detail?id=game-1&owner=1',
});
});
it('带 owner 的详情深链读 owner 路由而不是公开详情', async () => {
loginAsAuthor();
gameDistributionMock.getMyGame.mockResolvedValue(ownerEntry);
gameDistributionMock.loadVersionRecoveryActions.mockResolvedValue({
'gamever-1': 'wait',
});
gameDistributionMock.getGame.mockResolvedValue(null);
window.history.replaceState(null, '', '/games/detail?id=game-1&owner=1');
render(
<PlatformEntryFlowShellImpl
selectionStage="game-detail"
setSelectionStage={vi.fn()}
/>,
);
expect(await screen.findByText('作品状态')).toBeTruthy();
expect(gameDistributionMock.getMyGame).toHaveBeenCalledWith('game-1');
expect(gameDistributionMock.getGame).not.toHaveBeenCalled();
});
it('没有 owner 的详情仍走公开详情', async () => {
gameDistributionMock.getMyGame.mockResolvedValue(ownerEntry);
gameDistributionMock.getGame.mockResolvedValue({
id: 'game-1',
title: '公开作品',
summary: '公开详情',
description: '公开详情',
category: '动作',
tags: [],
coverColor: '#d77a51',
icon: '✦',
author: { id: 'author-1', name: '测试作者' },
deviceSupport: { desktop: true, mobile: true, touch: true },
status: 'published',
currentVersion: {
id: 'version-1',
version: '1.0.0',
entryUrl: 'https://play.example.test/releases/version-1/index.html',
sha256: 'sha256',
publishedAt: '2026-09-18T00:00:00.000Z',
controls: ['键盘'],
},
playCount: 1,
createdAt: '2026-09-18T00:00:00.000Z',
});
window.history.replaceState(null, '', '/games/detail?id=game-1');
render(
<PlatformEntryFlowShellImpl
selectionStage="game-detail"
setSelectionStage={vi.fn()}
/>,
);
expect(await screen.findByText('公开作品')).toBeTruthy();
expect(gameDistributionMock.getMyGame).not.toHaveBeenCalled();
});
});
@@ -443,8 +443,12 @@ export function PlatformEntryFlowShellImpl({
}, [setSelectionStage]);
const openGameDetail = useCallback(
(gameId: string) => {
const path = `/games/detail?id=${encodeURIComponent(gameId)}`;
(gameId: string, options?: { owner?: boolean }) => {
const params = new URLSearchParams({ id: gameId });
// 作者视角要一直带着 owner 作用域:刷新或直接打开该链接时详情页仍读
// `GET /my-games/{gameId}`,否则作者点自己的未公开作品只会拿到公开接口的 404。
if (options?.owner) params.set('owner', '1');
const path = `/games/detail?${params.toString()}`;
setSelectionStage('game-detail', { path });
},
[setSelectionStage],
@@ -804,16 +808,23 @@ export function PlatformEntryFlowShellImpl({
) : selectionStage === 'game-mine' ? (
<MyGamesPage
onBack={openGames}
onOpenDetail={openGameDetail}
onOpenDetail={(gameId) =>
openGameDetail(gameId, { owner: true })
}
onPublishVersion={
gamePublishGateAllowed
? openGamePublishVersion
: undefined
}
onResumePublish={
gamePublishGateAllowed ? openGamePublish : undefined
}
publishEnabled={gamePublishGateAllowed}
/>
) : selectionStage === 'game-detail' ? (
<GameDetailPage
gameId={gameId}
ownerView={gameSearchParams.get('owner') === '1'}
onBack={backFromGameDetail}
onPlay={openGamePlay}
/>