diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx
index 310b299ec..3bcc442a8 100644
--- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx
+++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx
@@ -21,6 +21,7 @@ import {
subscribeTauriEvent,
} from '../../services/tauriEventSubscription';
import CoCreationView from '../../view/co-creation';
+import ForkConfirmView from '../../view/fork-confirm';
import HomeView from '../../view/home';
import { type LauncherView, Sidebar } from '../../view/layout';
import ProjectDevelopmentView from '../../view/project-development';
@@ -39,6 +40,7 @@ import {
} from '../../view/project-development/projectResourceLiveUpdateModel';
import TemplateLibraryView from '../../view/template-library';
import { useDirectActiveTurns } from '../agent-runtime/directActiveTurns';
+import { useForkConfirmStore } from '../platform-fork/forkConfirmTarget';
import {
subscribeForkDeepLink,
useForkDeepLinkStore,
@@ -674,8 +676,20 @@ export function WorkspaceLauncherShell({
let unsubscribe: (() => void) | null = null;
void subscribeForkDeepLink((request) => {
useForkDeepLinkStore.getState().request(request);
+ if (request.gameId) {
+ // 带作品 ID 的深链直接落到确认页(需求:不需要用户再逛一遍列表)。
+ useForkConfirmStore.getState().open({
+ gameId: request.gameId,
+ game: null,
+ origin: 'deep-link',
+ });
+ }
setLauncherView((current) =>
- current === 'project-development' ? current : 'co-creation',
+ current === 'project-development'
+ ? current
+ : request.gameId
+ ? 'fork-confirm'
+ : 'co-creation',
);
})
.then((release) => {
@@ -858,8 +872,9 @@ export function WorkspaceLauncherShell({
homeProject={homeProject}
recentProjects={recentProjects}
/>
- ) : launcherView === 'co-creation' ? (
- setLauncherView('co-creation')}
onProjectCreated={async (result, isCurrent, analytics) => {
await homeProject.enterCreatedTemplateProject(
result,
@@ -868,6 +883,13 @@ export function WorkspaceLauncherShell({
);
}}
/>
+ ) : launcherView === 'co-creation' ? (
+ {
+ useForkConfirmStore.getState().open(target);
+ setLauncherView('fork-confirm');
+ }}
+ />
) : launcherView === 'template-library' && templateLibrary.enabled ? (
void;
+ close: () => void;
+};
+
+export const useForkConfirmStore = create((set) => ({
+ target: null,
+ open: (target) => set({ target }),
+ close: () => set({ target: null }),
+}));
diff --git a/apps/ai-game-creator-shell/src/features/platform-fork/forkNotice.ts b/apps/ai-game-creator-shell/src/features/platform-fork/forkNotice.ts
new file mode 100644
index 000000000..eb48cebd4
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/features/platform-fork/forkNotice.ts
@@ -0,0 +1,52 @@
+/**
+ * Fork 工程确认页的须知文案(可修改范围 / 商用权限说明 / 署名要求 / 溯源规则 / 违规后果说明)。
+ *
+ * 放在独立文件而不是页面里:页面只导出组件(`react-refresh/only-export-components`),而这份
+ * 文案要被测试逐条断言,也需要被独立阅读——它是这一页最该被审的代码。
+ *
+ * 口径:Fork = 把整个工程复制一份到你的项目里继续改造(与 git fork 同理);商用权限按父作品
+ * 当前的授权档位分档说明,不自行加码也不放宽。
+ */
+
+/** 强制阅读时长:底部「确认并进入编辑」在前 3 秒禁用。 */
+export const MANDATORY_READING_SECONDS = 3;
+
+export type ForkNoticeSection = {
+ key: string;
+ title: string;
+ body: string;
+};
+
+export function forkNoticeSections(
+ authorizationLabel: string,
+): readonly ForkNoticeSection[] {
+ const commercial =
+ authorizationLabel === '允许全开放共创'
+ ? '父作品授权是「允许全开放共创」:可以改编、商用、引流,也可以发布新版本。'
+ : authorizationLabel === '允许非商用共创'
+ ? '父作品授权是「允许非商用共创」:可以二次开发,但不得用于盈利——不能接入广告或任何盈利内容,只能公开分享。'
+ : '父作品授权是「禁止共创」:正常情况下不应走到这一步,请取消并回到作品详情页确认授权状态。';
+ return [
+ {
+ key: 'scope',
+ title: '可修改范围',
+ body: 'Fork 会把该作者公开的整个工程复制一份到你的项目里继续改造(与 git fork 同理):代码、素材与配置都可以按你的想法改。副本完全独立,你的修改只影响自己的作品,不会影响原作者。',
+ },
+ { key: 'commercial', title: '商用权限说明', body: commercial },
+ {
+ key: 'attribution',
+ title: '署名要求',
+ body: '按平台规则,你的二创作品会自带原作品溯源信息与原作者署名,不能删除、隐藏或改成别人的名字;平台也会在作品页展示历代改造者。',
+ },
+ {
+ key: 'lineage',
+ title: '溯源规则',
+ body: '平台会永久记录原作品 ID、复刻时间与你这个副本的迭代版本;你的二创作品会作为衍生版本出现在原作品的族谱里(发布后即为公开事实)。',
+ },
+ {
+ key: 'consequence',
+ title: '违规后果说明',
+ body: '违反上述授权、署名或溯源规则(例如删除署名、超范围商用、低改动度搬运)会被平台下架并要求整改;情节严重时限制账号的共创与发布权限。',
+ },
+ ];
+}
diff --git a/apps/ai-game-creator-shell/src/features/platform-fork/forkParentCard.ts b/apps/ai-game-creator-shell/src/features/platform-fork/forkParentCard.ts
new file mode 100644
index 000000000..e2dd38ab7
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/features/platform-fork/forkParentCard.ts
@@ -0,0 +1,80 @@
+/**
+ * 父作品信息(确认页信息卡的数据源)。
+ *
+ * 优先用列表已经加载过的那份;没有就按 gameId 走**既有的**公开详情接口(匿名单读,不新增平台接口)。
+ * 拿不到时返回 `null`,由调用方降级渲染(信息卡缺失字段不报错、不阻塞确认)。
+ */
+import type { GameDistributionGame } from '../../../../../packages/shared/src/contracts/gameDistribution';
+import { resolveTauriInvoke } from '../../app/tauri';
+
+const DETAIL_COMMAND = 'read_public_game_detail';
+
+/** 授权档位的中文标签(与共享契约的三态一一对应;缺省读作禁止共创)。 */
+export const FORK_AUTHORIZATION_LABELS: Record = {
+ forbidden: '禁止共创',
+ nonCommercial: '允许非商用共创',
+ full: '允许全开放共创',
+};
+
+export function forkAuthorizationLabel(
+ value: string | null | undefined,
+): string {
+ return FORK_AUTHORIZATION_LABELS[value ?? 'forbidden'] ?? '禁止共创';
+}
+
+/** 信息卡要展示的四项:缺哪个都不报错,展示层逐项降级。 */
+export type ForkParentCard = {
+ gameId: string;
+ title: string;
+ authorName: string;
+ authorizationLabel: string;
+ coverObjectKey: string;
+ coverColor: string;
+};
+
+export function forkParentCardFromGame(
+ game: Pick<
+ GameDistributionGame,
+ | 'id'
+ | 'title'
+ | 'author'
+ | 'forkAuthorization'
+ | 'coverObjectKey'
+ | 'coverColor'
+ >,
+): ForkParentCard {
+ return {
+ gameId: game.id,
+ title: game.title?.trim() ?? '',
+ authorName: game.author?.name?.trim() ?? '',
+ authorizationLabel: forkAuthorizationLabel(game.forkAuthorization),
+ coverObjectKey: game.coverObjectKey?.trim() ?? '',
+ coverColor: game.coverColor?.trim() ?? '',
+ };
+}
+
+/**
+ * 读父作品详情:拿不到就 `null`(确认页仍可用,只是信息卡降级)。
+ *
+ * 失败不打日志噪音也不抛:这条只是「让用户看清楚自己在 Fork 什么」,缺了它不该挡住流程。
+ */
+export async function loadForkParentCard(
+ gameId: string,
+): Promise {
+ const invoke = resolveTauriInvoke();
+ const normalized = gameId.trim();
+ if (!invoke || !normalized) {
+ return null;
+ }
+ try {
+ const game = await invoke(DETAIL_COMMAND, {
+ gameId: normalized,
+ });
+ if (!game || typeof game !== 'object' || !('id' in game)) {
+ return null;
+ }
+ return forkParentCardFromGame(game);
+ } catch {
+ return null;
+ }
+}
diff --git a/apps/ai-game-creator-shell/src/features/platform-fork/forkProgress.ts b/apps/ai-game-creator-shell/src/features/platform-fork/forkProgress.ts
new file mode 100644
index 000000000..3b351b005
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/features/platform-fork/forkProgress.ts
@@ -0,0 +1,93 @@
+/**
+ * Fork 工程同步的真实阶段(与 Rust `ForkSyncStage` 一一对应)。
+ *
+ * **不造百分比**:这些阶段是原生侧真实发生的边界(取回 → 校验 → 解压 → 建项),事件到哪个阶段
+ * 就点亮哪个阶段;阶段内部是不可测量的(一次 HTTP 下载、一次解压),所以只做「进行中」指示。
+ *
+ * 与需求文档「源码 / 素材 / 场景 / 配置」的差异:文档假设按资产种类分别同步,而客户端这条链路是
+ * **整包取回**(一次下载、一次校验、一次解压、一次建项),没有按种类分开的可测量阶段,因此这里
+ * 如实降级成四个真实阶段并在页面上写明每个阶段在做什么。
+ */
+import { subscribeTauriEvent } from '../../services/tauriEventSubscription';
+
+/** 与原生 `game_fork::desktop::FORK_PROGRESS_EVENT` 同名。 */
+export const FORK_PROGRESS_EVENT = 'game-fork-progress';
+
+export const FORK_PROGRESS_STAGES = [
+ { key: 'download', label: '取回作品内容', detail: '读取件元数据并下载整包' },
+ {
+ key: 'verify',
+ label: '校验摘要与字节',
+ detail: '字节数与 SHA-256 必须与元数据一致',
+ },
+ {
+ key: 'extract',
+ label: '解压到项目',
+ detail: '解压内容到项目(成品包放进 reference 子目录)',
+ },
+ {
+ key: 'project',
+ label: '生成工程与来源记录',
+ detail: '生成工程、写入 .agent 身份与 .agent/fork-source.json',
+ },
+] as const;
+
+export type ForkProgressStageKey = (typeof FORK_PROGRESS_STAGES)[number]['key'];
+
+const STAGE_KEYS = FORK_PROGRESS_STAGES.map((stage) => stage.key);
+const STAGE_LABEL_BY_KEY = new Map(
+ FORK_PROGRESS_STAGES.map((stage) => [stage.key, stage.label]),
+);
+
+export function forkProgressStageLabel(key: ForkProgressStageKey): string {
+ return STAGE_LABEL_BY_KEY.get(key) ?? key;
+}
+
+/**
+ * 把原生事件载荷折成阶段键;未知/缺失整条丢弃(不把无法识别的阶段塞进状态条)。
+ */
+export function forkProgressStageFromEvent(
+ payload: unknown,
+): ForkProgressStageKey | null {
+ if (typeof payload !== 'object' || payload === null) {
+ return null;
+ }
+ const stage =
+ 'stage' in payload && typeof payload.stage === 'string'
+ ? payload.stage
+ : '';
+ return STAGE_KEYS.includes(stage as ForkProgressStageKey)
+ ? (stage as ForkProgressStageKey)
+ : null;
+}
+
+/** 订阅原生 Fork 进度;返回注销函数(重复调用安全)。 */
+export function subscribeForkProgress(
+ onStage: (stage: ForkProgressStageKey) => void,
+): Promise<() => void> {
+ return subscribeTauriEvent(FORK_PROGRESS_EVENT, (event) => {
+ const stage = forkProgressStageFromEvent(event.payload);
+ if (stage) {
+ onStage(stage);
+ }
+ });
+}
+
+/**
+ * 已发出的阶段集合(按真实到达顺序)。
+ *
+ * 状态条据此标记「已发生」;`current` 是最后一个到达的阶段(阶段内部仍在进行,所以是进行中)。
+ */
+export type ForkProgressSnapshot = {
+ current: ForkProgressStageKey | null;
+ reached: ForkProgressStageKey[];
+};
+
+export function forkProgressSnapshot(
+ reached: readonly ForkProgressStageKey[],
+): ForkProgressSnapshot {
+ return {
+ current: reached[reached.length - 1] ?? null,
+ reached: [...reached],
+ };
+}
diff --git a/apps/ai-game-creator-shell/src/features/platform-fork/usePlatformFork.ts b/apps/ai-game-creator-shell/src/features/platform-fork/usePlatformFork.ts
index 41227a0bb..629c27f8a 100644
--- a/apps/ai-game-creator-shell/src/features/platform-fork/usePlatformFork.ts
+++ b/apps/ai-game-creator-shell/src/features/platform-fork/usePlatformFork.ts
@@ -13,6 +13,11 @@ import {
beginProjectOpenAnalytics,
type ProjectOpenAnalytics,
} from '../../services/clientAnalytics';
+import {
+ forkProgressSnapshot,
+ type ForkProgressStageKey,
+ subscribeForkProgress,
+} from './forkProgress';
import { classifyPlatformForkFailure } from './platformGameForkModel';
export type PlatformForkSuccess = {
@@ -39,6 +44,10 @@ export function usePlatformFork({ onProjectCreated }: UsePlatformForkOptions) {
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [success, setSuccess] = useState(null);
+ /** 原生侧真实发出的阶段(订阅在 invoke **之前**建立,否则会漏掉第一阶段)。 */
+ const [reachedStages, setReachedStages] = useState(
+ [],
+ );
/** 只认最后一次提交:迟到的结果不再改状态,也不进入项目。 */
const requestSequenceRef = useRef(0);
/** 同一 tick 内的重复提交靠 ref 拦截:`busy` 状态要等下一次渲染才生效。 */
@@ -66,6 +75,22 @@ export function usePlatformFork({ onProjectCreated }: UsePlatformForkOptions) {
setError('');
setNotice('');
setSuccess(null);
+ setReachedStages([]);
+ // 先订阅再下令:原生会在开始下载前就发第一阶段,晚订阅会漏掉它。
+ const reached: ForkProgressStageKey[] = [];
+ let releaseProgress: (() => void) | null = null;
+ try {
+ releaseProgress = await subscribeForkProgress((stage) => {
+ if (!isCurrent() || reached.includes(stage)) {
+ return;
+ }
+ reached.push(stage);
+ setReachedStages([...reached]);
+ });
+ } catch {
+ // 订阅不可用(非 Tauri 宿主/插件缺失)不影响 Fork 本身,只是没有阶段可显示。
+ releaseProgress = null;
+ }
const analytics = beginProjectOpenAnalytics(invoke, 'create');
try {
const result = await invoke(
@@ -85,6 +110,7 @@ export function usePlatformFork({ onProjectCreated }: UsePlatformForkOptions) {
}
setError(classifyPlatformForkFailure(nextError).message);
} finally {
+ releaseProgress?.();
if (isCurrent()) {
busyRef.current = false;
setBusy(false);
@@ -99,6 +125,8 @@ export function usePlatformFork({ onProjectCreated }: UsePlatformForkOptions) {
error,
notice,
success,
+ /** 工程同步状态条用:只含真实到达过的阶段。 */
+ progress: forkProgressSnapshot(reachedStages),
forkGame,
setError,
setNotice,
diff --git a/apps/ai-game-creator-shell/src/view/co-creation/index.tsx b/apps/ai-game-creator-shell/src/view/co-creation/index.tsx
index b0a48f777..bb16714ce 100644
--- a/apps/ai-game-creator-shell/src/view/co-creation/index.tsx
+++ b/apps/ai-game-creator-shell/src/view/co-creation/index.tsx
@@ -12,7 +12,6 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import type { GameDistributionGame } from '../../../../../packages/shared/src/contracts/gameDistribution';
import { resolveTauriInvoke } from '../../app/tauri';
-import type { InitLocalProjectResult } from '../../app/types';
import { useForkDeepLinkStore } from '../../features/platform-fork/forkDeepLink';
import {
FORK_CATALOG_MAX_PAGES,
@@ -23,17 +22,15 @@ import {
parsePlatformGameId,
PLATFORM_FORK_INPUT_ERROR,
} from '../../features/platform-fork/platformGameForkModel';
-import { usePlatformFork } from '../../features/platform-fork/usePlatformFork';
import { usePlatformForkCovers } from '../../features/platform-fork/usePlatformForkCovers';
-import type { ProjectOpenAnalytics } from '../../services/clientAnalytics';
type CoCreationViewProps = {
- /** 建项成功后的进项目通道:与模板库同一条「原生已建好 → 进入工作区」路径。 */
- onProjectCreated: (
- result: InitLocalProjectResult,
- isCurrent: () => boolean,
- analytics: ProjectOpenAnalytics,
- ) => Promise | void;
+ /** 打开 Fork 工程确认页(授权校验 + 工程同步都在那一页完成,这里只负责选作品)。 */
+ onOpenConfirm: (target: {
+ gameId: string;
+ game: GameDistributionGame | null;
+ origin: 'co-creation';
+ }) => void;
};
const sectionClass =
@@ -49,9 +46,7 @@ function formatCatalogTime(value: string) {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
-export default function CoCreationView({
- onProjectCreated,
-}: CoCreationViewProps) {
+export default function CoCreationView({ onOpenConfirm }: CoCreationViewProps) {
const [catalog, setCatalog] = useState(null);
const [loading, setLoading] = useState(true);
const [catalogError, setCatalogError] = useState('');
@@ -59,7 +54,6 @@ export default function CoCreationView({
const [inputError, setInputError] = useState('');
const inputRef = useRef(null);
const requestIdRef = useRef(0);
- const fork = usePlatformFork({ onProjectCreated });
/**
* 深链预填:原生收到 `genarrative://fork?gameId=…` 后放进 store,这里只把它填进输入框并聚焦,
@@ -122,22 +116,22 @@ export default function CoCreationView({
}
}, [pendingDeepLink, consumeDeepLink]);
- async function submitPastedGameId(event: FormEvent) {
+ function submitPastedGameId(event: FormEvent) {
event.preventDefault();
- // 与深链共用同一份解析(`parsePlatformGameId`),不另写一套。
+ // 与深链共用同一份解析(`parsePlatformGameId`),不另写一套;解析通过后统一去确认页。
const gameId = parsePlatformGameId(input);
if (!gameId) {
setInputError(PLATFORM_FORK_INPUT_ERROR);
return;
}
setInputError('');
- await fork.forkGame(gameId);
+ onOpenConfirm({ gameId, game: null, origin: 'co-creation' });
}
- async function forkFromCard(game: GameDistributionGame) {
+ function forkFromCard(game: GameDistributionGame) {
setInputError('');
setInput(game.id);
- await fork.forkGame(game.id);
+ onOpenConfirm({ gameId: game.id, game, origin: 'co-creation' });
}
const games = catalog?.games ?? [];
@@ -190,7 +184,6 @@ export default function CoCreationView({
ref={inputRef}
aria-label="平台作品 ID 或链接"
value={input}
- disabled={fork.busy}
placeholder="例如 game_ab12… 或 https://…/games/detail?id=game_ab12…"
onChange={(event) => {
setInput(event.target.value);
@@ -203,9 +196,9 @@ export default function CoCreationView({
{inputError ? (
@@ -213,28 +206,6 @@ export default function CoCreationView({
{inputError}
) : null}
- {fork.error ? (
-
- {fork.error}
-
- ) : null}
- {fork.busy ? (
-
- 正在
- Fork:下载、校验并创建项目;内容较大时需要几分钟,请不要关闭客户端。
-
- ) : null}
- {!fork.busy && fork.notice ? (
-
- {fork.notice}(来源已记录在项目的 .agent/fork-source.json)
-
- ) : null}
@@ -286,7 +257,6 @@ export default function CoCreationView({
{!catalogError && games.length > 0 ? (
{games.map((game) => {
- const active = fork.activeGameId === game.id;
const coverUrl = coverUrls[game.id];
return (
@@ -332,10 +302,9 @@ export default function CoCreationView({
diff --git a/apps/ai-game-creator-shell/src/view/fork-confirm/index.tsx b/apps/ai-game-creator-shell/src/view/fork-confirm/index.tsx
new file mode 100644
index 000000000..fd88121d7
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/view/fork-confirm/index.tsx
@@ -0,0 +1,399 @@
+/**
+ * Fork 工程确认页(创作用户端):创作前的授权校验与工程同步环节。
+ *
+ * 结构照需求文档「Fork 工程确认页(客户端)」:父作品信息卡 → 二次确认 + 授权须知 + 风险说明
+ * (**强制阅读 3 秒**)→ 工程同步状态条 → 底部「取消 / 确认并进入编辑」。
+ *
+ * 两条硬要求:
+ * 1. 须知**全文真实可见**(可滚动,不折叠成一句),五类内容逐条写清;
+ * 2. 状态条只显示**原生真实发出的阶段**(`fork.progress`),阶段内部不可测量就只做进行中指示,
+ * **不造百分比**。
+ */
+
+import { RefreshCcw } from 'lucide-react';
+import { useEffect, useMemo, useRef, useState } from 'react';
+
+import { resolveTauriInvoke } from '../../app/tauri';
+import type { InitLocalProjectResult } from '../../app/types';
+import { useForkConfirmStore } from '../../features/platform-fork/forkConfirmTarget';
+import {
+ forkNoticeSections,
+ MANDATORY_READING_SECONDS,
+} from '../../features/platform-fork/forkNotice';
+import {
+ type ForkParentCard,
+ forkParentCardFromGame,
+ loadForkParentCard,
+} from '../../features/platform-fork/forkParentCard';
+import {
+ FORK_PROGRESS_STAGES,
+ forkProgressStageLabel,
+} from '../../features/platform-fork/forkProgress';
+import { resolveCoverPreview } from '../../features/platform-fork/platformForkCover';
+import { usePlatformFork } from '../../features/platform-fork/usePlatformFork';
+import type { ProjectOpenAnalytics } from '../../services/clientAnalytics';
+
+type ForkConfirmViewProps = {
+ /** 建项成功后的进项目通道:与模板库/共创页同一条「原生已建好 → 进入工作区」路径。 */
+ onProjectCreated: (
+ result: InitLocalProjectResult,
+ isCurrent: () => boolean,
+ analytics: ProjectOpenAnalytics,
+ ) => Promise | void;
+ /** 取消:回到进入前的界面(列表/详情)。 */
+ onCancel: () => void;
+};
+
+const sectionClass =
+ 'mx-auto mt-5.5 grid w-[min(814px,calc(100vw-122px))] gap-3.5 max-[760px]:w-[min(100%,calc(100vw-76px))]';
+
+export default function ForkConfirmView({
+ onProjectCreated,
+ onCancel,
+}: ForkConfirmViewProps) {
+ const target = useForkConfirmStore((state) => state.target);
+ const closeTarget = useForkConfirmStore((state) => state.close);
+ const fork = usePlatformFork({ onProjectCreated });
+ const [card, setCard] = useState(() =>
+ target?.game ? forkParentCardFromGame(target.game) : null,
+ );
+ const [cardLoading, setCardLoading] = useState(false);
+ const [coverUrl, setCoverUrl] = useState('');
+ const [remainingSeconds, setRemainingSeconds] = useState(
+ MANDATORY_READING_SECONDS,
+ );
+ const noticeListRef = useRef(null);
+
+ const gameId = target?.gameId ?? '';
+ const notices = useMemo(
+ () => forkNoticeSections(card?.authorizationLabel ?? '禁止共创'),
+ [card?.authorizationLabel],
+ );
+
+ // 信息卡:列表里带过来的先用,缺了再按 gameId 拉公开详情(拿不到就降级,不阻塞确认)。
+ useEffect(() => {
+ if (!gameId) {
+ setCard(null);
+ return;
+ }
+ if (target?.game) {
+ setCard(forkParentCardFromGame(target.game));
+ return;
+ }
+ let cancelled = false;
+ setCardLoading(true);
+ void loadForkParentCard(gameId)
+ .then((next) => {
+ if (!cancelled) {
+ setCard(next);
+ }
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setCardLoading(false);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [gameId, target?.game]);
+
+ // 封面:复用共创列表那套有界 data URL 链路;失败/没有封面就退回首字占位。
+ useEffect(() => {
+ const invoke = resolveTauriInvoke();
+ const objectKey = card?.coverObjectKey ?? '';
+ if (!invoke || !objectKey) {
+ setCoverUrl('');
+ return;
+ }
+ let cancelled = false;
+ void resolveCoverPreview(invoke, { coverObjectKey: objectKey }).then(
+ (url) => {
+ if (!cancelled) {
+ setCoverUrl(url ?? '');
+ }
+ },
+ );
+ return () => {
+ cancelled = true;
+ };
+ }, [card?.coverObjectKey]);
+
+ // 强制阅读:进入页面后倒计时 3 秒,其间确认按钮禁用(重新打开确认页会重新计时)。
+ useEffect(() => {
+ setRemainingSeconds(MANDATORY_READING_SECONDS);
+ // 须知区一进来就滚到顶部,避免上次滚动位置让用户误以为已经读过。
+ // 用 `scrollTop` 而不是 `scrollTo()`:jsdom 没实现后者,而这里只需要复位。
+ if (noticeListRef.current) {
+ noticeListRef.current.scrollTop = 0;
+ }
+ const startedAt = Date.now();
+ const timer = window.setInterval(() => {
+ const elapsed = Math.floor((Date.now() - startedAt) / 1000);
+ const remaining = Math.max(0, MANDATORY_READING_SECONDS - elapsed);
+ setRemainingSeconds(remaining);
+ if (remaining === 0) {
+ window.clearInterval(timer);
+ }
+ }, 250);
+ return () => window.clearInterval(timer);
+ }, [gameId]);
+
+ const readingLocked = remainingSeconds > 0;
+ const readingProgress = Math.min(
+ 1,
+ (MANDATORY_READING_SECONDS - remainingSeconds) / MANDATORY_READING_SECONDS,
+ );
+ const reachedStages = fork.progress.reached;
+ const activeStage = fork.progress.current;
+
+ async function confirmFork() {
+ if (!gameId || readingLocked || fork.busy) {
+ return;
+ }
+ await fork.forkGame(gameId);
+ // 成功时 `usePlatformFork` 已经把用户带进工作区(onProjectCreated);这里只在失败后
+ // 需要清掉待确认目标时收尾,因此失败分支不关闭页面,让用户能重试或取消。
+ if (fork.error) {
+ return;
+ }
+ }
+
+ function cancelFork() {
+ if (fork.busy) {
+ return;
+ }
+ closeTarget();
+ onCancel();
+ }
+
+ if (!target) {
+ return (
+
+
+
+ 没有待确认的 Fork;请回到「共创」页选择一个作品。
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {/* 父作品信息卡:封面 / 标题 / 作者 / 当前授权类型(缺失逐项降级,不报错) */}
+
+
+
+ {coverUrl ? (
+
+ ) : (
+
+ {(card?.title || gameId || '?').slice(0, 1)}
+
+ )}
+
+
+
+ 父作品
+
+
+ {card?.title?.trim() ||
+ (cardLoading ? '正在读取作品信息…' : '(未获取到标题)')}
+
+
+ 作者
+
+ {card?.authorName?.trim() || '—'}
+
+ 授权类型
+
+ {card ? card.authorizationLabel : '—'}
+
+ 作品 ID
+ {gameId}
+
+
+
+
+
+ {/* 二次确认 + 授权须知 + 风险说明:全文可见(可滚动),前 3 秒确认按钮禁用 */}
+
+
+
+ 授权须知与风险说明
+
+
+ {readingLocked
+ ? `请阅读下面的须知,${remainingSeconds} 秒后可确认`
+ : '已可确认'}
+
+
+
+
+
+
+ {notices.map((notice) => (
+
+
+ {notice.title}
+
+
+ {notice.body}
+
+
+ ))}
+
+
+
+ {/* 工程同步状态条:只显示原生真实阶段,不造百分比 */}
+
+
+
+ 工程同步
+
+
+ {fork.busy
+ ? `正在:${activeStage ? forkProgressStageLabel(activeStage) : '准备中'}`
+ : reachedStages.length > 0
+ ? '已同步完成'
+ : '确认后开始'}
+
+
+
+ {FORK_PROGRESS_STAGES.map((stage, index) => {
+ const done =
+ reachedStages.includes(stage.key) &&
+ (!fork.busy || stage.key !== activeStage);
+ const active = fork.busy && stage.key === activeStage;
+ return (
+ -
+
+ {done ? '✓' : index + 1}
+
+
+
+ {stage.label}
+ {active ? '(进行中)' : ''}
+
+
+ {stage.detail}
+
+
+
+ );
+ })}
+
+ {fork.error ? (
+
+ {fork.error}
+
+ ) : null}
+
+
+
+
+
+
+
+ {fork.busy ? (
+
+ 正在同步工程:内容较大时需要几分钟,请不要关闭客户端。同步期间不能取消。
+
+ ) : null}
+ {!fork.busy && reachedStages.length > 0 ? (
+
+
+ 同步已完成,即将进入工作台。
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/ai-game-creator-shell/src/view/layout.tsx b/apps/ai-game-creator-shell/src/view/layout.tsx
index a5506bf59..79b85c129 100644
--- a/apps/ai-game-creator-shell/src/view/layout.tsx
+++ b/apps/ai-game-creator-shell/src/view/layout.tsx
@@ -32,6 +32,7 @@ export type LauncherView =
| 'contact'
| 'news'
| 'co-creation'
+ | 'fork-confirm'
| 'template-library'
| 'project-development';
diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
index 6b5bd81f0..c0a03b652 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
@@ -10,6 +10,7 @@ import {
parseStyleSheet,
resolveDeclarations,
} from '../styleCascade';
+import { createTauriEventFake } from '../tauriEventFake';
import {
act,
App,
@@ -4211,6 +4212,45 @@ export function registerCoCreationEntryTests() {
).toBeNull();
});
+ it('深链带 gameId 时直接落到确认页并预填父作品', async () => {
+ const invoke = vi.fn(async (command: string) => {
+ if (command === 'read_public_game_detail') {
+ return {
+ id: 'game_from_link',
+ title: '链接带来的作品',
+ author: { id: 'user_1', name: '作者乙' },
+ forkAuthorization: 'nonCommercial',
+ coverColor: '#c2653d',
+ };
+ }
+ if (command === 'list_platform_game_catalog') {
+ return { games: [], nextCursor: null };
+ }
+ throw new Error(`unexpected invoke ${command}`);
+ });
+ installTauriRuntime({ core: { invoke } });
+ const eventFake = createTauriEventFake({ invoke });
+ eventFake.install();
+ renderLauncherAt('/?launcher');
+ await screen.findByLabelText('创作想法');
+ eventFake.flushRegistrationEvals();
+
+ eventFake.emit('game-fork-deep-link', {
+ gameId: 'game_from_link',
+ message: null,
+ });
+
+ // 落到确认页:父作品信息卡 + 强制阅读闸门。
+ expect(await screen.findByText('Fork 工程确认')).not.toBeNull();
+ const card = await screen.findByLabelText('父作品信息');
+ await waitFor(() => expect(card.textContent).toContain('链接带来的作品'));
+ expect(card.textContent).toContain('game_from_link');
+ expect(
+ screen.getByRole('button', { name: /确认并进入编辑/ }),
+ ).toHaveProperty('disabled', true);
+ eventFake.restore();
+ });
+
it('侧边栏「共创」入口可切换,并落在共创页上', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'list_platform_game_catalog') {
diff --git a/apps/ai-game-creator-shell/tests/coCreation.test.tsx b/apps/ai-game-creator-shell/tests/coCreation.test.tsx
index e3453d6c1..6fa7d3587 100644
--- a/apps/ai-game-creator-shell/tests/coCreation.test.tsx
+++ b/apps/ai-game-creator-shell/tests/coCreation.test.tsx
@@ -1,11 +1,5 @@
// @vitest-environment jsdom
-import {
- cleanup,
- fireEvent,
- render,
- screen,
- waitFor,
-} from '@testing-library/react';
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { InitLocalProjectResult } from '../src/app/types';
@@ -15,10 +9,7 @@ import {
isForkableGame,
loadForkableGames,
} from '../src/features/platform-fork/platformForkCatalog';
-import {
- classifyPlatformForkFailure,
- parsePlatformGameId,
-} from '../src/features/platform-fork/platformGameForkModel';
+import { parsePlatformGameId } from '../src/features/platform-fork/platformGameForkModel';
import CoCreationView from '../src/view/co-creation';
const FORK_COMMAND = 'create_local_project_from_platform_game';
@@ -62,9 +53,9 @@ function catalogGame(id: string, title: string, forkAuthorization: string) {
}
function renderPage() {
- const onProjectCreated = vi.fn();
- render();
- return { onProjectCreated };
+ const onOpenConfirm = vi.fn();
+ render();
+ return { onOpenConfirm };
}
function forkCalls(calls: InvokeCall[]) {
@@ -154,7 +145,7 @@ describe('共创页', () => {
expect(forkCalls(calls)).toHaveLength(0);
});
- it('点卡片上的 Fork 才建项,一次点击一步到位', async () => {
+ it('点卡片只打开确认页,不直接建项', async () => {
const calls = installInvoke((command) => {
if (command === CATALOG_COMMAND) {
return {
@@ -162,74 +153,69 @@ describe('共创页', () => {
nextCursor: null,
};
}
- if (command === FORK_COMMAND) {
- return PROJECT_RESULT;
- }
throw new Error(`unexpected invoke ${command}`);
});
- const { onProjectCreated } = renderPage();
+ const { onOpenConfirm } = renderPage();
await screen.findByText('开放共创的作品');
fireEvent.click(screen.getByRole('button', { name: 'Fork' }));
- await waitFor(() => expect(forkCalls(calls)).toHaveLength(1));
- expect(forkCalls(calls)[0]?.args).toMatchObject({
+ // 建项与授权确认都搬到确认页;列表这一层一次 native 调用都不该发。
+ expect(forkCalls(calls)).toHaveLength(0);
+ expect(onOpenConfirm).toHaveBeenCalledTimes(1);
+ expect(onOpenConfirm.mock.calls[0]?.[0]).toMatchObject({
gameId: 'game_open',
- name: null,
- planning: false,
+ origin: 'co-creation',
});
- await waitFor(() => expect(onProjectCreated).toHaveBeenCalledTimes(1));
- expect(
- await screen.findByText(
- /已创建项目《Fork 项目 1》,Fork 自平台作品 game_open/,
- ),
- ).not.toBeNull();
});
- it('粘贴作品链接与卡片共用同一解析与命令', async () => {
+ it('粘贴作品链接与卡片共用同一解析,并同样进确认页', async () => {
const calls = installInvoke((command) => {
if (command === CATALOG_COMMAND) {
return { games: [], nextCursor: null };
}
- if (command === FORK_COMMAND) {
- return PROJECT_RESULT;
- }
throw new Error(`unexpected invoke ${command}`);
});
- renderPage();
+ const { onOpenConfirm } = renderPage();
await screen.findByText(/还没有开放共创的/);
fireEvent.change(screen.getByLabelText('平台作品 ID 或链接'), {
target: { value: 'https://platform.test/games/detail?id=game_pasted' },
});
- fireEvent.click(screen.getByRole('button', { name: 'Fork 到我的项目' }));
+ fireEvent.click(screen.getByRole('button', { name: '去确认并 Fork' }));
- await waitFor(() => expect(forkCalls(calls)).toHaveLength(1));
- expect(forkCalls(calls)[0]?.args).toMatchObject({ gameId: 'game_pasted' });
+ expect(onOpenConfirm).toHaveBeenCalledTimes(1);
+ expect(onOpenConfirm.mock.calls[0]?.[0]).toMatchObject({
+ gameId: 'game_pasted',
+ game: null,
+ origin: 'co-creation',
+ });
+ expect(forkCalls(calls)).toHaveLength(0);
// 解析仍是同一份实现。
expect(
parsePlatformGameId('https://platform.test/games/detail?id=game_pasted'),
).toBe('game_pasted');
});
- it('粘贴非法输入本地拦截,不发请求', async () => {
+ it('粘贴非法输入本地拦截,不进确认页也不发请求', async () => {
const calls = installInvoke((command) => {
if (command === CATALOG_COMMAND) {
return { games: [], nextCursor: null };
}
throw new Error(`unexpected invoke ${command}`);
});
- renderPage();
+ const { onOpenConfirm } = renderPage();
await screen.findByText(/还没有开放共创的/);
fireEvent.change(screen.getByLabelText('平台作品 ID 或链接'), {
target: { value: 'https://platform.test/other/path' },
});
- fireEvent.click(screen.getByRole('button', { name: 'Fork 到我的项目' }));
+ fireEvent.click(screen.getByRole('button', { name: '去确认并 Fork' }));
expect(screen.getByRole('alert').textContent).toContain(
'请输入要 Fork 的平台作品 ID 或作品链接',
);
+ expect(onOpenConfirm).not.toHaveBeenCalled();
expect(forkCalls(calls)).toHaveLength(0);
});
@@ -298,7 +284,7 @@ describe('共创页', () => {
}
throw new Error(`unexpected invoke ${command}`);
});
- const { container } = render();
+ const { container } = render();
// 有封面的那张走
,src 是命令返回的 data URL。
const image = await screen.findByRole('img', {
@@ -326,34 +312,6 @@ describe('共创页', () => {
).toHaveLength(2);
});
- it('深链只预填输入框,不自动开始下载', async () => {
- const calls = installInvoke((command) => {
- if (command === CATALOG_COMMAND) {
- return { games: [], nextCursor: null };
- }
- if (command === FORK_COMMAND) {
- return PROJECT_RESULT;
- }
- throw new Error(`unexpected invoke ${command}`);
- });
- renderPage();
- await screen.findByText(/还没有开放共创的/);
-
- useForkDeepLinkStore
- .getState()
- .request({ gameId: 'game_from_link', message: null });
-
- const input = screen.getByLabelText(
- '平台作品 ID 或链接',
- ) as HTMLInputElement;
- await waitFor(() => expect(input.value).toBe('game_from_link'));
- expect(forkCalls(calls)).toHaveLength(0);
-
- // 人按下按钮才发请求——与卡片路径同一条状态机。
- fireEvent.click(screen.getByRole('button', { name: 'Fork 到我的项目' }));
- await waitFor(() => expect(forkCalls(calls)).toHaveLength(1));
- });
-
it('深链携带错误原因时如实展示,不预填也不请求', async () => {
const calls = installInvoke((command) => {
if (command === CATALOG_COMMAND) {
@@ -374,34 +332,4 @@ describe('共创页', () => {
);
expect(forkCalls(calls)).toHaveLength(0);
});
-
- it('取件失败时按既有分类给出可读原因', async () => {
- installInvoke((command) => {
- if (command === CATALOG_COMMAND) {
- return {
- games: [catalogGame('game_locked', '未开放共创', 'full')],
- nextCursor: null,
- };
- }
- if (command === FORK_COMMAND) {
- throw new Error(
- 'permission-denied: 作者没有开放这个作品的共创授权,无法 Fork',
- );
- }
- throw new Error(`unexpected invoke ${command}`);
- });
- renderPage();
- await screen.findByText('未开放共创');
-
- fireEvent.click(screen.getByRole('button', { name: 'Fork' }));
-
- expect((await screen.findByRole('alert')).textContent).toBe(
- '作者没有开放这个作品的共创授权,无法 Fork',
- );
- expect(
- classifyPlatformForkFailure(
- new Error('fork-source-not-available: 该作品当前不能被 Fork'),
- ).kind,
- ).toBe('unavailable');
- });
});
diff --git a/apps/ai-game-creator-shell/tests/forkConfirm.test.tsx b/apps/ai-game-creator-shell/tests/forkConfirm.test.tsx
new file mode 100644
index 000000000..0db9b3a98
--- /dev/null
+++ b/apps/ai-game-creator-shell/tests/forkConfirm.test.tsx
@@ -0,0 +1,316 @@
+// @vitest-environment jsdom
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { InitLocalProjectResult } from '../src/app/types';
+import { useForkConfirmStore } from '../src/features/platform-fork/forkConfirmTarget';
+import { MANDATORY_READING_SECONDS } from '../src/features/platform-fork/forkNotice';
+import { FORK_PROGRESS_STAGES } from '../src/features/platform-fork/forkProgress';
+import ForkConfirmView from '../src/view/fork-confirm';
+import { createTauriEventFake, type TauriEventFake } from './tauriEventFake';
+
+const FORK_COMMAND = 'create_local_project_from_platform_game';
+
+const PROJECT_RESULT = {
+ projectPath: 'C:\\projects\\gameagent-fork-1',
+ manifestPath: 'C:\\projects\\gameagent-fork-1\\.agent\\manifest.json',
+ manifest: { name: 'Fork 项目 1' },
+} as unknown as InitLocalProjectResult;
+
+const PARENT_GAME = {
+ id: 'game_parent_1',
+ title: '母版作品',
+ author: { id: 'user_1', name: '原作者甲' },
+ forkAuthorization: 'nonCommercial',
+ coverObjectKey: 'game-distribution/cover/parent.png',
+ coverColor: '#c2653d',
+};
+
+type InvokeCall = { command: string; args?: Record };
+
+let eventFake: TauriEventFake | null = null;
+
+function installInvoke(
+ implementation: (command: string, args?: Record) => unknown,
+) {
+ const calls: InvokeCall[] = [];
+ const invoke = vi.fn(
+ async (command: string, args?: Record) => {
+ calls.push({ command, args });
+ return implementation(command, args);
+ },
+ );
+ (window as unknown as { __TAURI__?: unknown }).__TAURI__ = {
+ core: { invoke },
+ };
+ return calls;
+}
+
+function renderPage() {
+ const onProjectCreated = vi.fn();
+ const onCancel = vi.fn();
+ render(
+ ,
+ );
+ return { onProjectCreated, onCancel };
+}
+
+function confirmButton() {
+ return screen.getByRole('button', { name: /确认并进入编辑/ });
+}
+
+/** 跨过强制阅读窗口(真实计时器即可,3 秒内用假计时器更快)。 */
+function finishMandatoryReading() {
+ act(() => {
+ vi.advanceTimersByTime(MANDATORY_READING_SECONDS * 1000 + 300);
+ });
+}
+
+beforeEach(() => {
+ // 假计时器 + 允许真实时间推进:既能精确跨过 3 秒强制阅读窗口,又不会让
+ // `findBy*`/`waitFor` 这类依赖计时器的查询挂死。
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ useForkConfirmStore.getState().open({
+ gameId: PARENT_GAME.id,
+ game: PARENT_GAME as never,
+ origin: 'co-creation',
+ });
+});
+
+afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ eventFake?.restore();
+ eventFake = null;
+ useForkConfirmStore.getState().close();
+ delete (window as unknown as { __TAURI__?: unknown }).__TAURI__;
+});
+
+describe('Fork 工程确认页', () => {
+ it('父作品信息卡展示标题 / 作者 / 授权类型 / 封面,缺失时降级不报错', async () => {
+ installInvoke((command) => {
+ if (command === 'read_public_game_cover_preview') {
+ return 'data:image/png;base64,AAAA';
+ }
+ throw new Error(`unexpected invoke ${command}`);
+ });
+ renderPage();
+
+ const card = await screen.findByLabelText('父作品信息');
+ expect(card.textContent).toContain('母版作品');
+ expect(card.textContent).toContain('原作者甲');
+ expect(card.textContent).toContain('允许非商用共创');
+ expect(card.textContent).toContain('game_parent_1');
+ // 封面走有界 data URL 链路。
+ const cover = await screen.findByRole('img', { name: '母版作品 封面' });
+ expect(cover.getAttribute('src')).toBe('data:image/png;base64,AAAA');
+ });
+
+ it('信息卡缺字段时逐项降级:没有标题/作者/封面也不报错', async () => {
+ useForkConfirmStore.getState().close();
+ useForkConfirmStore.getState().open({
+ gameId: 'game_bare_1',
+ game: null,
+ origin: 'deep-link',
+ });
+ installInvoke((command) => {
+ if (command === 'read_public_game_detail') {
+ return { id: 'game_bare_1' };
+ }
+ throw new Error(`unexpected invoke ${command}`);
+ });
+ const { onProjectCreated } = renderPage();
+
+ const card = await screen.findByLabelText('父作品信息');
+ await waitFor(() => expect(card.textContent).toContain('(未获取到标题)'));
+ expect(card.textContent).toContain('game_bare_1');
+ // 缺授权类型读作「禁止共创」(契约缺省),缺封面不渲染
。
+ expect(card.textContent).toContain('禁止共创');
+ expect(screen.queryByRole('img')).toBeNull();
+ // 仍然可以走完确认流程(缺信息不阻塞)。
+ finishMandatoryReading();
+ expect(confirmButton()).toHaveProperty('disabled', false);
+ expect(onProjectCreated).not.toHaveBeenCalled();
+ });
+
+ it('强制阅读:3 秒内按钮禁用且倒计时可见,3 秒后可点', async () => {
+ installInvoke(() => null);
+ renderPage();
+
+ expect(confirmButton()).toHaveProperty('disabled', true);
+ expect(confirmButton().textContent).toContain('3s');
+ const progress = screen.getByRole('progressbar', { name: '强制阅读进度' });
+ expect(progress.getAttribute('aria-valuenow')).toBe('0');
+
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(confirmButton()).toHaveProperty('disabled', true);
+ expect(confirmButton().textContent).toContain('2s');
+ expect(progress.getAttribute('aria-valuenow')).toBe('1');
+
+ finishMandatoryReading();
+ expect(confirmButton()).toHaveProperty('disabled', false);
+ expect(confirmButton().textContent).not.toContain('s)');
+ });
+
+ it('五类须知全文真实渲染,不是一句话折叠', async () => {
+ installInvoke(() => null);
+ renderPage();
+
+ const notices = screen.getByLabelText('授权须知全文');
+ for (const title of [
+ '可修改范围',
+ '商用权限说明',
+ '署名要求',
+ '溯源规则',
+ '违规后果说明',
+ ]) {
+ expect(notices.textContent).toContain(title);
+ }
+ // 关键口径逐句落页:Fork 定义、非商用不得盈利、署名不可删、永久记录、违规后果。
+ expect(notices.textContent).toContain(
+ '整个工程复制一份到你的项目里继续改造',
+ );
+ expect(notices.textContent).toContain('不得用于盈利');
+ expect(notices.textContent).toContain('不能删除、隐藏或改成别人的名字');
+ expect(notices.textContent).toContain('永久记录原作品 ID');
+ expect(notices.textContent).toContain('会被平台下架并要求整改');
+ });
+
+ it('全开放授权时商用权限改写成可商用', async () => {
+ useForkConfirmStore.getState().close();
+ useForkConfirmStore.getState().open({
+ gameId: 'game_open_1',
+ game: {
+ ...PARENT_GAME,
+ id: 'game_open_1',
+ forkAuthorization: 'full',
+ } as never,
+ origin: 'co-creation',
+ });
+ installInvoke(() => null);
+ renderPage();
+
+ const notices = await screen.findByLabelText('授权须知全文');
+ expect(notices.textContent).toContain('允许全开放共创');
+ expect(notices.textContent).toContain('可以改编、商用、引流');
+ });
+
+ it('工程同步状态条按真实阶段渲染,事件到哪一步就点亮哪一步', async () => {
+ eventFake = createTauriEventFake();
+ eventFake.install();
+ // 让建项命令悬住:进度订阅在 forkGame 期间才在,命令立刻返回会先释放订阅。
+ // (`Promise.withResolvers` 需要 lib ES2024,本仓 AGC 走 ES2022,因此手写 deferred。)
+ let resolveFork: (value: unknown) => void = () => undefined;
+ installInvoke((command) => {
+ if (command === FORK_COMMAND) {
+ return new Promise((resolve) => {
+ resolveFork = resolve as (value: unknown) => void;
+ });
+ }
+ return null;
+ });
+ const { onProjectCreated } = renderPage();
+
+ // 未开始:四个真实阶段都在场,且没有任何一个被点亮。
+ for (const stage of FORK_PROGRESS_STAGES) {
+ expect(screen.getByText(stage.label)).not.toBeNull();
+ }
+ expect(screen.getByText('确认后开始')).not.toBeNull();
+
+ finishMandatoryReading();
+ fireEvent.click(confirmButton());
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: '正在 Fork…' })).toHaveProperty(
+ 'disabled',
+ true,
+ ),
+ );
+
+ // 原生真实事件:只按到达顺序点亮,不预估、不填百分比。
+ eventFake.flushRegistrationEvals();
+ eventFake.emit('game-fork-progress', {
+ stage: 'download',
+ label: '取回作品内容',
+ });
+ expect(await screen.findByText(/正在:取回作品内容/)).not.toBeNull();
+
+ eventFake.emit('game-fork-progress', {
+ stage: 'verify',
+ label: '校验摘要与字节',
+ });
+ expect(await screen.findByText(/正在:校验摘要与字节/)).not.toBeNull();
+ // 未知阶段整条丢弃,不会污染状态条。
+ eventFake.emit('game-fork-progress', { stage: 'whatever' });
+ expect(screen.getByText(/正在:校验摘要与字节/)).not.toBeNull();
+
+ resolveFork(PROJECT_RESULT);
+ await waitFor(() => expect(onProjectCreated).toHaveBeenCalledTimes(1));
+ });
+
+ it('确认后调用原生 Fork 命令,成功后交给上层进入工作台', async () => {
+ const calls = installInvoke((command) =>
+ command === FORK_COMMAND ? PROJECT_RESULT : null,
+ );
+ const { onProjectCreated } = renderPage();
+
+ finishMandatoryReading();
+ fireEvent.click(confirmButton());
+
+ await waitFor(() =>
+ expect(
+ calls.filter((call) => call.command === FORK_COMMAND),
+ ).toHaveLength(1),
+ );
+ expect(
+ calls.find((call) => call.command === FORK_COMMAND)?.args,
+ ).toMatchObject({ gameId: 'game_parent_1', name: null, planning: false });
+ await waitFor(() => expect(onProjectCreated).toHaveBeenCalledTimes(1));
+ });
+
+ it('取件失败时如实展示可读原因,且不进入工作台', async () => {
+ installInvoke((command) => {
+ if (command === FORK_COMMAND) {
+ throw new Error(
+ 'permission-denied: 作者没有开放这个作品的共创授权,无法 Fork',
+ );
+ }
+ return null;
+ });
+ const { onProjectCreated } = renderPage();
+
+ finishMandatoryReading();
+ fireEvent.click(confirmButton());
+
+ expect((await screen.findByRole('alert')).textContent).toBe(
+ '作者没有开放这个作品的共创授权,无法 Fork',
+ );
+ expect(onProjectCreated).not.toHaveBeenCalled();
+ });
+
+ it('取消回到进入前的界面,且同步进行中不允许取消', async () => {
+ installInvoke(() => null);
+ const { onCancel } = renderPage();
+
+ fireEvent.click(screen.getByRole('button', { name: '取消' }));
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ expect(useForkConfirmStore.getState().target).toBeNull();
+ });
+
+ it('没有待确认目标时给出可读提示而不是空白', async () => {
+ useForkConfirmStore.getState().close();
+ const { onCancel } = renderPage();
+
+ expect(await screen.findByText(/没有待确认的 Fork/)).not.toBeNull();
+ fireEvent.click(screen.getByRole('button', { name: '返回' }));
+ expect(onCancel).toHaveBeenCalledTimes(1);
+ });
+});