feat(游戏共创): Fork 工程确认页(授权校验 + 强制阅读 + 真实同步阶段)
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

照需求文档「Fork 工程确认页(客户端)」新增独立确认页,把原来的「列表上直接 Fork」升级成
「先确认授权边界,再同步工程」。

- 父作品信息卡:封面(复用已有的有界 data URL 链路,失败/缺封面回落首字占位)、标题、作者、
  当前授权类型(缺省读作「禁止共创」);数据优先用列表已加载的那份,缺了才按 gameId 走公开详情
  接口,**逐项降级不报错**。
- 二次确认 + 授权须知 + 风险说明(**强制阅读 3 秒**):五类内容逐条全文可见(可滚动,不折叠),
  用已统一的 Fork 口径;商用权限按父作品授权档位分档写(非商用档明确「不得用于盈利」,全开放档
  写明可商用);底部确认按钮在前 3 秒禁用并显示倒计时与进度条。
- 工程同步状态条:**只显示原生真实阶段**(`game-fork-progress`:取回作品内容 → 校验摘要与字节
  → 解压到项目 → 生成工程与来源记录),事件到哪一步点亮哪一步,阶段内部不可测量就只做「进行中」
  指示,**没有百分比动画**。需求文档的四分类(源码/素材/场景/配置)假设按资产种类分别同步,而这条
  链路是整包取回,因此如实降级并在页面上写明每个阶段在做什么。
- 底部「取消 / 确认并进入编辑」:确认 → 走既有取件建项链路 → 成功后由上层进入工作台;取消 → 回到
  进入前的界面(列表)。
- 落点与入口:共创列表的卡片与「粘贴作品 ID/链接」都改为进确认页(粘贴仍用同一份解析);深链
  `genarrative://fork?gameId=…` 直接打开确认页并预填父作品(带错误原因的深链仍留在列表展示)。

测试:新增 `forkConfirm.test.tsx`(10 条:信息卡四字段 + 缺字段降级、3 秒闸门与倒计时与可点、
五类须知全文与关键口径、全开放档商用文案、真实阶段点亮与未知阶段丢弃、确认后的命令参数与进工作台、
失败展示与不进工作台、取消回上层、无目标时的可读提示);列表测试改为断言「只打开确认页、不直接建项」;
appSurface 增一条「深链落确认页并预填」。
This commit is contained in:
2026-10-06 17:20:04 +08:00
parent dab99e82a8
commit 8ff01d65dd
12 changed files with 1109 additions and 148 deletions
@@ -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' ? (
<CoCreationView
) : launcherView === 'fork-confirm' ? (
<ForkConfirmView
onCancel={() => setLauncherView('co-creation')}
onProjectCreated={async (result, isCurrent, analytics) => {
await homeProject.enterCreatedTemplateProject(
result,
@@ -868,6 +883,13 @@ export function WorkspaceLauncherShell({
);
}}
/>
) : launcherView === 'co-creation' ? (
<CoCreationView
onOpenConfirm={(target) => {
useForkConfirmStore.getState().open(target);
setLauncherView('fork-confirm');
}}
/>
) : launcherView === 'template-library' && templateLibrary.enabled ? (
<TemplateLibraryView
controller={templateLibrary}
@@ -0,0 +1,33 @@
/**
* 「Fork 工程确认页」的进入目标:谁要 Fork 哪个作品。
*
* 两条入口共用同一个 store:
* - 共创列表卡片点「Fork」→ 打开确认页(带列表里已加载的作品,信息卡不必再等一次请求);
* - 深链 `genarrative://fork?gameId=…` → 直接打开确认页(作品信息靠公开详情补)。
*
* 取消时回到进入前的落点(`origin`),这样「从列表进来」与「从深链进来」都能回到合理的位置。
*/
import { create } from 'zustand';
import type { GameDistributionGame } from '../../../../../packages/shared/src/contracts/gameDistribution';
export type ForkConfirmOrigin = 'co-creation' | 'deep-link';
type ForkConfirmTarget = {
gameId: string;
/** 列表里已加载的作品(有则信息卡直接渲染,缺则确认页自己按 gameId 拉详情)。 */
game: GameDistributionGame | null;
origin: ForkConfirmOrigin;
};
type ForkConfirmStore = {
target: ForkConfirmTarget | null;
open: (target: ForkConfirmTarget) => void;
close: () => void;
};
export const useForkConfirmStore = create<ForkConfirmStore>((set) => ({
target: null,
open: (target) => set({ target }),
close: () => set({ target: null }),
}));
@@ -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: '违反上述授权、署名或溯源规则(例如删除署名、超范围商用、低改动度搬运)会被平台下架并要求整改;情节严重时限制账号的共创与发布权限。',
},
];
}
@@ -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<string, string> = {
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<ForkParentCard | null> {
const invoke = resolveTauriInvoke();
const normalized = gameId.trim();
if (!invoke || !normalized) {
return null;
}
try {
const game = await invoke<GameDistributionGame>(DETAIL_COMMAND, {
gameId: normalized,
});
if (!game || typeof game !== 'object' || !('id' in game)) {
return null;
}
return forkParentCardFromGame(game);
} catch {
return null;
}
}
@@ -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<unknown>(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],
};
}
@@ -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<PlatformForkSuccess | null>(null);
/** 原生侧真实发出的阶段(订阅在 invoke **之前**建立,否则会漏掉第一阶段)。 */
const [reachedStages, setReachedStages] = useState<ForkProgressStageKey[]>(
[],
);
/** 只认最后一次提交:迟到的结果不再改状态,也不进入项目。 */
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<InitLocalProjectResult>(
@@ -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,
@@ -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> | 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<ForkCatalogResult | null>(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<HTMLInputElement>(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<HTMLFormElement>) {
function submitPastedGameId(event: FormEvent<HTMLFormElement>) {
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({
<button
className="mt-6 shrink-0 cursor-pointer rounded-lg border-0 bg-(image:--platform-button-primary-fill) px-3.5 py-2 text-[12px] font-bold text-(--platform-button-primary-text) disabled:cursor-not-allowed disabled:opacity-55 max-[760px]:mt-0"
type="submit"
disabled={fork.busy || input.trim().length === 0}
disabled={input.trim().length === 0}
>
{fork.busy ? '正在 Fork…' : 'Fork 到我的项目'}
去确认并 Fork
</button>
</form>
{inputError ? (
@@ -213,28 +206,6 @@ export default function CoCreationView({
{inputError}
</p>
) : null}
{fork.error ? (
<p className="m-0 text-[12px] leading-5 text-[#b42318]" role="alert">
{fork.error}
</p>
) : null}
{fork.busy ? (
<p
className="m-0 text-[12px] leading-5 text-(--platform-text-soft)"
role="status"
>
正在
Fork:下载、校验并创建项目;内容较大时需要几分钟,请不要关闭客户端。
</p>
) : null}
{!fork.busy && fork.notice ? (
<p
className="m-0 text-[12px] leading-5 text-(--platform-text-strong)"
role="status"
>
{fork.notice}(来源已记录在项目的 .agent/fork-source.json)
</p>
) : null}
</section>
<section className={sectionClass} aria-label="支持共创的作品">
@@ -286,7 +257,6 @@ export default function CoCreationView({
{!catalogError && games.length > 0 ? (
<div className="grid grid-cols-3 gap-4 max-[900px]:grid-cols-2 max-[760px]:grid-cols-1">
{games.map((game) => {
const active = fork.activeGameId === game.id;
const coverUrl = coverUrls[game.id];
return (
<article className="grid min-w-0" key={game.id}>
@@ -332,10 +302,9 @@ export default function CoCreationView({
<button
className="mt-1 w-fit cursor-pointer rounded-lg border-0 bg-(image:--platform-button-primary-fill) px-2.5 py-1 text-[11px] font-bold text-(--platform-button-primary-text) disabled:cursor-not-allowed disabled:opacity-55"
type="button"
disabled={fork.busy}
onClick={() => void forkFromCard(game)}
>
{active ? '正在 Fork…' : 'Fork'}
Fork
</button>
</span>
</div>
@@ -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> | 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<ForkParentCard | null>(() =>
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<HTMLDivElement>(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 (
<div className="grid content-start gap-3 pb-10">
<section className={sectionClass} aria-label="Fork 确认">
<p className="m-0 text-[12px] leading-5 text-(--platform-text-soft)">
没有待确认的 Fork;请回到「共创」页选择一个作品。
</p>
<button
className="w-fit cursor-pointer rounded-lg border border-(--platform-surface-border) bg-(image:--platform-subpanel-fill) px-3 py-1.5 text-[12px] text-(--platform-text-strong)"
type="button"
onClick={onCancel}
>
返回
</button>
</section>
</div>
);
}
return (
<div className="grid content-start gap-0 pb-10">
<header className={sectionClass}>
<h1 className="m-0 text-[19px] font-semibold text-(--platform-text-strong)">
Fork 工程确认
</h1>
<p className="m-0 mt-1 text-[12px] leading-5 text-(--platform-text-soft)">
确认授权边界后再开始。Fork
会把整个工程复制一份到你的项目里继续改造(像 git
fork),副本完全独立。
</p>
</header>
{/* 父作品信息卡:封面 / 标题 / 作者 / 当前授权类型(缺失逐项降级,不报错) */}
<section className={sectionClass} aria-label="父作品信息">
<div className="grid grid-cols-[112px_minmax(0,1fr)] items-stretch gap-3.5 rounded-xl border border-(--platform-surface-border) bg-(image:--platform-subpanel-fill) p-3 shadow-(--platform-panel-shadow) max-[760px]:grid-cols-[88px_minmax(0,1fr)]">
<span
className="grid min-h-29 place-items-center overflow-hidden rounded-lg text-[26px] font-bold text-white shadow-inner"
style={{ backgroundColor: card?.coverColor || '#c2653d' }}
>
{coverUrl ? (
<img
className="h-full w-full object-cover"
src={coverUrl}
alt={`${card?.title || gameId} 封面`}
/>
) : (
<span aria-hidden="true">
{(card?.title || gameId || '?').slice(0, 1)}
</span>
)}
</span>
<span className="grid min-w-0 content-center gap-1.5">
<small className="text-[10px] text-(--platform-text-soft)">
父作品
</small>
<strong className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[15px] font-semibold text-(--platform-text-strong)">
{card?.title?.trim() ||
(cardLoading ? '正在读取作品信息…' : '(未获取到标题)')}
</strong>
<small className="grid grid-cols-[52px_minmax(0,1fr)] gap-x-2 gap-y-1 text-[11px] leading-4 text-(--platform-text-soft)">
<span className="whitespace-nowrap">作者</span>
<span className="truncate whitespace-nowrap text-(--platform-neutral-text)">
{card?.authorName?.trim() || '—'}
</span>
<span className="whitespace-nowrap">授权类型</span>
<span className="truncate whitespace-nowrap text-(--platform-warm-text)">
{card ? card.authorizationLabel : '—'}
</span>
<span className="whitespace-nowrap">作品 ID</span>
<code className="truncate whitespace-nowrap">{gameId}</code>
</small>
</span>
</div>
</section>
{/* 二次确认 + 授权须知 + 风险说明:全文可见(可滚动),前 3 秒确认按钮禁用 */}
<section className={sectionClass} aria-label="授权须知与风险说明">
<header className="flex items-center justify-between gap-3">
<h2 className="m-0 text-[15px] text-(--platform-text-strong)">
授权须知与风险说明
</h2>
<small className="text-[11px] text-(--platform-text-soft)">
{readingLocked
? `请阅读下面的须知,${remainingSeconds} 秒后可确认`
: '已可确认'}
</small>
</header>
<div
className="h-1 w-full overflow-hidden rounded-full bg-(--platform-neutral-bg)"
role="progressbar"
aria-label="强制阅读进度"
aria-valuemin={0}
aria-valuemax={MANDATORY_READING_SECONDS}
aria-valuenow={MANDATORY_READING_SECONDS - remainingSeconds}
>
<span
className="block h-full rounded-full bg-(image:--platform-button-primary-fill) transition-[width] duration-250"
style={{ width: `${Math.round(readingProgress * 100)}%` }}
/>
</div>
<div
className="grid max-h-72 gap-3 overflow-y-auto rounded-xl border border-(--platform-surface-border) bg-(image:--platform-subpanel-fill) p-3.5"
ref={noticeListRef}
aria-label="授权须知全文"
>
{notices.map((notice) => (
<article className="grid gap-1" key={notice.key}>
<strong className="text-[12px] font-bold text-(--platform-text-strong)">
{notice.title}
</strong>
<p className="m-0 text-[12px] leading-5 text-(--platform-text-soft)">
{notice.body}
</p>
</article>
))}
</div>
</section>
{/* 工程同步状态条:只显示原生真实阶段,不造百分比 */}
<section className={sectionClass} aria-label="工程同步状态">
<header className="flex items-center justify-between gap-3">
<h2 className="m-0 text-[15px] text-(--platform-text-strong)">
工程同步
</h2>
<small className="text-[11px] text-(--platform-text-soft)">
{fork.busy
? `正在:${activeStage ? forkProgressStageLabel(activeStage) : '准备中'}`
: reachedStages.length > 0
? '已同步完成'
: '确认后开始'}
</small>
</header>
<ol className="m-0 grid list-none gap-2 p-0" aria-label="工程同步阶段">
{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 (
<li
className="grid grid-cols-[18px_minmax(0,1fr)] items-start gap-2.5 text-[12px] leading-5"
key={stage.key}
>
<span
className={
'mt-0.5 grid size-4.5 place-items-center rounded-full border text-[9px] font-bold ' +
(done
? 'border-(--platform-warm-text) bg-(image:--platform-button-primary-fill) text-(--platform-button-primary-text)'
: active
? 'border-(--platform-warm-text) text-(--platform-warm-text)'
: 'border-(--platform-surface-border) text-(--platform-text-soft)')
}
aria-hidden="true"
>
{done ? '✓' : index + 1}
</span>
<span className="grid gap-0.5">
<span
className={
done || active
? 'font-bold text-(--platform-text-strong)'
: 'text-(--platform-text-soft)'
}
>
{stage.label}
{active ? '(进行中)' : ''}
</span>
<small className="text-[10px] text-(--platform-text-soft)">
{stage.detail}
</small>
</span>
</li>
);
})}
</ol>
{fork.error ? (
<p className="m-0 text-[12px] leading-5 text-[#b42318]" role="alert">
{fork.error}
</p>
) : null}
</section>
<section className={sectionClass}>
<div className="flex items-center justify-end gap-2.5">
<button
className="cursor-pointer rounded-lg border border-(--platform-surface-border) bg-(image:--platform-subpanel-fill) px-3.5 py-2 text-[12px] text-(--platform-text-strong) disabled:cursor-not-allowed disabled:opacity-55"
type="button"
disabled={fork.busy}
onClick={cancelFork}
>
取消
</button>
<button
className="cursor-pointer rounded-lg border-0 bg-(image:--platform-button-primary-fill) px-3.5 py-2 text-[12px] font-bold text-(--platform-button-primary-text) disabled:cursor-not-allowed disabled:opacity-55"
type="button"
disabled={readingLocked || fork.busy || !gameId}
onClick={() => void confirmFork()}
>
{fork.busy
? '正在 Fork…'
: readingLocked
? `确认并进入编辑(${remainingSeconds}s)`
: '确认并进入编辑'}
</button>
</div>
{fork.busy ? (
<p
className="m-0 mt-2 text-[11px] leading-4 text-(--platform-text-soft)"
role="status"
>
正在同步工程:内容较大时需要几分钟,请不要关闭客户端。同步期间不能取消。
</p>
) : null}
{!fork.busy && reachedStages.length > 0 ? (
<p
className="m-0 mt-2 text-[11px] leading-4 text-(--platform-text-soft)"
role="status"
>
<RefreshCcw
className="mr-1 inline-block align-[-2px]"
size={11}
aria-hidden="true"
/>
同步已完成,即将进入工作台。
</p>
) : null}
</section>
</div>
);
}
@@ -32,6 +32,7 @@ export type LauncherView =
| 'contact'
| 'news'
| 'co-creation'
| 'fork-confirm'
| 'template-library'
| 'project-development';
@@ -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') {
@@ -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(<CoCreationView onProjectCreated={onProjectCreated} />);
return { onProjectCreated };
const onOpenConfirm = vi.fn();
render(<CoCreationView onOpenConfirm={onOpenConfirm} />);
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(<CoCreationView onProjectCreated={vi.fn()} />);
const { container } = render(<CoCreationView onOpenConfirm={vi.fn()} />);
// 有封面的那张走 <img>,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');
});
});
@@ -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<string, unknown> };
let eventFake: TauriEventFake | null = null;
function installInvoke(
implementation: (command: string, args?: Record<string, unknown>) => unknown,
) {
const calls: InvokeCall[] = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
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(
<ForkConfirmView onCancel={onCancel} onProjectCreated={onProjectCreated} />,
);
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');
// 缺授权类型读作「禁止共创」(契约缺省),缺封面不渲染 <img>。
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);
});
});