Merge remote-tracking branch 'origin/master' into fix/agc-acceptance-followup
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 19s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 19s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 19s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 19s
Project CI / AI game creator shell Rust smoke (pull_request) Failing after 19s
Project CI / Native shell tests (pull_request) Failing after 19s
Project CI / AI game creator shell Rust crates (pull_request) Failing after 19s
Project CI / Backend tests (pull_request) Failing after 19s
Project CI / Frontend tests (pull_request) Failing after 7s
Project CI / AI game creator shell web tests (pull_request) Failing after 14s
Project CI / Repository checks (pull_request) Failing after 15s

This commit is contained in:
2026-09-20 19:25:45 +08:00
775 changed files with 50586 additions and 22846 deletions
@@ -0,0 +1,223 @@
/* @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 { ClientDownloadEntry } from './ClientDownloadEntry';
const fetchMock = vi.fn();
const installerUrl = (version: string) =>
`https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/${version}/setup.exe`;
const releaseResponse = (version: string) =>
new Response(
JSON.stringify({
downloads: [
{
platform: 'windows',
architecture: 'x86_64',
version,
downloadUrl: installerUrl(version),
},
],
unavailablePlatforms: [],
}),
);
const macDownload = (architecture: 'aarch64' | 'x86_64') => ({
platform: 'macos',
architecture,
version: '0.2.0',
downloadUrl: `https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-mac/0.2.0/app_${architecture}.dmg`,
});
describe('官网下载入口', () => {
beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it('无需登录,每次打开重新获取最新版本和安装包链接', async () => {
fetchMock.mockImplementationOnce(() =>
Promise.resolve(releaseResponse('0.1.73')),
);
render(<ClientDownloadEntry />);
expect(fetchMock).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '下载客户端' }));
expect(
(
await screen.findByRole('link', { name: '下载 Windows 版' })
).getAttribute('href'),
).toBe(installerUrl('0.1.73'));
expect(screen.getByText('最新版本 v0.1.73')).toBeTruthy();
expect(fetchMock).toHaveBeenCalledWith(
'/api/client-downloads',
expect.objectContaining({ cache: 'no-store', credentials: 'omit' }),
);
fireEvent.keyDown(document, { key: 'Escape' });
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
fetchMock.mockImplementationOnce(() =>
Promise.resolve(releaseResponse('0.1.74')),
);
fireEvent.click(screen.getByRole('button', { name: '下载客户端' }));
expect(
(
await screen.findByRole('link', { name: '下载 Windows 版' })
).getAttribute('href'),
).toBe(installerUrl('0.1.74'));
});
it('服务不可用时不显示下载链接,重试后使用成功响应', async () => {
fetchMock.mockResolvedValueOnce(new Response('', { status: 502 }));
render(<ClientDownloadEntry />);
fireEvent.click(screen.getByRole('button', { name: '下载客户端' }));
expect(await screen.findByRole('alert')).toBeTruthy();
expect(screen.queryByRole('link')).toBeNull();
fetchMock.mockImplementationOnce(() =>
Promise.resolve(releaseResponse('0.1.75')),
);
fireEvent.click(screen.getByRole('button', { name: '重试' }));
expect(
(
await screen.findByRole('link', { name: '下载 Windows 版' })
).getAttribute('href'),
).toBe(installerUrl('0.1.75'));
});
it('Mac 发布后重新打开即显示各架构和独立版本,未发布时不显示', async () => {
fetchMock.mockImplementationOnce(() =>
Promise.resolve(releaseResponse('0.1.73')),
);
render(<ClientDownloadEntry />);
fireEvent.click(screen.getByRole('button', { name: '下载客户端' }));
await screen.findByRole('link', { name: '下载 Windows 版' });
expect(screen.queryByText(/macOS/u)).toBeNull();
fireEvent.keyDown(document, { key: 'Escape' });
fetchMock.mockImplementationOnce(async () => {
const windows = await releaseResponse('0.1.74').json();
return new Response(
JSON.stringify({
...windows,
downloads: [
...windows.downloads,
macDownload('aarch64'),
macDownload('x86_64'),
],
}),
);
});
fireEvent.click(screen.getByRole('button', { name: '下载客户端' }));
const arm = await screen.findByRole('region', {
name: 'macOSApple Silicon',
});
const intel = screen.getByRole('region', { name: 'macOSIntel' });
expect(within(arm).getByText('最新版本 v0.2.0')).toBeTruthy();
expect(within(arm).getByRole('link').getAttribute('href')).toBe(
macDownload('aarch64').downloadUrl,
);
expect(within(intel).getByRole('link').getAttribute('href')).toBe(
macDownload('x86_64').downloadUrl,
);
expect(screen.getByText('最新版本 v0.1.74')).toBeTruthy();
expect(screen.getAllByRole('link')).toHaveLength(3);
});
it('部分平台失败仍可下载其他平台,重试成功后补齐列表', async () => {
fetchMock.mockImplementationOnce(async () => {
const windows = await releaseResponse('0.1.74').json();
return new Response(
JSON.stringify({ ...windows, unavailablePlatforms: ['macos'] }),
);
});
render(<ClientDownloadEntry />);
fireEvent.click(screen.getByRole('button', { name: '下载客户端' }));
expect(
(
await screen.findByRole('link', { name: '下载 Windows 版' })
).getAttribute('href'),
).toBe(installerUrl('0.1.74'));
expect(screen.getByRole('alert').textContent).toContain('macOS');
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
downloads: [macDownload('aarch64')],
unavailablePlatforms: [],
}),
),
);
fireEvent.click(screen.getByRole('button', { name: '重试' }));
await screen.findByRole('region', { name: 'macOSApple Silicon' });
expect(screen.queryByRole('alert')).toBeNull();
expect(screen.queryByRole('link', { name: '下载 Windows 版' })).toBeNull();
});
it('所有平台尚未发布时显示空状态而不伪造链接', async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ downloads: [], unavailablePlatforms: [] })),
);
render(<ClientDownloadEntry />);
fireEvent.click(screen.getByRole('button', { name: '下载客户端' }));
expect(await screen.findByText('暂无可下载版本')).toBeTruthy();
expect(screen.queryByRole('link')).toBeNull();
expect(screen.getByRole('button', { name: '重试' })).toBeTruthy();
});
it('响应缺少平台列表时显示可重试错误', async () => {
fetchMock.mockResolvedValueOnce(new Response('{}'));
render(<ClientDownloadEntry />);
fireEvent.click(screen.getByRole('button', { name: '下载客户端' }));
expect(await screen.findByRole('alert')).toBeTruthy();
expect(screen.queryByRole('link')).toBeNull();
});
it('关闭取消请求,迟到响应不覆盖重开后版本', async () => {
let resolveFirst!: (response: Response) => void;
fetchMock.mockImplementationOnce(
() =>
new Promise<Response>((resolve) => {
resolveFirst = resolve;
}),
);
render(<ClientDownloadEntry />);
fireEvent.click(screen.getByRole('button', { name: '下载客户端' }));
const signal = fetchMock.mock.calls[0][1].signal as AbortSignal;
fireEvent.keyDown(document, { key: 'Escape' });
expect(signal.aborted).toBe(true);
fetchMock.mockImplementationOnce(() =>
Promise.resolve(releaseResponse('0.1.76')),
);
fireEvent.click(screen.getByRole('button', { name: '下载客户端' }));
await screen.findByText('最新版本 v0.1.76');
await act(async () => {
resolveFirst(releaseResponse('0.1.73'));
});
expect(screen.getByText('最新版本 v0.1.76')).toBeTruthy();
});
it('请求超时后退出加载并允许重试', async () => {
vi.useFakeTimers();
fetchMock.mockImplementationOnce(() => new Promise(() => {}));
render(<ClientDownloadEntry />);
fireEvent.click(screen.getByRole('button', { name: '下载客户端' }));
const signal = fetchMock.mock.calls[0][1].signal as AbortSignal;
await act(async () => {
vi.advanceTimersByTime(15_000);
});
expect(signal.aborted).toBe(true);
expect(screen.getByRole('alert')).toBeTruthy();
expect(screen.getByRole('button', { name: '重试' })).toBeTruthy();
});
});
@@ -0,0 +1,174 @@
import { getPlatformActionButtonClassName } from '@genarrative/shared/components';
import { Download, Monitor } from 'lucide-react';
import { useEffect, useState } from 'react';
import type { ClientDownloadResponse } from '../../../packages/shared/src/contracts/clientDownload';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { UnifiedModal } from '../common/UnifiedModal';
const DOWNLOAD_ERROR = '暂时无法获取最新版本,请稍后重试';
type DownloadState =
| { status: 'loading' }
| { status: 'ready'; release: ClientDownloadResponse }
| { status: 'error' };
const platformNames = { windows: 'Windows', macos: 'macOS' } as const;
function downloadLabel(download: ClientDownloadResponse['downloads'][number]) {
if (download.platform === 'windows') return 'Windows 64 位';
return `macOS${download.architecture === 'aarch64' ? 'Apple Silicon' : 'Intel'}`;
}
export function ClientDownloadEntry() {
const [open, setOpen] = useState(false);
const [attempt, setAttempt] = useState(0);
const [state, setState] = useState<DownloadState>({ status: 'loading' });
useEffect(() => {
if (!open) return;
const controller = new AbortController();
let disposed = false;
const timeout = window.setTimeout(() => {
if (disposed) return;
disposed = true;
controller.abort();
setState({ status: 'error' });
}, 15_000);
setState({ status: 'loading' });
void (async () => {
try {
const response = await fetch('/api/client-downloads', {
signal: controller.signal,
cache: 'no-store',
credentials: 'omit',
});
if (!response.ok) throw new Error(DOWNLOAD_ERROR);
const release = (await response.json()) as ClientDownloadResponse;
if (
!Array.isArray(release.downloads) ||
!Array.isArray(release.unavailablePlatforms)
) {
throw new Error(DOWNLOAD_ERROR);
}
if (!disposed) setState({ status: 'ready', release });
} catch {
if (!disposed) setState({ status: 'error' });
} finally {
window.clearTimeout(timeout);
}
})();
return () => {
disposed = true;
window.clearTimeout(timeout);
controller.abort();
};
}, [open, attempt]);
return (
<>
<PlatformActionButton
tone="secondary"
size="sm"
shape="pill"
className="min-h-11 shrink-0 whitespace-nowrap"
onClick={() => {
setState({ status: 'loading' });
setOpen(true);
}}
>
<Download className="h-4 w-4" aria-hidden="true" />
</PlatformActionButton>
<UnifiedModal
open={open}
onClose={() => setOpen(false)}
title="下载陶泥儿客户端"
size="sm"
>
{state.status === 'loading' ? (
<PlatformStatusMessage tone="info" role="status">
</PlatformStatusMessage>
) : state.status === 'error' ? (
<div className="flex flex-col gap-4">
<PlatformStatusMessage tone="error" role="alert">
{DOWNLOAD_ERROR}
</PlatformStatusMessage>
<PlatformActionButton
onClick={() => setAttempt((value) => value + 1)}
>
</PlatformActionButton>
</div>
) : (
<div className="flex flex-col gap-4">
{state.release.downloads.map((download) => (
<PlatformSubpanel
key={`${download.platform}-${download.architecture}`}
as="section"
aria-label={downloadLabel(download)}
padding="md"
radius="md"
>
<div className="mb-4 flex items-center gap-3">
<Monitor
className="h-7 w-7 shrink-0 text-[var(--platform-accent-strong)]"
aria-hidden="true"
/>
<div>
<p className="font-semibold text-[var(--platform-text-strong)]">
{downloadLabel(download)}
</p>
<p className="text-sm text-[var(--platform-text-soft)]">
v{download.version}
</p>
</div>
</div>
<a
href={download.downloadUrl}
className={getPlatformActionButtonClassName({
size: 'md',
fullWidth: true,
})}
referrerPolicy="no-referrer"
>
<Download className="h-4 w-4 shrink-0" aria-hidden="true" />
{download.platform === 'windows'
? '下载 Windows 版'
: `下载 ${downloadLabel(download)}`}
</a>
</PlatformSubpanel>
))}
{state.release.downloads.length === 0 ? (
<PlatformStatusMessage tone="info" role="status">
</PlatformStatusMessage>
) : null}
{state.release.unavailablePlatforms.length > 0 ? (
<PlatformStatusMessage tone="warning" role="alert">
{state.release.unavailablePlatforms
.map((platform) => platformNames[platform])
.join('、')}{' '}
</PlatformStatusMessage>
) : null}
{state.release.downloads.length === 0 ||
state.release.unavailablePlatforms.length > 0 ? (
<PlatformActionButton
tone="secondary"
onClick={() => setAttempt((value) => value + 1)}
>
</PlatformActionButton>
) : null}
</div>
)}
</UnifiedModal>
</>
);
}
@@ -1313,7 +1313,7 @@ export function ImageCanvasWorldView({
{canvasGenerationDialogs.map((dialog) => {
const hasGeneratedLayer = Boolean(
dialog.generatedLayerId &&
layers.some((layer) => layer.id === dialog.generatedLayerId),
layers.some((layer) => layer.id === dialog.generatedLayerId),
);
return dialog.placeholder && !hasGeneratedLayer
? (() => {
@@ -230,6 +230,7 @@ describe('PlatformEntryActiveFlowShell', () => {
);
expect(await screen.findByLabelText('泥点 207')).toBeTruthy();
expect(screen.getByRole('button', { name: '下载客户端' })).toBeTruthy();
rerender(
<PlatformEntryFlowShellImpl
@@ -484,6 +485,7 @@ describe('PlatformEntryActiveFlowShell', () => {
.map((button) => button.getAttribute('aria-label')),
).toEqual(['我的']);
expect(await screen.findByRole('main', { name: '我的' })).toBeTruthy();
expect(screen.getByRole('button', { name: '下载客户端' })).toBeTruthy();
expect(
within(navigation)
.getByRole('button', { name: '我的' })
@@ -1,5 +1,6 @@
import {
FolderKanban,
Gamepad2,
Home,
Monitor,
Palette,
@@ -30,6 +31,7 @@ import { useAuthUi } from '../auth/AuthUiContext';
import { FLOATING_FEEDBACK_FORM_URL } from '../common/floatingFeedbackEntryModel';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { ClientDownloadEntry } from '../creation-home/ClientDownloadEntry';
import {
PlatformActiveMobileWelcomeDialog,
shouldOpenActiveMobileWelcomeDialog,
@@ -65,6 +67,31 @@ const ProjectGalleryView = lazy(async () => {
return { default: module.ProjectGalleryView };
});
const GameGalleryPage = lazy(async () => {
const module = await import('../game-distribution/GameGalleryPage');
return { default: module.GameGalleryPage };
});
const GameDetailPage = lazy(async () => {
const module = await import('../game-distribution/GameDetailPage');
return { default: module.GameDetailPage };
});
const GamePublishPage = lazy(async () => {
const module = await import('../game-distribution/GamePublishPage');
return { default: module.GamePublishPage };
});
const MyGamesPage = lazy(async () => {
const module = await import('../game-distribution/MyGamesPage');
return { default: module.MyGamesPage };
});
const GamePlayPage = lazy(async () => {
const module = await import('../game-distribution/GamePlayPage');
return { default: module.GamePlayPage };
});
type ActiveRailButtonProps = {
active: boolean;
emphasized?: boolean;
@@ -144,16 +171,26 @@ function ActiveBottomNavButton({
function MobileProfileDock({
active,
onOpenProfile,
gameActive,
onOpenGames,
}: {
active: boolean;
onOpenProfile: () => void;
gameActive: boolean;
onOpenGames: () => void;
}) {
return (
<div className="platform-mobile-bottom-dock min-w-0 shrink-0 lg:hidden">
<nav
className="platform-bottom-nav grid grid-cols-1"
className="platform-bottom-nav grid grid-cols-2"
aria-label="移动平台导航"
>
<ActiveBottomNavButton
active={gameActive}
icon={Gamepad2}
label="游戏"
onClick={onOpenGames}
/>
<ActiveBottomNavButton
active={active}
icon={UserRound}
@@ -357,6 +394,34 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage('profile', { path: '/profile' });
}, [setSelectionStage]);
const openGames = useCallback(() => {
setSelectionStage('games', { path: '/games' });
}, [setSelectionStage]);
const openGamePublish = useCallback(() => {
setSelectionStage('game-publish', { path: '/games/publish' });
}, [setSelectionStage]);
const openMyGames = useCallback(() => {
setSelectionStage('game-mine', { path: '/games/mine' });
}, [setSelectionStage]);
const openGameDetail = useCallback(
(gameId: string) => {
const path = `/games/detail?id=${encodeURIComponent(gameId)}`;
setSelectionStage('game-detail', { path });
},
[setSelectionStage],
);
const openGamePlay = useCallback(
(gameId: string) => {
const path = `/games/play?id=${encodeURIComponent(gameId)}`;
setSelectionStage('game-play', { path });
},
[setSelectionStage],
);
const openEditorProject = useCallback(
(projectId: string, options?: { guide?: boolean; tool?: string }) => {
if (!isDesktopLayout) {
@@ -401,7 +466,12 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage('platform', { path: '/' });
}}
/>
<MobileProfileDock active={false} onOpenProfile={openProfile} />
<MobileProfileDock
active={false}
gameActive={false}
onOpenProfile={openProfile}
onOpenGames={openGames}
/>
<PlatformActiveMobileWelcomeDialog
open={shouldOpenMobileHomeWelcome}
platformThemeClass={platformThemeClass}
@@ -429,6 +499,15 @@ export function PlatformEntryFlowShellImpl({
const isCreationStage =
!isProfileStage &&
(selectionStage === 'platform' || selectionStage === 'creation-home');
const isGamesStage =
selectionStage === 'games' ||
selectionStage === 'game-detail' ||
selectionStage === 'game-play' ||
selectionStage === 'game-mine' ||
selectionStage === 'game-publish';
const gameId = new URLSearchParams(
typeof window === 'undefined' ? '' : window.location.search,
).get('id');
const avatarUrl = authUi?.user?.avatarUrl?.trim() || null;
const avatarLabel = resolveActiveUserAvatarLabel(authUi?.user);
const publicUserCode = resolveActivePublicUserCode(authUi?.user);
@@ -483,6 +562,14 @@ export function PlatformEntryFlowShellImpl({
label="项目"
onClick={openProjects}
/>
<ActiveRailButton
active={isGamesStage}
emphasized={isGamesStage}
icon={Gamepad2}
iconSrc="/creation-home/nav-projects.png"
label="游戏"
onClick={openGames}
/>
<ActiveRailButton
active={isProfileStage}
icon={UserRound}
@@ -494,7 +581,7 @@ export function PlatformEntryFlowShellImpl({
</aside>
<div className="platform-desktop-main flex min-w-0 flex-1 flex-col">
<header className="platform-desktop-topbar flex min-h-16 shrink-0 items-center justify-between gap-1 px-4 sm:gap-3 sm:px-6">
<header className="platform-desktop-topbar flex min-h-16 shrink-0 flex-wrap items-center justify-between gap-1 gap-y-2 px-4 py-2 sm:flex-nowrap sm:gap-3 sm:px-6 sm:py-0">
<div className="min-w-0 shrink-0 lg:hidden">
<ActivePlatformBrand />
</div>
@@ -514,8 +601,12 @@ export function PlatformEntryFlowShellImpl({
<input
type="search"
value={searchInput}
placeholder="搜索项目、素材、作者或描述"
aria-label="搜索项目和素材"
placeholder={
isGamesStage
? '搜索游戏、作者或标签'
: '搜索项目、素材、作者或描述'
}
aria-label={isGamesStage ? '搜索游戏' : '搜索项目和素材'}
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
onChange={(event) => {
const value = event.target.value;
@@ -534,7 +625,8 @@ export function PlatformEntryFlowShellImpl({
</form>
</div>
<div className="platform-desktop-topbar__actions flex shrink-0 items-center gap-1 sm:gap-2 lg:gap-3">
<div className="platform-desktop-topbar__actions flex max-w-full shrink-0 flex-wrap items-center gap-1 sm:flex-nowrap sm:gap-2 lg:gap-3">
<ClientDownloadEntry />
{isAuthenticated ? (
<PlatformMudPointWalletEntry
variant={isDesktopLayout ? 'desktop' : 'mobile'}
@@ -620,6 +712,42 @@ export function PlatformEntryFlowShellImpl({
searchKeyword={activeSearchKeyword}
/>
</Suspense>
) : isGamesStage ? (
<Suspense
fallback={<LoadingPanel label="正在加载游戏广场..." />}
>
{selectionStage === 'games' ? (
<GameGalleryPage
searchKeyword={activeSearchKeyword}
onOpenDetail={openGameDetail}
onOpenMyGames={openMyGames}
onOpenPublish={openGamePublish}
/>
) : selectionStage === 'game-publish' ? (
<GamePublishPage
onBack={openGames}
onOpenMyGames={openMyGames}
/>
) : selectionStage === 'game-mine' ? (
<MyGamesPage
onBack={openGames}
onOpenDetail={openGameDetail}
/>
) : selectionStage === 'game-detail' ? (
<GameDetailPage
gameId={gameId}
onBack={openGames}
onPlay={openGamePlay}
/>
) : (
<GamePlayPage
gameId={gameId}
onBack={() =>
gameId ? openGameDetail(gameId) : openGames()
}
/>
)}
</Suspense>
) : (
<Suspense fallback={<LoadingPanel label="正在加载项目..." />}>
<ProjectGalleryView
@@ -633,7 +761,9 @@ export function PlatformEntryFlowShellImpl({
</div>
<MobileProfileDock
active={isProfileStage}
gameActive={isGamesStage}
onOpenProfile={openProfile}
onOpenGames={openGames}
/>
</div>
</div>
@@ -3,7 +3,12 @@ export type SelectionStage =
| 'creation-home'
| 'project'
| 'profile'
| 'image-editor';
| 'image-editor'
| 'games'
| 'game-detail'
| 'game-play'
| 'game-mine'
| 'game-publish';
export type PlatformEntryFlowShellProps = {
selectionStage: SelectionStage;