From 8ff01d65dd152f440c98c0d7682ba90aede77065 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 6 Oct 2026 17:20:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=B8=B8=E6=88=8F=E5=85=B1=E5=88=9B):=20F?= =?UTF-8?q?ork=20=E5=B7=A5=E7=A8=8B=E7=A1=AE=E8=AE=A4=E9=A1=B5=EF=BC=88?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E6=A0=A1=E9=AA=8C=20+=20=E5=BC=BA=E5=88=B6?= =?UTF-8?q?=E9=98=85=E8=AF=BB=20+=20=E7=9C=9F=E5=AE=9E=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 照需求文档「Fork 工程确认页(客户端)」新增独立确认页,把原来的「列表上直接 Fork」升级成 「先确认授权边界,再同步工程」。 - 父作品信息卡:封面(复用已有的有界 data URL 链路,失败/缺封面回落首字占位)、标题、作者、 当前授权类型(缺省读作「禁止共创」);数据优先用列表已加载的那份,缺了才按 gameId 走公开详情 接口,**逐项降级不报错**。 - 二次确认 + 授权须知 + 风险说明(**强制阅读 3 秒**):五类内容逐条全文可见(可滚动,不折叠), 用已统一的 Fork 口径;商用权限按父作品授权档位分档写(非商用档明确「不得用于盈利」,全开放档 写明可商用);底部确认按钮在前 3 秒禁用并显示倒计时与进度条。 - 工程同步状态条:**只显示原生真实阶段**(`game-fork-progress`:取回作品内容 → 校验摘要与字节 → 解压到项目 → 生成工程与来源记录),事件到哪一步点亮哪一步,阶段内部不可测量就只做「进行中」 指示,**没有百分比动画**。需求文档的四分类(源码/素材/场景/配置)假设按资产种类分别同步,而这条 链路是整包取回,因此如实降级并在页面上写明每个阶段在做什么。 - 底部「取消 / 确认并进入编辑」:确认 → 走既有取件建项链路 → 成功后由上层进入工作台;取消 → 回到 进入前的界面(列表)。 - 落点与入口:共创列表的卡片与「粘贴作品 ID/链接」都改为进确认页(粘贴仍用同一份解析);深链 `genarrative://fork?gameId=…` 直接打开确认页并预填父作品(带错误原因的深链仍留在列表展示)。 测试:新增 `forkConfirm.test.tsx`(10 条:信息卡四字段 + 缺字段降级、3 秒闸门与倒计时与可点、 五类须知全文与关键口径、全开放档商用文案、真实阶段点亮与未知阶段丢弃、确认后的命令参数与进工作台、 失败展示与不进工作台、取消回上层、无目标时的可读提示);列表测试改为断言「只打开确认页、不直接建项」; appSurface 增一条「深链落确认页并预填」。 --- .../features/app-shell/WorkspaceLauncher.tsx | 28 +- .../platform-fork/forkConfirmTarget.ts | 33 ++ .../src/features/platform-fork/forkNotice.ts | 52 +++ .../features/platform-fork/forkParentCard.ts | 80 ++++ .../features/platform-fork/forkProgress.ts | 93 ++++ .../features/platform-fork/usePlatformFork.ts | 28 ++ .../src/view/co-creation/index.tsx | 61 +-- .../src/view/fork-confirm/index.tsx | 399 ++++++++++++++++++ .../ai-game-creator-shell/src/view/layout.tsx | 1 + .../tests/appSurface/home.suite.ts | 40 ++ .../tests/coCreation.test.tsx | 126 ++---- .../tests/forkConfirm.test.tsx | 316 ++++++++++++++ 12 files changed, 1109 insertions(+), 148 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/platform-fork/forkConfirmTarget.ts create mode 100644 apps/ai-game-creator-shell/src/features/platform-fork/forkNotice.ts create mode 100644 apps/ai-game-creator-shell/src/features/platform-fork/forkParentCard.ts create mode 100644 apps/ai-game-creator-shell/src/features/platform-fork/forkProgress.ts create mode 100644 apps/ai-game-creator-shell/src/view/fork-confirm/index.tsx create mode 100644 apps/ai-game-creator-shell/tests/forkConfirm.test.tsx 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 ( +
+
+

+ Fork 工程确认 +

+

+ 确认授权边界后再开始。Fork + 会把整个工程复制一份到你的项目里继续改造(像 git + fork),副本完全独立。 +

+
+ + {/* 父作品信息卡:封面 / 标题 / 作者 / 当前授权类型(缺失逐项降级,不报错) */} +
+
+ + {coverUrl ? ( + {`${card?.title + ) : ( + + )} + + + + 父作品 + + + {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 ( +
  1. + + + + {stage.label} + {active ? '(进行中)' : ''} + + + {stage.detail} + + +
  2. + ); + })} +
+ {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); + }); +});