feat(游戏共创): 首页新增「从平台作品开始创作」入口(唯一可点的客户端路径)
- 入口位置:首页模板库推荐位之后新增一节「从平台作品开始创作」。与模板库同属「从现成内容 起步」,但这条拿的是平台作品的已构建成品(可玩参考 + 素材),不是可编辑工程,因此单独成节 而不是塞进模板库;未登录用户进不到首页,服务端也确实对任何登录用户开放取件,无需额外灰度。 - 交互:输入平台作品 ID 或作品链接 → 解析出作品 ID(裸 ID / `?id=` / `?gameId=` / `/games/<id>`, 也容忍不带 scheme 的粘贴;解析不出就本地拦截、不发请求)→ 调 `create_local_project_from_platform_game`(name/planning 走默认,与模板链路一致)→ 成功后复用模板那条「原生已落盘 → 登记最近项目并进入工作区」通道进项目。 - 三类结果:① 未登录/登录过期(`authentication-required:`)→「请重新登录后重试」; ② 403 未开放授权(`permission-denied:`)与 409 未公开/已下架(`fork-source-not-available:`) 按服务端文案分别展示,且都不建项目、按钮可重试;③ 成功展示「改编自平台作品 <gameId>」 与 `reference/<gameId>/` 参考产物位置,让来源事实可见。 - 为此给取件的 404/409 补了与 401/403 同族的稳定前缀(`fork-source-not-found:` / `fork-source-not-available:`):渲染层按前缀精确分类,不做文案子串猜测。 - 加载态:取件期间按钮禁用 + 明确提示「下载、校验、解压需要几分钟」,并用 ref 守卫挡住同一 tick 的重复提交(只认最后一次请求)。 - 文案严守产品口径:拿到的是「已构建成品」= 可玩参考 + 素材,不能直接再次发布,需在新工程里 改造后发布;不出现「一键复刻工程」这类说法。 - 门禁登记:命令已接回 App 入口,因此从 `scripts/check-config.mjs` 的 native-only 白名单移除 (该脚本要求「已接入」与「未接入」两个集合互斥)。 - 测试:新增 `tests/platformGameFork.test.tsx`(纯解析/失败分类 + 组件:空与非法输入不发请求、 403/409/401 各自提示且不建项目、加载态与重复提交、成功时命令参数与「改编自」展示)。
This commit is contained in:
@@ -176,9 +176,6 @@ const allowedUncalledTauriCommands = [
|
||||
'sync_local_project_snapshot',
|
||||
'reset_design_agent_session',
|
||||
'stop_local_game_preview_if_matches',
|
||||
// 「从平台作品开始创作」本轮只落 native 能力:取件、摘要校验与建项都在 Rust 完成,
|
||||
// App 前端的改编入口(作品详情页「改造这个作品」/ 首页入口)尚未接线,先按 native-only 登记。
|
||||
'create_local_project_from_platform_game',
|
||||
'start_game_creator_external_mcp',
|
||||
'stop_game_creator_external_mcp',
|
||||
'list_agc_plugins',
|
||||
|
||||
@@ -514,6 +514,10 @@ const FORK_SOURCE_PACKAGE_MAX_BYTES: u64 = 512 * 1024 * 1024;
|
||||
/// 状态码本身就是服务端合同(`fork_source_target`):行不存在 → 404;不可用作来源
|
||||
/// (未公开 / 已软删除 / 没有当前公开版本 / 缺版本元数据)→ 409;授权禁止或未知 → 403;
|
||||
/// 未登录 → 401。因此这里按状态映射,不依赖 `error.code`。
|
||||
///
|
||||
/// 四个分类各自带稳定前缀(`authentication-required:` / `permission-denied:` /
|
||||
/// `fork-source-not-found:` / `fork-source-not-available:`):渲染层按前缀区分「需要登录 /
|
||||
/// 未开放授权 / 作品不存在 / 作品不可用」并按需要去掉前缀展示,不做子串猜测。
|
||||
fn map_fork_source_http_error(status: StatusCode, body: &str) -> String {
|
||||
crate::platform_maintenance::watch_platform_response(status.as_u16(), body);
|
||||
let (code, message) = parse_error_payload(body);
|
||||
@@ -524,9 +528,10 @@ fn map_fork_source_http_error(status: StatusCode, body: &str) -> String {
|
||||
StatusCode::FORBIDDEN => {
|
||||
"permission-denied: 作者没有开放这个作品的共创授权,无法改编".to_string()
|
||||
}
|
||||
StatusCode::NOT_FOUND => "该作品不存在或已被删除,无法改编".to_string(),
|
||||
StatusCode::NOT_FOUND => "fork-source-not-found: 该作品不存在或已被删除,无法改编".to_string(),
|
||||
StatusCode::CONFLICT => {
|
||||
"该作品当前不能作为改编来源(未公开、已下架或没有公开版本)".to_string()
|
||||
"fork-source-not-available: 该作品当前不能作为改编来源(未公开、已下架或没有公开版本)"
|
||||
.to_string()
|
||||
}
|
||||
_ => format!(
|
||||
"读取改编来源失败:{}",
|
||||
@@ -1894,14 +1899,14 @@ mod tests {
|
||||
StatusCode::NOT_FOUND,
|
||||
r#"{"error":{"code":"FORK_SOURCE_NOT_FOUND"}}"#
|
||||
),
|
||||
"该作品不存在或已被删除,无法改编"
|
||||
"fork-source-not-found: 该作品不存在或已被删除,无法改编"
|
||||
);
|
||||
assert_eq!(
|
||||
map_fork_source_http_error(
|
||||
StatusCode::CONFLICT,
|
||||
r#"{"error":{"code":"FORK_SOURCE_NOT_AVAILABLE"}}"#
|
||||
),
|
||||
"该作品当前不能作为改编来源(未公开、已下架或没有公开版本)"
|
||||
"fork-source-not-available: 该作品当前不能作为改编来源(未公开、已下架或没有公开版本)"
|
||||
);
|
||||
// 其它失败保留原始 detail,便于排障,但仍是可读文案而不是裸 HTTP 码。
|
||||
assert_eq!(
|
||||
|
||||
@@ -807,6 +807,19 @@ export function WorkspaceLauncherShell({
|
||||
}
|
||||
templateLibraryError={templateLibrary.error}
|
||||
onTemplateLibraryOpen={() => setLauncherView('template-library')}
|
||||
// 平台作品改编建出的项目与模板项目一样「原生已落盘」,复用同一条进项目通道;
|
||||
// 项目来源事实(改编自哪个作品)由命令写入 .agent/fork-source.json,首页负责展示。
|
||||
onPlatformGameProjectCreated={async (
|
||||
result,
|
||||
isCurrent,
|
||||
analytics,
|
||||
) => {
|
||||
await homeProject.enterCreatedTemplateProject(
|
||||
result,
|
||||
isCurrent,
|
||||
analytics,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : launcherView === 'projects' ? (
|
||||
<ProjectsPage
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 「从平台作品开始创作」的纯逻辑:输入解析与失败分类。
|
||||
*
|
||||
* 与原生取件的分工:这里只做**能在渲染层判定的格式问题**(空、非法字符、链接里找不到作品
|
||||
* ID)——判不了就不发请求;作品的可见性、授权与存在性一律以服务端为准(原生命令的
|
||||
* 404 / 409 / 403 / 401 已折成带稳定前缀的文案,见 `classifyPlatformForkFailure`)。
|
||||
*/
|
||||
|
||||
/** 平台作品 ID 的字符集,与原生 `fetch_platform_game_fork_source` 的路径安全判据一致。 */
|
||||
const GAME_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
/** 链接里可能承载作品 ID 的查询参数名(网页端详情页用 `id`,其余命名一并容忍)。 */
|
||||
const GAME_ID_QUERY_KEYS = ['gameId', 'gameid', 'game_id', 'id'] as const;
|
||||
|
||||
/**
|
||||
* 从「作品 ID 或作品链接」解析出平台作品 ID;无法确定时返回 `null`(调用方不得发请求)。
|
||||
*
|
||||
* 两条口径,顺序固定:
|
||||
* 1. 裸 ID:整串都落在作品 ID 字符集内即原样使用(存在性由服务端回答,不做本地白名单);
|
||||
* 2. 链接:先看查询参数,再看 `/games/` 之后的首个路径段(`/games/detail?id=` 已被第 1 步命中)。
|
||||
* 不解析 deep link、不依赖服务端:解析失败就是「请检查输入」,不是请求失败。
|
||||
*/
|
||||
export function parsePlatformGameId(input: string): string | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
if (GAME_ID_PATTERN.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
// 用户常直接粘贴不带 scheme 的地址;补一个占位 scheme 只用于解析,不参与任何请求。
|
||||
const withScheme = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//u.test(trimmed)
|
||||
? trimmed
|
||||
: `https://${trimmed.replace(/^\/+/u, '')}`;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(withScheme);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (const key of GAME_ID_QUERY_KEYS) {
|
||||
const candidate = url.searchParams.get(key)?.trim() ?? '';
|
||||
if (candidate && GAME_ID_PATTERN.test(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const segments = url.pathname.split('/').filter(Boolean);
|
||||
const gamesIndex = segments.indexOf('games');
|
||||
if (gamesIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
// `/games/detail?id=` 已被上面的查询参数命中;这里取 `/games/<id>` 的首个非 detail 段。
|
||||
const candidate = segments
|
||||
.slice(gamesIndex + 1)
|
||||
.find((segment) => segment !== 'detail');
|
||||
return candidate && GAME_ID_PATTERN.test(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
export type PlatformForkFailureKind =
|
||||
/** 未登录 / 登录态过期:可操作提示是「重新登录」。 */
|
||||
| 'auth'
|
||||
/** 403:作者没有开放共创授权。 */
|
||||
| 'forbidden'
|
||||
/** 404:作品不存在或已被删除。 */
|
||||
| 'notFound'
|
||||
/** 409:作品未公开 / 已下架 / 没有公开版本。 */
|
||||
| 'unavailable'
|
||||
/** 其它失败(网络、超时、未预期状态):原样展示原生文案。 */
|
||||
| 'other';
|
||||
|
||||
export type PlatformForkFailure = {
|
||||
kind: PlatformForkFailureKind;
|
||||
/** 去掉机器前缀后的用户可见文案。 */
|
||||
message: string;
|
||||
};
|
||||
|
||||
const FAILURE_PREFIXES: readonly [string, PlatformForkFailureKind][] = [
|
||||
['authentication-required:', 'auth'],
|
||||
['permission-denied:', 'forbidden'],
|
||||
['fork-source-not-found:', 'notFound'],
|
||||
['fork-source-not-available:', 'unavailable'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 把原生取件失败折成「可区分 + 可直接展示」的两段式结果。
|
||||
*
|
||||
* 前缀是原生错误契约的一部分(`map_fork_source_http_error`),因此这里按前缀精确匹配,
|
||||
* 绝不用文案子串猜测错误类型;未命中前缀的一律按 `other` 原样展示,不吞掉排障信息。
|
||||
*/
|
||||
export function classifyPlatformForkFailure(error: unknown): PlatformForkFailure {
|
||||
const raw = error instanceof Error ? error.message : String(error);
|
||||
const text = raw.trim();
|
||||
for (const [prefix, kind] of FAILURE_PREFIXES) {
|
||||
if (text.startsWith(prefix)) {
|
||||
const message = text.slice(prefix.length).trim();
|
||||
return { kind, message: message || raw.trim() };
|
||||
}
|
||||
}
|
||||
return { kind: 'other', message: text };
|
||||
}
|
||||
|
||||
/** 输入非法(解析不出作品 ID)时的统一提示:本地拦截,不发请求。 */
|
||||
export const PLATFORM_FORK_INPUT_ERROR =
|
||||
'请输入平台作品 ID 或作品链接(例如 game_ab12… 或 https://…/games/detail?id=game_ab12…)';
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* 首页「从平台作品开始创作」入口:输入平台作品 ID 或作品链接,取件并新建本地项目。
|
||||
*
|
||||
* 产品口径(与 `docs/【技术方案】游戏共创与作品Fork-2026-10-03.md` §2.5 / §3.5.1 一致):
|
||||
* 平台下发的是**已构建的发行成品包**,只能当「可玩参考 + 素材来源」,**不是可编辑的工程**,
|
||||
* 也不能直接再次发布;参考产物铺在新项目的 `reference/<作品ID>/` 下,用户要在新建的合规工程
|
||||
* (Phaser 4 + Vite 脚手架)里自己改造后发布。这里绝不写成「一键复刻工程」。
|
||||
*
|
||||
* 取件、摘要校验、解压与来源记录全在原生命令里(`create_local_project_from_platform_game`),
|
||||
* 渲染层只做输入解析、加载态与失败分类。
|
||||
*/
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
|
||||
import type { InitLocalProjectResult } from '../../app/types';
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
import {
|
||||
classifyPlatformForkFailure,
|
||||
parsePlatformGameId,
|
||||
PLATFORM_FORK_INPUT_ERROR,
|
||||
} from '../../features/platform-fork/platformGameForkModel';
|
||||
import {
|
||||
beginProjectOpenAnalytics,
|
||||
type ProjectOpenAnalytics,
|
||||
} from '../../services/clientAnalytics';
|
||||
|
||||
type PlatformGameForkSectionProps = {
|
||||
/**
|
||||
* 建项成功后的进项目通道:与模板库共用同一条「原生已建好 → 登记最近项目并进入工作区」路径,
|
||||
* 因此这里只透传结果、代次判据与项目打开埋点,不自己碰工作区状态。
|
||||
*/
|
||||
onProjectCreated: (
|
||||
result: InitLocalProjectResult,
|
||||
isCurrent: () => boolean,
|
||||
analytics: ProjectOpenAnalytics,
|
||||
) => Promise<void> | void;
|
||||
};
|
||||
|
||||
type PlatformGameForkSuccess = {
|
||||
gameId: string;
|
||||
projectPath: string;
|
||||
projectName: string;
|
||||
};
|
||||
|
||||
export default function PlatformGameForkSection({
|
||||
onProjectCreated,
|
||||
}: PlatformGameForkSectionProps) {
|
||||
const [input, setInput] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState<PlatformGameForkSuccess | null>(null);
|
||||
/** 只认最后一次提交:迟到的结果不再改状态,也不进入项目。 */
|
||||
const requestSequenceRef = useRef(0);
|
||||
/** 同一 tick 内的重复提交靠 ref 拦截:`busy` 状态要等下一次渲染才生效。 */
|
||||
const busyRef = useRef(false);
|
||||
|
||||
async function submitForkRequest(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (busyRef.current) {
|
||||
return;
|
||||
}
|
||||
const gameId = parsePlatformGameId(input);
|
||||
if (!gameId) {
|
||||
setSuccess(null);
|
||||
setError(PLATFORM_FORK_INPUT_ERROR);
|
||||
return;
|
||||
}
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
setSuccess(null);
|
||||
setError('需要在陶泥儿客户端内运行');
|
||||
return;
|
||||
}
|
||||
const sequence = requestSequenceRef.current + 1;
|
||||
requestSequenceRef.current = sequence;
|
||||
const isCurrent = () => requestSequenceRef.current === sequence;
|
||||
busyRef.current = true;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setSuccess(null);
|
||||
const analytics = beginProjectOpenAnalytics(invoke, 'create');
|
||||
try {
|
||||
const result = await invoke<InitLocalProjectResult>(
|
||||
'create_local_project_from_platform_game',
|
||||
{ gameId, name: null, planning: false },
|
||||
);
|
||||
if (!isCurrent()) {
|
||||
return;
|
||||
}
|
||||
setSuccess({
|
||||
gameId,
|
||||
projectPath: result.projectPath,
|
||||
projectName: result.manifest.name || gameId,
|
||||
});
|
||||
await onProjectCreated(result, isCurrent, analytics);
|
||||
} catch (nextError) {
|
||||
if (!isCurrent()) {
|
||||
return;
|
||||
}
|
||||
setError(classifyPlatformForkFailure(nextError).message);
|
||||
} finally {
|
||||
if (isCurrent()) {
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="mx-auto mt-5.5 grid w-[min(814px,calc(100vw-122px))] gap-3.5 max-[760px]:w-[min(100%,calc(100vw-76px))]"
|
||||
aria-label="从平台作品开始创作"
|
||||
>
|
||||
<header className="flex items-center justify-start gap-3">
|
||||
<h2 className="m-0 text-[15px] text-(--platform-text-strong)">
|
||||
从平台作品开始创作
|
||||
</h2>
|
||||
<span className="rounded-full bg-(--platform-neutral-bg) px-2 py-0.5 text-[11px] text-(--platform-warm-text)">
|
||||
参考改编
|
||||
</span>
|
||||
</header>
|
||||
{/*
|
||||
口径必须写清楚:拿到的是成品包(可玩参考 + 素材),不是可编辑工程,也不能直接再发布。
|
||||
否则用户会以为「一键复刻」之后可以直接上架。
|
||||
*/}
|
||||
<p className="m-0 text-[12px] leading-5 text-(--platform-text-soft)">
|
||||
输入平台上已开放共创的作品 ID 或作品链接。我们会取回该作品的
|
||||
<strong className="text-(--platform-text-strong)">已构建成品</strong>
|
||||
作为可玩参考与素材,放进新项目(合规的 Phaser 4 + Vite 工程)的{' '}
|
||||
<code>reference/<作品ID>/</code>{' '}
|
||||
目录。成品包里没有源码,不能直接再次发布,请在新工程里改造后发布。
|
||||
</p>
|
||||
<form
|
||||
className="flex items-center gap-2.5 max-[760px]:flex-wrap"
|
||||
onSubmit={submitForkRequest}
|
||||
>
|
||||
<input
|
||||
className="min-w-0 flex-1 rounded-lg border border-(--platform-subpanel-border) bg-(--platform-neutral-bg) px-3 py-2 text-[12.5px] text-(--platform-text-strong) outline-none disabled:opacity-60"
|
||||
aria-label="平台作品 ID 或链接"
|
||||
value={input}
|
||||
disabled={busy}
|
||||
placeholder="例如 game_ab12… 或 https://…/games/detail?id=game_ab12…"
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="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"
|
||||
type="submit"
|
||||
disabled={busy || input.trim().length === 0}
|
||||
>
|
||||
{busy ? '正在取回…' : '取回并创建项目'}
|
||||
</button>
|
||||
</form>
|
||||
{busy ? (
|
||||
<p
|
||||
className="m-0 text-[12px] leading-5 text-(--platform-text-soft)"
|
||||
role="status"
|
||||
>
|
||||
正在取回作品成品、校验摘要并创建项目;发行包较大时下载与解压需要几分钟,请不要关闭客户端。
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p className="m-0 text-[12px] leading-5 text-[#b42318]" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{success ? (
|
||||
<p
|
||||
className="m-0 text-[12px] leading-5 text-(--platform-text-strong)"
|
||||
role="status"
|
||||
>
|
||||
已创建项目《{success.projectName}》,改编自平台作品 {success.gameId}
|
||||
(来源已记录在项目的 .agent/fork-source.json)。参考产物在 reference/
|
||||
{success.gameId}/ 下,请在新工程里改造后发布。
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -12,10 +12,15 @@ import type { FormEvent } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import BRAND_ICON from '../../../../../packages/shared/src/icons/taonier-product-ip.png';
|
||||
import type { ProjectStartMode } from '../../app/types';
|
||||
import type {
|
||||
InitLocalProjectResult,
|
||||
ProjectStartMode,
|
||||
} from '../../app/types';
|
||||
import { resolveHomeCreationFailureMessage } from '../../features/app-shell/homeCreationFailure';
|
||||
import { ConversationModelSelect } from '../../features/project-workspace/ConversationModelSelect';
|
||||
import type { GameTemplateEntry } from '../../features/template-library/templateLibraryModel';
|
||||
import type { ProjectOpenAnalytics } from '../../services/clientAnalytics';
|
||||
import PlatformGameForkSection from './PlatformGameForkSection';
|
||||
import RichInputArea, { UploadButton } from './components/RichInputArea';
|
||||
import {
|
||||
richTextToAttachments,
|
||||
@@ -124,6 +129,15 @@ type HomeViewProps = {
|
||||
templateLibraryLoading: boolean;
|
||||
templateLibraryError: string;
|
||||
onTemplateLibraryOpen: () => void;
|
||||
/**
|
||||
* 「从平台作品开始创作」建成项目后的进项目通道:与模板库共用同一条原生已建好 → 登记最近项目
|
||||
* 并进入工作区的路径,首页只透传结果与项目打开埋点。
|
||||
*/
|
||||
onPlatformGameProjectCreated: (
|
||||
result: InitLocalProjectResult,
|
||||
isCurrent: () => boolean,
|
||||
analytics: ProjectOpenAnalytics,
|
||||
) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export default function HomeView({
|
||||
@@ -141,6 +155,7 @@ export default function HomeView({
|
||||
templateLibraryLoading,
|
||||
templateLibraryError,
|
||||
onTemplateLibraryOpen,
|
||||
onPlatformGameProjectCreated,
|
||||
}: HomeViewProps) {
|
||||
const homeCreationType = useLauncherHomeDraftStore(
|
||||
(state) => state.creationType,
|
||||
@@ -455,6 +470,12 @@ export default function HomeView({
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/*
|
||||
「从平台作品开始创作」放在模板库之后:两者都是「从现成内容起步」,但这条走平台作品的
|
||||
已授权成品包(可玩参考 + 素材),不是可编辑工程,因此与模板库分开成节。
|
||||
*/}
|
||||
<PlatformGameForkSection onProjectCreated={onPlatformGameProjectCreated} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
// @vitest-environment jsdom
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { InitLocalProjectResult } from '../src/app/types';
|
||||
import {
|
||||
classifyPlatformForkFailure,
|
||||
parsePlatformGameId,
|
||||
} from '../src/features/platform-fork/platformGameForkModel';
|
||||
import PlatformGameForkSection from '../src/view/home/PlatformGameForkSection';
|
||||
|
||||
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: '改编项目 1' },
|
||||
} as unknown as InitLocalProjectResult;
|
||||
|
||||
type InvokeCall = { command: string; args?: Record<string, unknown> };
|
||||
|
||||
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 renderSection() {
|
||||
const onProjectCreated = vi.fn();
|
||||
render(<PlatformGameForkSection onProjectCreated={onProjectCreated} />);
|
||||
return { onProjectCreated };
|
||||
}
|
||||
|
||||
function typeGameInput(value: string) {
|
||||
fireEvent.change(screen.getByLabelText('平台作品 ID 或链接'), {
|
||||
target: { value },
|
||||
});
|
||||
}
|
||||
|
||||
/** 直接提交表单:空输入时按钮是 disabled,只有提交表单才能覆盖「本地拦截」这条路径。 */
|
||||
function submitForm() {
|
||||
const form = screen
|
||||
.getByLabelText('平台作品 ID 或链接')
|
||||
.closest('form') as HTMLFormElement;
|
||||
fireEvent.submit(form);
|
||||
}
|
||||
|
||||
function forkCalls(calls: InvokeCall[]) {
|
||||
return calls.filter((call) => call.command === FORK_COMMAND);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
delete (window as unknown as { __TAURI__?: unknown }).__TAURI__;
|
||||
});
|
||||
|
||||
describe('游戏共创入口输入解析', () => {
|
||||
it('接受裸作品 ID 与常见作品链接形态', () => {
|
||||
expect(parsePlatformGameId(' game_ab12cd ')).toBe('game_ab12cd');
|
||||
expect(parsePlatformGameId('https://platform.test/games/detail?id=game_1')).toBe(
|
||||
'game_1',
|
||||
);
|
||||
expect(parsePlatformGameId('https://platform.test/games/detail?gameId=game_2')).toBe(
|
||||
'game_2',
|
||||
);
|
||||
expect(parsePlatformGameId('platform.test/games/game_3?from=share')).toBe(
|
||||
'game_3',
|
||||
);
|
||||
expect(parsePlatformGameId('https://platform.test/games/game_3/play')).toBe(
|
||||
'game_3',
|
||||
);
|
||||
});
|
||||
|
||||
it('解析不出作品 ID 时返回 null(本地拦截,不发请求)', () => {
|
||||
for (const input of [
|
||||
'',
|
||||
' ',
|
||||
'game/1',
|
||||
'game 1',
|
||||
'https://platform.test/other/path',
|
||||
'https://platform.test/games/detail',
|
||||
'https://platform.test/games',
|
||||
]) {
|
||||
expect(parsePlatformGameId(input), input).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('按原生前缀分类失败,并去掉机器前缀后展示', () => {
|
||||
expect(
|
||||
classifyPlatformForkFailure(
|
||||
new Error('authentication-required: 陶泥儿登录态已过期,请重新登录后重试'),
|
||||
),
|
||||
).toEqual({
|
||||
kind: 'auth',
|
||||
message: '陶泥儿登录态已过期,请重新登录后重试',
|
||||
});
|
||||
expect(
|
||||
classifyPlatformForkFailure(
|
||||
'permission-denied: 作者没有开放这个作品的共创授权,无法改编',
|
||||
),
|
||||
).toEqual({
|
||||
kind: 'forbidden',
|
||||
message: '作者没有开放这个作品的共创授权,无法改编',
|
||||
});
|
||||
expect(
|
||||
classifyPlatformForkFailure(
|
||||
'fork-source-not-found: 该作品不存在或已被删除,无法改编',
|
||||
),
|
||||
).toEqual({ kind: 'notFound', message: '该作品不存在或已被删除,无法改编' });
|
||||
expect(
|
||||
classifyPlatformForkFailure('fork-source-not-available: 该作品当前不能作为改编来源(未公开、已下架或没有公开版本)'),
|
||||
).toEqual({
|
||||
kind: 'unavailable',
|
||||
message: '该作品当前不能作为改编来源(未公开、已下架或没有公开版本)',
|
||||
});
|
||||
// 未命中前缀:原样展示,不吞掉排障信息。
|
||||
expect(classifyPlatformForkFailure(new Error('网络不可用'))).toEqual({
|
||||
kind: 'other',
|
||||
message: '网络不可用',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlatformGameForkSection', () => {
|
||||
it('空或非法输入不发请求,并给出可操作提示', () => {
|
||||
const calls = installInvoke(() => PROJECT_RESULT);
|
||||
renderSection();
|
||||
|
||||
submitForm();
|
||||
expect(forkCalls(calls)).toHaveLength(0);
|
||||
expect(screen.getByRole('alert').textContent).toContain(
|
||||
'请输入平台作品 ID 或作品链接',
|
||||
);
|
||||
|
||||
typeGameInput(' ');
|
||||
submitForm();
|
||||
expect(forkCalls(calls)).toHaveLength(0);
|
||||
|
||||
typeGameInput('https://platform.test/other/path');
|
||||
submitForm();
|
||||
expect(forkCalls(calls)).toHaveLength(0);
|
||||
expect(screen.getByRole('alert').textContent).toContain(
|
||||
'请输入平台作品 ID 或作品链接',
|
||||
);
|
||||
});
|
||||
|
||||
it('403 未开放授权:展示服务端文案、不建项目且可重试', async () => {
|
||||
installInvoke(() => {
|
||||
throw new Error('permission-denied: 作者没有开放这个作品的共创授权,无法改编');
|
||||
});
|
||||
const { onProjectCreated } = renderSection();
|
||||
|
||||
typeGameInput('game_locked');
|
||||
submitForm();
|
||||
|
||||
expect((await screen.findByRole('alert')).textContent).toBe(
|
||||
'作者没有开放这个作品的共创授权,无法改编',
|
||||
);
|
||||
expect(onProjectCreated).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '取回并创建项目' }),
|
||||
).toHaveProperty('disabled', false);
|
||||
});
|
||||
|
||||
it('409 作品不可用与 401 未登录:各自给出对应提示且不建项目', async () => {
|
||||
let failure = new Error(
|
||||
'fork-source-not-available: 该作品当前不能作为改编来源(未公开、已下架或没有公开版本)',
|
||||
);
|
||||
installInvoke(() => {
|
||||
throw failure;
|
||||
});
|
||||
const { onProjectCreated } = renderSection();
|
||||
|
||||
typeGameInput('game_private');
|
||||
submitForm();
|
||||
expect((await screen.findByRole('alert')).textContent).toContain(
|
||||
'该作品当前不能作为改编来源',
|
||||
);
|
||||
|
||||
failure = new Error(
|
||||
'authentication-required: 陶泥儿登录态已过期,请重新登录后重试',
|
||||
);
|
||||
typeGameInput('game_private');
|
||||
submitForm();
|
||||
expect((await screen.findByRole('alert')).textContent).toBe(
|
||||
'陶泥儿登录态已过期,请重新登录后重试',
|
||||
);
|
||||
expect(onProjectCreated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('取件期间展示加载态、禁止重复提交,完成后回到可提交状态', async () => {
|
||||
// `Promise.withResolvers` 需要 lib ES2024,本仓 AGC 走 ES2022(同
|
||||
// `gameDistributionPublishPanel.test.tsx` 的既有写法),因此手写等价 deferred。
|
||||
let resolveFork: (value: unknown) => void = () => undefined;
|
||||
const calls = installInvoke((command) =>
|
||||
command === FORK_COMMAND
|
||||
? new Promise((resolve) => {
|
||||
resolveFork = resolve as (value: unknown) => void;
|
||||
})
|
||||
: null,
|
||||
);
|
||||
renderSection();
|
||||
|
||||
typeGameInput('game_parent_1');
|
||||
submitForm();
|
||||
|
||||
expect(await screen.findByText(/正在取回作品成品/u)).not.toBeNull();
|
||||
expect(screen.getByRole('button', { name: '正在取回…' })).toHaveProperty(
|
||||
'disabled',
|
||||
true,
|
||||
);
|
||||
// 同一 tick 内重复提交:ref 守卫必须挡住第二次请求。
|
||||
submitForm();
|
||||
expect(forkCalls(calls)).toHaveLength(1);
|
||||
|
||||
resolveFork(PROJECT_RESULT);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(/正在取回作品成品/u)).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
it('成功:用解析出的作品 ID 调命令、进入新项目并展示「改编自」事实', async () => {
|
||||
const calls = installInvoke((command) =>
|
||||
command === FORK_COMMAND ? PROJECT_RESULT : null,
|
||||
);
|
||||
const { onProjectCreated } = renderSection();
|
||||
|
||||
typeGameInput('https://platform.test/games/detail?id=game_parent_1');
|
||||
submitForm();
|
||||
|
||||
await waitFor(() => expect(onProjectCreated).toHaveBeenCalledTimes(1));
|
||||
expect(forkCalls(calls)).toEqual([
|
||||
{
|
||||
command: FORK_COMMAND,
|
||||
args: { gameId: 'game_parent_1', name: null, planning: false },
|
||||
},
|
||||
]);
|
||||
const [result, isCurrent, analytics] = onProjectCreated.mock.calls[0] ?? [];
|
||||
expect(result).toBe(PROJECT_RESULT);
|
||||
expect(typeof isCurrent).toBe('function');
|
||||
expect(analytics).not.toBeNull();
|
||||
|
||||
const statusText = screen
|
||||
.getAllByRole('status')
|
||||
.map((node) => node.textContent ?? '')
|
||||
.join('\n');
|
||||
expect(statusText).toContain('改编自平台作品 game_parent_1');
|
||||
expect(statusText).toContain('reference/game_parent_1/');
|
||||
// 产品口径:只到「可玩参考 + 素材」,不是可编辑工程。
|
||||
expect(statusText).toContain('请在新工程里改造后发布');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user