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
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:
@@ -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: 'macOS(Apple Silicon)',
|
||||
});
|
||||
const intel = screen.getByRole('region', { name: 'macOS(Intel)' });
|
||||
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: 'macOS(Apple 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;
|
||||
|
||||
@@ -22,24 +22,6 @@ describe('vite dev api proxy', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards the public creation entry config route to the Rust API server', async () => {
|
||||
const resolvedConfig =
|
||||
typeof viteConfig === 'function'
|
||||
? await viteConfig({ command: 'serve', mode: 'test' })
|
||||
: viteConfig;
|
||||
|
||||
// 中文注释:创作入口配置是底部加号入口的首屏事实源,漏配代理会回退 index.html。
|
||||
expect(resolvedConfig.server?.proxy).toEqual(
|
||||
expect.objectContaining({
|
||||
'/api/creation-entry': expect.objectContaining({
|
||||
target: expect.any(String),
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards the admin route to the admin dev server', async () => {
|
||||
const resolvedConfig =
|
||||
typeof viteConfig === 'function'
|
||||
@@ -57,4 +39,23 @@ describe('vite dev api proxy', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards the game distribution route to the Rust API server', async () => {
|
||||
const resolvedConfig =
|
||||
typeof viteConfig === 'function'
|
||||
? await viteConfig({ command: 'serve', mode: 'test' })
|
||||
: viteConfig;
|
||||
|
||||
// 中文注释:游戏目录、详情、发行网关和发布写入都在 `/api/game-distribution/*`;
|
||||
// 漏配代理会回退 index.html,前端解析 JSON 时直接失败。
|
||||
expect(resolvedConfig.server?.proxy).toEqual(
|
||||
expect.objectContaining({
|
||||
'/api/game-distribution': expect.objectContaining({
|
||||
target: expect.any(String),
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,13 @@ describe('appPageRoutes', () => {
|
||||
expect(resolveSelectionStageFromPath('/EDITOR/CANVAS/')).toBe(
|
||||
'image-editor',
|
||||
);
|
||||
expect(resolveSelectionStageFromPath('/games')).toBe('games');
|
||||
expect(resolveSelectionStageFromPath('/games/detail/')).toBe('game-detail');
|
||||
expect(resolveSelectionStageFromPath('/games/play')).toBe('game-play');
|
||||
expect(resolveSelectionStageFromPath('/games/mine')).toBe('game-mine');
|
||||
expect(resolveSelectionStageFromPath('/games/publish')).toBe(
|
||||
'game-publish',
|
||||
);
|
||||
expect(resolveSelectionStageFromPath('/creation/rpg')).toBe('platform');
|
||||
expect(resolveSelectionStageFromPath('/runtime/puzzle')).toBe('platform');
|
||||
expect(isKnownMainAppPagePath('/creation')).toBe(true);
|
||||
@@ -31,6 +38,9 @@ describe('appPageRoutes', () => {
|
||||
expect(resolvePathForSelectionStage('creation-home')).toBe('/creation');
|
||||
expect(resolvePathForSelectionStage('project')).toBe('/project');
|
||||
expect(resolvePathForSelectionStage('profile')).toBe('/profile');
|
||||
expect(resolvePathForSelectionStage('games')).toBe('/games');
|
||||
expect(resolvePathForSelectionStage('game-mine')).toBe('/games/mine');
|
||||
expect(resolvePathForSelectionStage('game-publish')).toBe('/games/publish');
|
||||
});
|
||||
|
||||
it('requires a project id for direct editor navigation', () => {
|
||||
|
||||
@@ -9,6 +9,11 @@ const STAGE_ROUTE_ENTRIES = [
|
||||
['project', '/project'],
|
||||
['profile', '/profile'],
|
||||
['image-editor', '/editor/canvas'],
|
||||
['games', '/games'],
|
||||
['game-detail', '/games/detail'],
|
||||
['game-play', '/games/play'],
|
||||
['game-mine', '/games/mine'],
|
||||
['game-publish', '/games/publish'],
|
||||
] as const satisfies readonly (readonly [SelectionStage, string])[];
|
||||
|
||||
export const APP_STAGE_ROUTES: Record<SelectionStage, string> =
|
||||
|
||||
@@ -32,6 +32,13 @@ describe('activeAppTitle', () => {
|
||||
expect(resolveAppTitleForSelectionStage('image-editor')).toBe(
|
||||
'美术编辑器 - 陶泥儿',
|
||||
);
|
||||
expect(resolveAppTitleForSelectionStage('games')).toBe('游戏 - 陶泥儿');
|
||||
expect(resolveAppTitleForSelectionStage('game-mine')).toBe(
|
||||
'我的游戏 - 陶泥儿',
|
||||
);
|
||||
expect(resolveAppTitleForSelectionStage('game-publish')).toBe(
|
||||
'发布游戏 - 陶泥儿',
|
||||
);
|
||||
});
|
||||
|
||||
test('syncs browser and host titles', () => {
|
||||
|
||||
@@ -10,6 +10,11 @@ const APP_TITLE_BY_SELECTION_STAGE: Record<SelectionStage, string> = {
|
||||
project: '项目 - 陶泥儿',
|
||||
profile: '我的 - 陶泥儿',
|
||||
'image-editor': '美术编辑器 - 陶泥儿',
|
||||
games: '游戏 - 陶泥儿',
|
||||
'game-detail': '游戏详情 - 陶泥儿',
|
||||
'game-play': '正在游玩 - 陶泥儿',
|
||||
'game-mine': '我的游戏 - 陶泥儿',
|
||||
'game-publish': '发布游戏 - 陶泥儿',
|
||||
};
|
||||
|
||||
export function resolveAppTitleForSelectionStage(stage: SelectionStage) {
|
||||
|
||||
@@ -887,7 +887,7 @@ export async function fetchWithApiAuth(
|
||||
requestHeaders[REQUEST_ID_HEADER] = requestId;
|
||||
let hasAuthHeader = Boolean(
|
||||
requestHeaders.Authorization?.trim() ||
|
||||
requestHeaders.authorization?.trim(),
|
||||
requestHeaders.authorization?.trim(),
|
||||
);
|
||||
|
||||
if (
|
||||
@@ -903,7 +903,7 @@ export async function fetchWithApiAuth(
|
||||
requestHeaders[REQUEST_ID_HEADER] = requestId;
|
||||
hasAuthHeader = Boolean(
|
||||
requestHeaders.Authorization?.trim() ||
|
||||
requestHeaders.authorization?.trim(),
|
||||
requestHeaders.authorization?.trim(),
|
||||
);
|
||||
} catch (error) {
|
||||
if (requestSignal?.aborted) {
|
||||
|
||||
@@ -4,6 +4,7 @@ const FRONTEND_RUNTIME_CONFIG_API = '/api/runtime/frontend-config';
|
||||
|
||||
export type FrontendRuntimeConfig = {
|
||||
imageEditorAgentSidebarEnabled: boolean;
|
||||
agcTemplateLibraryEnabled: boolean;
|
||||
};
|
||||
|
||||
export async function loadFrontendRuntimeConfig() {
|
||||
|
||||
@@ -211,8 +211,7 @@ describe('editorProjectClient', () => {
|
||||
{ deadlineAt: expect.any(Number) },
|
||||
);
|
||||
const requestOptions = requestJsonMock.mock.calls[0]?.[3] as
|
||||
| { deadlineAt?: number }
|
||||
| undefined;
|
||||
{ deadlineAt?: number } | undefined;
|
||||
expect(requestOptions?.deadlineAt).toBeGreaterThanOrEqual(
|
||||
startedAt + 60_000,
|
||||
);
|
||||
|
||||
@@ -219,9 +219,7 @@ export type EditorAssetGenerationInputs = {
|
||||
};
|
||||
|
||||
export type EditorProjectResourceSourceType =
|
||||
| 'uploaded'
|
||||
| 'generated'
|
||||
| 'mock_generated';
|
||||
'uploaded' | 'generated' | 'mock_generated';
|
||||
|
||||
export type EditorProjectResourceSnapshot = {
|
||||
resourceId: string;
|
||||
@@ -330,11 +328,7 @@ export type EditorImageGenerationInput = {
|
||||
prompt: string;
|
||||
size?: string;
|
||||
kind?:
|
||||
| 'spec'
|
||||
| 'character'
|
||||
| 'quick-edit'
|
||||
| 'ui-design'
|
||||
| 'publication-material';
|
||||
'spec' | 'character' | 'quick-edit' | 'ui-design' | 'publication-material';
|
||||
model?: string;
|
||||
screenColor?: string;
|
||||
segModel?: string;
|
||||
@@ -544,12 +538,7 @@ export type EditorIconSpritesheetSliceResult = {
|
||||
export type EditorCharacterAnimationResolution = '480p' | '720p';
|
||||
|
||||
export type EditorCharacterAnimationRatio =
|
||||
| 'same'
|
||||
| '1:1'
|
||||
| '4:3'
|
||||
| '16:9'
|
||||
| '9:16'
|
||||
| '3:4';
|
||||
'same' | '1:1' | '4:3' | '16:9' | '9:16' | '3:4';
|
||||
|
||||
export type EditorCharacterAnimationFrameCount = 32 | 40 | 48;
|
||||
|
||||
@@ -611,12 +600,7 @@ export type EditorVideoModel =
|
||||
export type EditorVideoResolution = '480p' | '720p' | '1080p';
|
||||
export type EditorVideoSoundMode = 'on' | 'off';
|
||||
export type EditorVideoAspectRatio =
|
||||
| '16:9'
|
||||
| '9:16'
|
||||
| '1:1'
|
||||
| '4:3'
|
||||
| '3:4'
|
||||
| '21:9';
|
||||
'16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9';
|
||||
|
||||
export type EditorVideoGenerationInput = {
|
||||
prompt: string;
|
||||
|
||||
Reference in New Issue
Block a user