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
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user