feat(游戏共创): 首页内联 Fork 面板改为侧边栏「共创」页
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 / 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 / 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 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 / 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 / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
用户看了真机截图判定首页那张内联卡片太丑,撤掉并改成独立一页。
- 删掉首页内联面板:`src/view/home/PlatformGameForkSection.tsx`(整文件)与首页里的渲染块、两个
专属 props(`onPlatformGameProjectCreated`)及其 import(该 prop 一路从 shell 传下来,一并清掉,
不留死代码);首页恢复成「最近项目 + 模板库推荐」。旧组件测试文件
`tests/platformGameFork.test.tsx` 删除,其中仍有价值的解析/分类用例抽成
`tests/platformGameForkModel.test.ts`(纯模型,不依赖组件)。
- 侧边栏新增「共创」:`LauncherView` 加 `co-creation`,`view/layout.tsx` 的 rail 加一个按钮
(icon 用仓库既有的 lucide-react,选 `GitFork` 对应「像 git fork」的口径;`aria-label`/`title`
都是「共创」,与既有 rail 项的悬停提示方式一致)。
- 新页 `src/view/co-creation/index.tsx`:
· 数据源是平台公开目录(匿名可读):新增原生命令 `list_platform_game_catalog(limit, cursor)`
(不带 Bearer——带鉴权头会把「匿名可读」变成依赖登录态;仍取会话只为拿 base URL;limit
与服务端同口径:缺省 20 / 上限 50,用 `url` 拼查询串避免手拼游标)。
· 只保留支持共创的:`forkAuthorization !== 'forbidden'`(字段缺省按 forbidden)。
· 排序:只按接口顺序(新 → 旧);推荐算法留 TODO,替换点在 `loadForkableGames`
(UI 只消费它的返回值)。
· 翻页:最多 3 页 / 150 条,扫到 12 个可 Fork 作品就提前停;被上限截断时页头如实标注
「已检查最新 N 个作品(M 页,最多 3 页)」。理由写在常量注释里:目录是新→旧的、可 Fork
的作品可能稀疏,3 页足够填满一屏,同时把最坏请求数与耗时钉住。
· 卡片:与首页「最近项目」同一套视觉语言(同尺寸圆角卡、首字占位块用作品的 `coverColor`),
展示标题 / 作者 / 类型 / 更新时间,卡片内一个「Fork」按钮——**一步可达**,点击即走取件建项。
· 空态:扫完仍没有可 Fork 作品时给明确文案(而不是空白);加载态 / 失败态(含重试)照既有写法。
- 「填详细内容」随页搬走:原来的「输入作品 ID 或链接」输入框放到列表上方,解析仍用
`parsePlatformGameId`(与 deep link 完全同一份,没有第二套)。
- 提交路径只留一份实现:新增 `usePlatformFork` 状态机,卡片按钮与粘贴输入共用它
(取件、失败分类、加载态、进项目与埋点都是同一段代码;`busy` 用 ref 挡同一 tick 的重复提交)。
- deep link:`genarrative://fork?gameId=…` 现在切到「共创」页并预填输入框(仍**不自动下载**),
不再指向首页内联面板;正停在项目工作区时行为不变(不强行切走,请求留着)。
测试:新增 `tests/coCreation.test.tsx`(12 条:目录模型过滤/提前停/页数上限、列表渲染与过滤、
点卡片 Fork 的参数与「点击前不发请求」、粘贴链接同一解析、非法输入本地拦截、空态、失败态与重试、
深链预填且不自动下载、深链错误原因、取件失败分类);`tests/platformForkDeepLink.test.tsx` 收窄为
事件解析 + 订阅(预填行为改由共创页测试覆盖,不重复断言);`tests/appSurface/home.suite.ts` 增两条
外壳级回归:首页不再出现内联面板(标题与按钮都不在)、侧边栏「共创」可切换并落在共创页;
Rust 增一条 `catalog_limit_clamps_to_server_range`。
This commit is contained in:
@@ -622,6 +622,7 @@ pub(super) fn run() {
|
||||
suggest_game_distribution_publish_metadata,
|
||||
read_game_cover_generation_price,
|
||||
generate_game_distribution_cover,
|
||||
list_platform_game_catalog,
|
||||
read_game_distribution_publication,
|
||||
publish_local_project_game,
|
||||
read_game_creator_app_config,
|
||||
|
||||
@@ -29,8 +29,8 @@ use shared_contracts::game_distribution::{
|
||||
GameDistributionCreateGameRequest, GameDistributionCreateVersionRequest,
|
||||
GameDistributionDeviceSupport, GameDistributionForkAuthorization,
|
||||
GameDistributionForkDeclaration, GameDistributionForkSource, GameDistributionInputMode,
|
||||
GameDistributionOrientation, GameDistributionPublishMetadataSuggestion,
|
||||
GameDistributionPublishMetadataSuggestionRequest,
|
||||
GameDistributionListResponse, GameDistributionOrientation,
|
||||
GameDistributionPublishMetadataSuggestion, GameDistributionPublishMetadataSuggestionRequest,
|
||||
};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
@@ -678,6 +678,16 @@ async fn request_fork_source_package(
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// 公开目录每页条数:与服务端口径一致(缺省 20 / 上限 50)。
|
||||
pub(crate) fn clamped_catalog_limit(limit: Option<u32>) -> u32 {
|
||||
const DEFAULT: u32 = 20;
|
||||
const MAX: u32 = 50;
|
||||
match limit {
|
||||
Some(0) | None => DEFAULT,
|
||||
Some(value) => value.min(MAX),
|
||||
}
|
||||
}
|
||||
|
||||
/// 作品 ID 的路径安全判据:只接受 ASCII 字母数字与 `-`/`_`。
|
||||
///
|
||||
/// 取件请求把作品 ID 当路径段拼进 URL,深链解析也用它校验链接里的取值——两处同一口径。
|
||||
@@ -1047,6 +1057,75 @@ async fn bound_publication_result(
|
||||
})
|
||||
}
|
||||
|
||||
/// 平台公开作品目录(`GET /api/game-distribution/games?limit=&cursor=`)。
|
||||
///
|
||||
/// 这条接口**匿名可读**,因此不叠加发布灰度、按公开投影返回;这里仍取平台会话只为拿 base URL
|
||||
/// (启动器本身在登录后才渲染,所以正常路径一定有会话),**不带 Bearer**——带上反而会把
|
||||
/// 「匿名可读」变成依赖登录态。
|
||||
///
|
||||
/// 分页与服务端同口径(缺省 20 / 上限 50),游标原样透传(服务端校验非法游标并回 400)。
|
||||
/// 「共创」页拿它当数据源,在该页做「只保留 `forkAuthorization !== 'forbidden'`」的过滤。
|
||||
#[tauri::command]
|
||||
pub(crate) async fn list_platform_game_catalog(
|
||||
limit: Option<u32>,
|
||||
cursor: Option<String>,
|
||||
) -> Result<GameDistributionListResponse, String> {
|
||||
let snapshot = require_platform_session()?;
|
||||
let client = build_client()?;
|
||||
let limit = clamped_catalog_limit(limit);
|
||||
let cursor = cursor
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
// 用 `url` 拼查询串:游标形如 `"{ts}:{id}"`,必须按 query 规则编码而不是手拼。
|
||||
let mut url = url::Url::parse(&endpoint(
|
||||
&snapshot,
|
||||
&["api", "game-distribution", "games"],
|
||||
)?)
|
||||
.map_err(|error| format!("拼装共创目录请求失败:{error}"))?;
|
||||
{
|
||||
let mut query = url.query_pairs_mut();
|
||||
query.append_pair("limit", &limit.to_string());
|
||||
if let Some(cursor) = cursor.as_deref() {
|
||||
query.append_pair("cursor", cursor);
|
||||
}
|
||||
}
|
||||
let response = client
|
||||
.request(Method::GET, url.as_str())
|
||||
.header(
|
||||
HeaderName::from_static(AGC_CLIENT_MARKER_HEADER),
|
||||
HeaderValue::from_static(AGC_CLIENT_MARKER_VALUE),
|
||||
)
|
||||
.header(
|
||||
HeaderName::from_static(API_RESPONSE_ENVELOPE_HEADER),
|
||||
HeaderValue::from_static(API_RESPONSE_ENVELOPE_VERSION),
|
||||
)
|
||||
.timeout(HTTP_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if error.is_timeout() {
|
||||
"读取共创目录失败:请求超时,请稍后重试".to_string()
|
||||
} else {
|
||||
"读取共创目录失败:无法连接登录服务,请确认配套后端或 API 代理已启动后重试"
|
||||
.to_string()
|
||||
}
|
||||
})?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|error| format!("读取共创目录失败:读取响应失败:{error}"))?;
|
||||
if !status.is_success() {
|
||||
let (code, message) = parse_error_payload(&text);
|
||||
return Err(format!(
|
||||
"读取共创目录失败:{}",
|
||||
server_error_detail(code, message, || format!("HTTP {}", status.as_u16()))
|
||||
));
|
||||
}
|
||||
let value = response_data(&text).map_err(|error| format!("读取共创目录失败:{error}"))?;
|
||||
serde_json::from_value(value).map_err(|error| format!("共创目录响应无效:{error}"))
|
||||
}
|
||||
|
||||
/// 发布面板打开时的绑定恢复。
|
||||
///
|
||||
/// 只有「本地绑定(且属于当前账号/origin)+ 线上作品」或「无本地绑定时按作者 + localProjectId
|
||||
@@ -2010,6 +2089,16 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_limit_clamps_to_server_range() {
|
||||
// 缺省 20 / 上限 50,与服务端公开目录同口径;0 视作缺省(服务端同义)。
|
||||
assert_eq!(clamped_catalog_limit(None), 20);
|
||||
assert_eq!(clamped_catalog_limit(Some(0)), 20);
|
||||
assert_eq!(clamped_catalog_limit(Some(7)), 7);
|
||||
assert_eq!(clamped_catalog_limit(Some(50)), 50);
|
||||
assert_eq!(clamped_catalog_limit(Some(500)), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_source_errors_keep_contract_statuses_distinguishable() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
canSubscribeTauriEvents,
|
||||
subscribeTauriEvent,
|
||||
} from '../../services/tauriEventSubscription';
|
||||
import CoCreationView from '../../view/co-creation';
|
||||
import HomeView from '../../view/home';
|
||||
import { type LauncherView, Sidebar } from '../../view/layout';
|
||||
import ProjectDevelopmentView from '../../view/project-development';
|
||||
@@ -674,7 +675,7 @@ export function WorkspaceLauncherShell({
|
||||
void subscribeForkDeepLink((request) => {
|
||||
useForkDeepLinkStore.getState().request(request);
|
||||
setLauncherView((current) =>
|
||||
current === 'project-development' ? current : 'home',
|
||||
current === 'project-development' ? current : 'co-creation',
|
||||
);
|
||||
})
|
||||
.then((release) => {
|
||||
@@ -851,11 +852,15 @@ export function WorkspaceLauncherShell({
|
||||
onTemplateLibraryOpen={() => setLauncherView('template-library')}
|
||||
// 平台作品改编建出的项目与模板项目一样「原生已落盘」,复用同一条进项目通道;
|
||||
// 项目来源事实(改编自哪个作品)由命令写入 .agent/fork-source.json,首页负责展示。
|
||||
onPlatformGameProjectCreated={async (
|
||||
result,
|
||||
isCurrent,
|
||||
analytics,
|
||||
) => {
|
||||
/>
|
||||
) : launcherView === 'projects' ? (
|
||||
<ProjectsPage
|
||||
homeProject={homeProject}
|
||||
recentProjects={recentProjects}
|
||||
/>
|
||||
) : launcherView === 'co-creation' ? (
|
||||
<CoCreationView
|
||||
onProjectCreated={async (result, isCurrent, analytics) => {
|
||||
await homeProject.enterCreatedTemplateProject(
|
||||
result,
|
||||
isCurrent,
|
||||
@@ -863,11 +868,6 @@ export function WorkspaceLauncherShell({
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : launcherView === 'projects' ? (
|
||||
<ProjectsPage
|
||||
homeProject={homeProject}
|
||||
recentProjects={recentProjects}
|
||||
/>
|
||||
) : launcherView === 'template-library' && templateLibrary.enabled ? (
|
||||
<TemplateLibraryView
|
||||
controller={templateLibrary}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 「共创」页的数据源:平台公开作品目录 + 「只保留支持共创的」过滤。
|
||||
*
|
||||
* 平台侧没有「只列可 Fork 作品」的接口,因此这里从公开目录(新 → 旧)**顺序扫描**并按
|
||||
* `forkAuthorization !== 'forbidden'` 过滤。推荐算法**留 TODO**:现在只按接口给的顺序取最新,
|
||||
* 等平台出推荐/排序接口(或目录支持按 forkAuthorization 过滤)后,替换点就在
|
||||
* `loadForkableGames` 里——UI 只消费它的返回值,不需要跟着改。
|
||||
*/
|
||||
import type { GameDistributionGame } from '../../../../../packages/shared/src/contracts/gameDistribution';
|
||||
|
||||
/** 每页条数:用满服务端公开目录的上限,少发几次请求。 */
|
||||
export const FORK_CATALOG_PAGE_SIZE = 50;
|
||||
/**
|
||||
* 最多向后扫几页。
|
||||
*
|
||||
* 理由:目录是新 → 旧的,而可 Fork 的作品可能很稀疏;扫太多页会让「打开页面」变成慢操作。
|
||||
* 3 页(最多 150 条)在实测目录规模下足够填满一屏卡片,同时把最坏情况的请求数与耗时钉住。
|
||||
*/
|
||||
export const FORK_CATALOG_MAX_PAGES = 3;
|
||||
/** 够一屏卡片就不再往后扫(避免每次进来都打满 3 页)。 */
|
||||
export const FORK_CATALOG_ENOUGH = 12;
|
||||
|
||||
export type ForkCatalogResult = {
|
||||
/** 过滤后的可 Fork 作品(按接口顺序 = 最新在前)。 */
|
||||
games: GameDistributionGame[];
|
||||
/** 实际扫过的页数(用于报告「已检查多少条」)。 */
|
||||
scannedPages: number;
|
||||
/** 扫过的原始条数。 */
|
||||
scannedGames: number;
|
||||
/** 是否因为页数上限而停(此时可能还有更多可 Fork 的作品)。 */
|
||||
cappedByPageLimit: boolean;
|
||||
};
|
||||
|
||||
/** 支持共创 = 授权不是「禁止共创」;字段缺省按 `forbidden` 处理(与契约一致)。 */
|
||||
export function isForkableGame(game: GameDistributionGame): boolean {
|
||||
return (game.forkAuthorization ?? 'forbidden') !== 'forbidden';
|
||||
}
|
||||
|
||||
export function forkableGames(
|
||||
games: readonly GameDistributionGame[],
|
||||
): GameDistributionGame[] {
|
||||
return games.filter(isForkableGame);
|
||||
}
|
||||
|
||||
type CatalogPage = {
|
||||
games?: GameDistributionGame[];
|
||||
nextCursor?: string | null;
|
||||
};
|
||||
|
||||
type CatalogInvoke = <T>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => Promise<T>;
|
||||
|
||||
/**
|
||||
* 顺序扫描公开目录,直到凑够一屏可 Fork 作品、没有下一页、或撞到页数上限。
|
||||
*
|
||||
* 只做只读请求(匿名目录接口),失败时把错误抛给调用方渲染失败态——不在这里吞掉。
|
||||
*/
|
||||
export async function loadForkableGames(
|
||||
invoke: CatalogInvoke,
|
||||
options: {
|
||||
maxPages?: number;
|
||||
enough?: number;
|
||||
pageSize?: number;
|
||||
} = {},
|
||||
): Promise<ForkCatalogResult> {
|
||||
const maxPages = options.maxPages ?? FORK_CATALOG_MAX_PAGES;
|
||||
const enough = options.enough ?? FORK_CATALOG_ENOUGH;
|
||||
const pageSize = options.pageSize ?? FORK_CATALOG_PAGE_SIZE;
|
||||
const games: GameDistributionGame[] = [];
|
||||
let scannedPages = 0;
|
||||
let scannedGames = 0;
|
||||
let cursor: string | null = null;
|
||||
let cappedByPageLimit = false;
|
||||
|
||||
while (scannedPages < maxPages) {
|
||||
const page: CatalogPage = await invoke<CatalogPage>(
|
||||
'list_platform_game_catalog',
|
||||
{
|
||||
limit: pageSize,
|
||||
cursor,
|
||||
},
|
||||
);
|
||||
scannedPages += 1;
|
||||
const pageGames: GameDistributionGame[] = Array.isArray(page?.games)
|
||||
? page.games
|
||||
: [];
|
||||
scannedGames += pageGames.length;
|
||||
games.push(...forkableGames(pageGames));
|
||||
const nextCursor: string | null = page?.nextCursor ?? null;
|
||||
if (!nextCursor) {
|
||||
break;
|
||||
}
|
||||
if (games.length >= enough) {
|
||||
break;
|
||||
}
|
||||
if (scannedPages >= maxPages) {
|
||||
cappedByPageLimit = true;
|
||||
break;
|
||||
}
|
||||
cursor = nextCursor;
|
||||
}
|
||||
|
||||
return { games, scannedPages, scannedGames, cappedByPageLimit };
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Fork 提交状态机:**卡片点击**与**粘贴作品 ID/链接**共用这一条路径。
|
||||
*
|
||||
* 两处入口只有「作品 ID 从哪来」不同,取件、失败分类、进项目与埋点完全一致——所以实现只留这一份。
|
||||
* 取件、摘要校验、解压与来源记录都在原生命令里(`create_local_project_from_platform_game`),
|
||||
* 渲染层只负责加载态、错误分类与建项后的进入动作。
|
||||
*/
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
import type { InitLocalProjectResult } from '../../app/types';
|
||||
import {
|
||||
beginProjectOpenAnalytics,
|
||||
type ProjectOpenAnalytics,
|
||||
} from '../../services/clientAnalytics';
|
||||
import { classifyPlatformForkFailure } from './platformGameForkModel';
|
||||
|
||||
export type PlatformForkSuccess = {
|
||||
gameId: string;
|
||||
projectName: string;
|
||||
};
|
||||
|
||||
type UsePlatformForkOptions = {
|
||||
/**
|
||||
* 建项成功后的进项目通道:与模板库共用同一条「原生已建好 → 登记最近项目并进入工作区」路径,
|
||||
* 因此这里只透传结果、代次判据与项目打开埋点,不自己碰工作区状态。
|
||||
*/
|
||||
onProjectCreated: (
|
||||
result: InitLocalProjectResult,
|
||||
isCurrent: () => boolean,
|
||||
analytics: ProjectOpenAnalytics,
|
||||
) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export function usePlatformFork({ onProjectCreated }: UsePlatformForkOptions) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
/** 当前正在 Fork 的作品 ID:列表卡片只让这一张进入加载态。 */
|
||||
const [activeGameId, setActiveGameId] = useState<string | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [success, setSuccess] = useState<PlatformForkSuccess | null>(null);
|
||||
/** 只认最后一次提交:迟到的结果不再改状态,也不进入项目。 */
|
||||
const requestSequenceRef = useRef(0);
|
||||
/** 同一 tick 内的重复提交靠 ref 拦截:`busy` 状态要等下一次渲染才生效。 */
|
||||
const busyRef = useRef(false);
|
||||
const onProjectCreatedRef = useRef(onProjectCreated);
|
||||
onProjectCreatedRef.current = onProjectCreated;
|
||||
|
||||
const forkGame = useCallback(async (gameId: string) => {
|
||||
const normalized = gameId.trim();
|
||||
if (!normalized || busyRef.current) {
|
||||
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);
|
||||
setActiveGameId(normalized);
|
||||
setError('');
|
||||
setNotice('');
|
||||
setSuccess(null);
|
||||
const analytics = beginProjectOpenAnalytics(invoke, 'create');
|
||||
try {
|
||||
const result = await invoke<InitLocalProjectResult>(
|
||||
'create_local_project_from_platform_game',
|
||||
{ gameId: normalized, name: null, planning: false },
|
||||
);
|
||||
if (!isCurrent()) {
|
||||
return;
|
||||
}
|
||||
const projectName = result.manifest.name || normalized;
|
||||
setSuccess({ gameId: normalized, projectName });
|
||||
setNotice(`已创建项目《${projectName}》,Fork 自平台作品 ${normalized}`);
|
||||
await onProjectCreatedRef.current(result, isCurrent, analytics);
|
||||
} catch (nextError) {
|
||||
if (!isCurrent()) {
|
||||
return;
|
||||
}
|
||||
setError(classifyPlatformForkFailure(nextError).message);
|
||||
} finally {
|
||||
if (isCurrent()) {
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
setActiveGameId(null);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
busy,
|
||||
activeGameId,
|
||||
error,
|
||||
notice,
|
||||
success,
|
||||
forkGame,
|
||||
setError,
|
||||
setNotice,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* 「共创」页:列出支持共创(可 Fork)的平台作品,并支持按作品 ID/链接 Fork。
|
||||
*
|
||||
* 对外口径只有一句:**Fork = 把整个工程复制一份到你的项目里继续改造(与 git fork 同理)**。
|
||||
* 列表与「填详细内容」两条入口共用同一个提交状态机(`usePlatformFork`),卡片上的 Fork 按钮是
|
||||
* **一步可达**的确认动作——不会自动下载,也不会跳去网页。
|
||||
*/
|
||||
|
||||
import { RefreshCcw } from 'lucide-react';
|
||||
import type { FormEvent } from 'react';
|
||||
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,
|
||||
type ForkCatalogResult,
|
||||
loadForkableGames,
|
||||
} from '../../features/platform-fork/platformForkCatalog';
|
||||
import {
|
||||
parsePlatformGameId,
|
||||
PLATFORM_FORK_INPUT_ERROR,
|
||||
} from '../../features/platform-fork/platformGameForkModel';
|
||||
import { usePlatformFork } from '../../features/platform-fork/usePlatformFork';
|
||||
import type { ProjectOpenAnalytics } from '../../services/clientAnalytics';
|
||||
|
||||
type CoCreationViewProps = {
|
||||
/** 建项成功后的进项目通道:与模板库同一条「原生已建好 → 进入工作区」路径。 */
|
||||
onProjectCreated: (
|
||||
result: InitLocalProjectResult,
|
||||
isCurrent: () => boolean,
|
||||
analytics: ProjectOpenAnalytics,
|
||||
) => Promise<void> | 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))]';
|
||||
|
||||
function formatCatalogTime(value: string) {
|
||||
const timestamp = Date.parse(value);
|
||||
if (!Number.isFinite(timestamp)) {
|
||||
return '—';
|
||||
}
|
||||
const date = new Date(timestamp);
|
||||
const pad = (part: number) => String(part).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
}
|
||||
|
||||
export default function CoCreationView({
|
||||
onProjectCreated,
|
||||
}: CoCreationViewProps) {
|
||||
const [catalog, setCatalog] = useState<ForkCatalogResult | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [catalogError, setCatalogError] = useState('');
|
||||
const [input, setInput] = useState('');
|
||||
const [inputError, setInputError] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const requestIdRef = useRef(0);
|
||||
const fork = usePlatformFork({ onProjectCreated });
|
||||
|
||||
/**
|
||||
* 深链预填:原生收到 `genarrative://fork?gameId=…` 后放进 store,这里只把它填进输入框并聚焦,
|
||||
* **不自动提交**(与卡片一样,必须由人按下动作)。
|
||||
*/
|
||||
const pendingDeepLink = useForkDeepLinkStore((state) => state.pending);
|
||||
const consumeDeepLink = useForkDeepLinkStore((state) => state.consume);
|
||||
|
||||
const reloadCatalog = useCallback(async () => {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
setLoading(false);
|
||||
setCatalogError('需要在陶泥儿客户端内运行');
|
||||
return;
|
||||
}
|
||||
const requestId = requestIdRef.current + 1;
|
||||
requestIdRef.current = requestId;
|
||||
setLoading(true);
|
||||
setCatalogError('');
|
||||
try {
|
||||
const result = await loadForkableGames(invoke);
|
||||
if (requestIdRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
setCatalog(result);
|
||||
} catch (error) {
|
||||
if (requestIdRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
setCatalog(null);
|
||||
setCatalogError(
|
||||
error instanceof Error ? error.message : '读取共创目录失败',
|
||||
);
|
||||
} finally {
|
||||
if (requestIdRef.current === requestId) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reloadCatalog();
|
||||
}, [reloadCatalog]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingDeepLink) {
|
||||
return;
|
||||
}
|
||||
const request = consumeDeepLink();
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
if (request.gameId) {
|
||||
setInput(request.gameId);
|
||||
setInputError('');
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
if (request.message) {
|
||||
setInputError(request.message);
|
||||
}
|
||||
}, [pendingDeepLink, consumeDeepLink]);
|
||||
|
||||
async function submitPastedGameId(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
// 与深链共用同一份解析(`parsePlatformGameId`),不另写一套。
|
||||
const gameId = parsePlatformGameId(input);
|
||||
if (!gameId) {
|
||||
setInputError(PLATFORM_FORK_INPUT_ERROR);
|
||||
return;
|
||||
}
|
||||
setInputError('');
|
||||
await fork.forkGame(gameId);
|
||||
}
|
||||
|
||||
async function forkFromCard(game: GameDistributionGame) {
|
||||
setInputError('');
|
||||
setInput(game.id);
|
||||
await fork.forkGame(game.id);
|
||||
}
|
||||
|
||||
const games = catalog?.games ?? [];
|
||||
const scannedHint = catalog
|
||||
? `已检查最新 ${catalog.scannedGames} 个作品(${catalog.scannedPages} 页${
|
||||
catalog.cappedByPageLimit ? `,最多 ${FORK_CATALOG_MAX_PAGES} 页` : ''
|
||||
})`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="grid content-start gap-0 pb-10">
|
||||
<header className={sectionClass}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="grid gap-1">
|
||||
<h1 className="m-0 text-[19px] font-semibold text-(--platform-text-strong)">
|
||||
共创
|
||||
</h1>
|
||||
<p className="m-0 text-[12px] leading-5 text-(--platform-text-soft)">
|
||||
把平台上的作品整个工程复制到你的项目里继续改造(像 git fork)。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="shrink-0 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) disabled:cursor-not-allowed disabled:opacity-55"
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => void reloadCatalog()}
|
||||
>
|
||||
<RefreshCcw
|
||||
className="mr-1 inline-block align-[-2px]"
|
||||
size={13}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className={sectionClass} aria-label="填详细内容">
|
||||
<form
|
||||
className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2.5 max-[760px]:grid-cols-1"
|
||||
onSubmit={submitPastedGameId}
|
||||
>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-[12px] font-bold text-(--platform-text-strong)">
|
||||
直接 Fork 指定作品
|
||||
</span>
|
||||
<input
|
||||
className="min-w-0 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"
|
||||
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);
|
||||
if (inputError) {
|
||||
setInputError('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<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}
|
||||
>
|
||||
{fork.busy ? '正在 Fork…' : 'Fork 到我的项目'}
|
||||
</button>
|
||||
</form>
|
||||
{inputError ? (
|
||||
<p className="m-0 text-[12px] leading-5 text-[#b42318]" role="alert">
|
||||
{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="支持共创的作品">
|
||||
<header className="flex items-center justify-between gap-3">
|
||||
<h2 className="m-0 text-[15px] text-(--platform-text-strong)">
|
||||
支持共创的作品
|
||||
</h2>
|
||||
{scannedHint ? (
|
||||
<small className="text-[11px] text-(--platform-text-soft)">
|
||||
{scannedHint}
|
||||
</small>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{loading ? (
|
||||
<p
|
||||
className="m-0 text-[12px] leading-5 text-(--platform-text-soft)"
|
||||
role="status"
|
||||
>
|
||||
正在读取共创目录…
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!loading && catalogError ? (
|
||||
<div className="grid justify-items-start gap-2.5">
|
||||
<p
|
||||
className="m-0 text-[12px] leading-5 text-[#b42318]"
|
||||
role="alert"
|
||||
>
|
||||
{catalogError}
|
||||
</p>
|
||||
<button
|
||||
className="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={() => void reloadCatalog()}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && !catalogError && games.length === 0 ? (
|
||||
<p className="m-0 text-[12px] leading-5 text-(--platform-text-soft)">
|
||||
最新{scannedHint ? ` ${catalog?.scannedGames ?? 0} 个` : ''}
|
||||
作品里还没有开放共创的;可以在上面直接粘贴作品 ID 试试,或稍后再来。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!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;
|
||||
return (
|
||||
<article className="grid min-w-0" key={game.id}>
|
||||
<div className="grid min-h-34 w-full grid-cols-[96px_minmax(0,1fr)] items-stretch gap-3 overflow-hidden rounded-xl border border-(--platform-surface-border) bg-(image:--platform-subpanel-fill) p-2 text-left shadow-(--platform-panel-shadow)">
|
||||
<span
|
||||
className="grid min-h-29 place-items-center rounded-lg text-[26px] font-bold text-white shadow-inner"
|
||||
style={{ backgroundColor: game.coverColor || '#c2653d' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{(game.title || game.id).slice(0, 1)}
|
||||
</span>
|
||||
<span className="grid min-w-0 content-start gap-1.5 py-1">
|
||||
<strong className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[14px] font-semibold leading-4.5 text-(--platform-text-strong)">
|
||||
{game.title || '(无标题)'}
|
||||
</strong>
|
||||
<small className="grid grid-cols-[44px_minmax(0,1fr)] gap-x-2 gap-y-0.5 text-[10px] leading-3.5 text-(--platform-text-soft)">
|
||||
<span className="whitespace-nowrap">作者</span>
|
||||
<span className="truncate whitespace-nowrap text-(--platform-neutral-text)">
|
||||
{game.author?.name || '—'}
|
||||
</span>
|
||||
<span className="whitespace-nowrap">类型</span>
|
||||
<span className="truncate whitespace-nowrap text-(--platform-neutral-text)">
|
||||
{game.category || '—'}
|
||||
</span>
|
||||
<span className="whitespace-nowrap">更新时间</span>
|
||||
<span className="truncate whitespace-nowrap text-(--platform-neutral-text)">
|
||||
{formatCatalogTime(game.createdAt)}
|
||||
</span>
|
||||
</small>
|
||||
<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'}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
/**
|
||||
* 首页「从平台作品 Fork」入口:输入平台作品 ID 或作品链接,把该作品的工程复制成本地项目。
|
||||
*
|
||||
* 对外口径只有一句:**Fork = 把整个工程复制一份到你的项目里继续改造(与 git fork 同理)**。
|
||||
* 取件、摘要校验、解压与来源记录全在原生命令里(`create_local_project_from_platform_game`),
|
||||
* 渲染层只做输入解析、加载态与失败分类。
|
||||
*/
|
||||
|
||||
import type { FormEvent } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
import type { InitLocalProjectResult } from '../../app/types';
|
||||
import { useForkDeepLinkStore } from '../../features/platform-fork/forkDeepLink';
|
||||
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);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
/**
|
||||
* 深链预填:原生收到 `genarrative://fork?gameId=…` 后会放进这个 store。
|
||||
*
|
||||
* 这里只做「填进输入框 + 聚焦输入框」,**不自动提交**——工程源包是 200 MiB 级别的下载,
|
||||
* 必须由用户按按钮确认;这一条与手工粘贴作品 ID 走的是同一条提交路径。
|
||||
*/
|
||||
const pendingDeepLink = useForkDeepLinkStore((state) => state.pending);
|
||||
const consumeDeepLink = useForkDeepLinkStore((state) => state.consume);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingDeepLink) {
|
||||
return;
|
||||
}
|
||||
const request = consumeDeepLink();
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
if (request.gameId) {
|
||||
setInput(request.gameId);
|
||||
setError('');
|
||||
setSuccess(null);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
if (request.message) {
|
||||
setSuccess(null);
|
||||
setError(request.message);
|
||||
}
|
||||
}, [pendingDeepLink, consumeDeepLink]);
|
||||
|
||||
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="从平台作品 Fork"
|
||||
>
|
||||
<header className="flex items-center justify-start gap-3">
|
||||
<h2 className="m-0 text-[15px] text-(--platform-text-strong)">
|
||||
从平台作品 Fork
|
||||
</h2>
|
||||
<span className="rounded-full bg-(--platform-neutral-bg) px-2 py-0.5 text-[11px] text-(--platform-warm-text)">
|
||||
Fork
|
||||
</span>
|
||||
</header>
|
||||
{/* 一句话口径,不再展开解释:Fork 就是把整个工程复制一份到你的项目里继续改造。 */}
|
||||
<p className="m-0 text-[12px] leading-5 text-(--platform-text-soft)">
|
||||
把平台上的作品整个工程复制到你的项目里继续改造(像 git fork)。输入要 Fork
|
||||
的作品 ID 或作品链接即可。
|
||||
</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"
|
||||
ref={inputRef}
|
||||
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 ? '正在 Fork…' : 'Fork 到我的项目'}
|
||||
</button>
|
||||
</form>
|
||||
{busy ? (
|
||||
<p
|
||||
className="m-0 text-[12px] leading-5 text-(--platform-text-soft)"
|
||||
role="status"
|
||||
>
|
||||
正在 Fork:下载、校验并创建项目;内容较大时需要几分钟,请不要关闭客户端。
|
||||
</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}》,Fork 自平台作品 {success.gameId}
|
||||
(来源已记录在项目的 .agent/fork-source.json)。
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -12,21 +12,16 @@ import type { FormEvent } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import BRAND_ICON from '../../../../../packages/shared/src/icons/taonier-product-ip.png';
|
||||
import type {
|
||||
InitLocalProjectResult,
|
||||
ProjectStartMode,
|
||||
} from '../../app/types';
|
||||
import type { 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 RichInputArea, { UploadButton } from './components/RichInputArea';
|
||||
import {
|
||||
richTextToAttachments,
|
||||
richTextToPrompt,
|
||||
} from './components/RichInputArea/richTextToPrompt';
|
||||
import { resolveHomeStartMode } from './homeStartMode';
|
||||
import PlatformGameForkSection from './PlatformGameForkSection';
|
||||
import TemplateRecommendations from './TemplateRecommendations';
|
||||
import {
|
||||
type HomeCreationType,
|
||||
@@ -129,15 +124,6 @@ type HomeViewProps = {
|
||||
templateLibraryLoading: boolean;
|
||||
templateLibraryError: string;
|
||||
onTemplateLibraryOpen: () => void;
|
||||
/**
|
||||
* 「从平台作品 Fork」建成项目后的进项目通道:与模板库共用同一条原生已建好 → 登记最近项目
|
||||
* 并进入工作区的路径,首页只透传结果与项目打开埋点。
|
||||
*/
|
||||
onPlatformGameProjectCreated: (
|
||||
result: InitLocalProjectResult,
|
||||
isCurrent: () => boolean,
|
||||
analytics: ProjectOpenAnalytics,
|
||||
) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export default function HomeView({
|
||||
@@ -155,7 +141,6 @@ export default function HomeView({
|
||||
templateLibraryLoading,
|
||||
templateLibraryError,
|
||||
onTemplateLibraryOpen,
|
||||
onPlatformGameProjectCreated,
|
||||
}: HomeViewProps) {
|
||||
const homeCreationType = useLauncherHomeDraftStore(
|
||||
(state) => state.creationType,
|
||||
@@ -470,12 +455,6 @@ export default function HomeView({
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/*
|
||||
「从平台作品 Fork」放在模板库之后:两者都是「从现成内容起步」,但这条走的是平台作品
|
||||
已公开的工程,因此与模板库分开成节。
|
||||
*/}
|
||||
<PlatformGameForkSection onProjectCreated={onPlatformGameProjectCreated} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import {
|
||||
CircleHelp,
|
||||
FolderKanban,
|
||||
GitFork,
|
||||
Home,
|
||||
LayoutTemplate,
|
||||
Plus,
|
||||
@@ -30,6 +31,7 @@ export type LauncherView =
|
||||
| 'guide'
|
||||
| 'contact'
|
||||
| 'news'
|
||||
| 'co-creation'
|
||||
| 'template-library'
|
||||
| 'project-development';
|
||||
|
||||
@@ -313,6 +315,19 @@ export function Sidebar({
|
||||
>
|
||||
<FolderKanban size={17} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="共创"
|
||||
title="共创"
|
||||
className={cx(
|
||||
sidebarIconButtonClass,
|
||||
activeView === 'co-creation' &&
|
||||
'border border-(--platform-nav-active-border) bg-(image:--platform-nav-active-fill) text-(--platform-nav-item-text-active) shadow-(--platform-nav-active-shadow)',
|
||||
)}
|
||||
onClick={() => changeView('co-creation')}
|
||||
>
|
||||
<GitFork size={17} aria-hidden="true" />
|
||||
</button>
|
||||
{templateLibraryEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { registerDesignAgentSurfaceTests } from './appSurface/design-agent.suite
|
||||
import { describe } from './appSurface/harness';
|
||||
import {
|
||||
registerClientHomeTests,
|
||||
registerCoCreationEntryTests,
|
||||
registerHomeProjectCreationTests,
|
||||
registerRecentProjectsTests,
|
||||
} from './appSurface/home.suite';
|
||||
@@ -41,6 +42,7 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
registerAuthTests();
|
||||
registerAgentStatusDerivationTests();
|
||||
registerClientHomeTests();
|
||||
registerCoCreationEntryTests();
|
||||
registerHomeProjectCreationTests();
|
||||
registerRuntimeSettingsTests();
|
||||
registerRecentProjectsTests();
|
||||
|
||||
@@ -4064,3 +4064,57 @@ export function registerRecentProjectsTests() {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 共创入口从首页内联面板搬到侧边栏「共创」页之后的回归防线。 */
|
||||
export function registerCoCreationEntryTests() {
|
||||
it('首页不再内联「从平台作品 Fork」面板', async () => {
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
if (command === 'list_platform_game_catalog') {
|
||||
return { games: [], nextCursor: null };
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
});
|
||||
installTauriRuntime({ core: { invoke } });
|
||||
renderLauncherAt('/?launcher');
|
||||
|
||||
// 首页该有的东西还在(否则这条断言会因为整页没渲染而假绿)。
|
||||
expect(await screen.findByLabelText('创作想法')).not.toBeNull();
|
||||
// 内联面板整体消失:标题、按钮都不得再出现在首页。
|
||||
expect(screen.queryByText('从平台作品 Fork')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Fork 到我的项目' })).toBeNull();
|
||||
});
|
||||
|
||||
it('侧边栏「共创」入口可切换,并落在共创页上', async () => {
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
if (command === 'list_platform_game_catalog') {
|
||||
return {
|
||||
games: [
|
||||
{
|
||||
id: 'game_open',
|
||||
title: '开放共创的作品',
|
||||
category: '动作',
|
||||
author: { id: 'user_1', name: '作者甲' },
|
||||
coverColor: '#c2653d',
|
||||
forkAuthorization: 'nonCommercial',
|
||||
createdAt: '2026-10-01T08:00:00Z',
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
});
|
||||
installTauriRuntime({ core: { invoke } });
|
||||
renderLauncherAt('/?launcher');
|
||||
await screen.findByLabelText('创作想法');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '共创' }));
|
||||
|
||||
expect(await screen.findByText('支持共创的作品')).not.toBeNull();
|
||||
expect(await screen.findByText('开放共创的作品')).not.toBeNull();
|
||||
expect(invoke).toHaveBeenCalledWith('list_platform_game_catalog', {
|
||||
limit: 50,
|
||||
cursor: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
// @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 { useForkDeepLinkStore } from '../src/features/platform-fork/forkDeepLink';
|
||||
import {
|
||||
forkableGames,
|
||||
isForkableGame,
|
||||
loadForkableGames,
|
||||
} from '../src/features/platform-fork/platformForkCatalog';
|
||||
import {
|
||||
classifyPlatformForkFailure,
|
||||
parsePlatformGameId,
|
||||
} from '../src/features/platform-fork/platformGameForkModel';
|
||||
import CoCreationView from '../src/view/co-creation';
|
||||
|
||||
const FORK_COMMAND = 'create_local_project_from_platform_game';
|
||||
const CATALOG_COMMAND = 'list_platform_game_catalog';
|
||||
|
||||
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;
|
||||
|
||||
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 catalogGame(id: string, title: string, forkAuthorization: string) {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
summary: '',
|
||||
category: '动作',
|
||||
author: { id: 'user_1', name: '作者甲' },
|
||||
coverColor: '#c2653d',
|
||||
forkAuthorization,
|
||||
createdAt: '2026-10-01T08:00:00Z',
|
||||
};
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
const onProjectCreated = vi.fn();
|
||||
render(<CoCreationView onProjectCreated={onProjectCreated} />);
|
||||
return { onProjectCreated };
|
||||
}
|
||||
|
||||
function forkCalls(calls: InvokeCall[]) {
|
||||
return calls.filter((call) => call.command === FORK_COMMAND);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
delete (window as unknown as { __TAURI__?: unknown }).__TAURI__;
|
||||
useForkDeepLinkStore.getState().consume();
|
||||
});
|
||||
|
||||
describe('共创目录模型', () => {
|
||||
it('只保留授权非 forbidden 的作品,字段缺省按 forbidden 处理', () => {
|
||||
expect(isForkableGame(catalogGame('g1', 'A', 'forbidden') as never)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
isForkableGame(catalogGame('g2', 'B', 'nonCommercial') as never),
|
||||
).toBe(true);
|
||||
expect(isForkableGame(catalogGame('g3', 'C', 'full') as never)).toBe(true);
|
||||
expect(isForkableGame({ id: 'g4' } as never)).toBe(false);
|
||||
expect(
|
||||
forkableGames([
|
||||
catalogGame('g1', 'A', 'forbidden'),
|
||||
catalogGame('g2', 'B', 'full'),
|
||||
] as never).map((game) => game.id),
|
||||
).toEqual(['g2']);
|
||||
});
|
||||
|
||||
it('扫到足够可 Fork 的作品就停,不把页数打满', async () => {
|
||||
const invoke = vi.fn(async () => ({
|
||||
games: [catalogGame('g1', 'A', 'full'), catalogGame('g2', 'B', 'full')],
|
||||
nextCursor: 'cursor-1',
|
||||
}));
|
||||
const result = await loadForkableGames(invoke as never, {
|
||||
enough: 2,
|
||||
maxPages: 3,
|
||||
});
|
||||
expect(result.games.map((game) => game.id)).toEqual(['g1', 'g2']);
|
||||
expect(result.scannedPages).toBe(1);
|
||||
expect(result.cappedByPageLimit).toBe(false);
|
||||
});
|
||||
|
||||
it('一直没凑够就按页数上限停,并标记「被页数上限截断」', async () => {
|
||||
let page = 0;
|
||||
const invoke = vi.fn(async () => {
|
||||
page += 1;
|
||||
return {
|
||||
games: [catalogGame(`g${page}`, 'A', 'forbidden')],
|
||||
nextCursor: `cursor-${page}`,
|
||||
};
|
||||
});
|
||||
const result = await loadForkableGames(invoke as never, {
|
||||
enough: 5,
|
||||
maxPages: 3,
|
||||
});
|
||||
expect(result.scannedPages).toBe(3);
|
||||
expect(result.scannedGames).toBe(3);
|
||||
expect(result.games).toEqual([]);
|
||||
expect(result.cappedByPageLimit).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('共创页', () => {
|
||||
it('列出可 Fork 的作品,过滤掉禁止共创的,并展示扫描范围', async () => {
|
||||
const calls = installInvoke((command) => {
|
||||
if (command === CATALOG_COMMAND) {
|
||||
return {
|
||||
games: [
|
||||
catalogGame('game_open', '开放共创的作品', 'nonCommercial'),
|
||||
catalogGame('game_locked', '禁止共创的作品', 'forbidden'),
|
||||
],
|
||||
nextCursor: null,
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
});
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText('开放共创的作品')).not.toBeNull();
|
||||
expect(screen.queryByText('禁止共创的作品')).toBeNull();
|
||||
expect(screen.getByText('作者甲')).not.toBeNull();
|
||||
expect(screen.getByText('动作')).not.toBeNull();
|
||||
expect(screen.getByText(/已检查最新 2 个作品(1 页)/)).not.toBeNull();
|
||||
// 列表只读目录,不碰建项命令。
|
||||
expect(forkCalls(calls)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('点卡片上的 Fork 才建项,一次点击一步到位', async () => {
|
||||
const calls = installInvoke((command) => {
|
||||
if (command === CATALOG_COMMAND) {
|
||||
return {
|
||||
games: [catalogGame('game_open', '开放共创的作品', 'full')],
|
||||
nextCursor: null,
|
||||
};
|
||||
}
|
||||
if (command === FORK_COMMAND) {
|
||||
return PROJECT_RESULT;
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
});
|
||||
const { onProjectCreated } = renderPage();
|
||||
await screen.findByText('开放共创的作品');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Fork' }));
|
||||
|
||||
await waitFor(() => expect(forkCalls(calls)).toHaveLength(1));
|
||||
expect(forkCalls(calls)[0]?.args).toMatchObject({
|
||||
gameId: 'game_open',
|
||||
name: null,
|
||||
planning: false,
|
||||
});
|
||||
await waitFor(() => expect(onProjectCreated).toHaveBeenCalledTimes(1));
|
||||
expect(
|
||||
await screen.findByText(
|
||||
/已创建项目《Fork 项目 1》,Fork 自平台作品 game_open/,
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
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(/还没有开放共创的/);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('平台作品 ID 或链接'), {
|
||||
target: { value: 'https://platform.test/games/detail?id=game_pasted' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Fork 到我的项目' }));
|
||||
|
||||
await waitFor(() => expect(forkCalls(calls)).toHaveLength(1));
|
||||
expect(forkCalls(calls)[0]?.args).toMatchObject({ gameId: 'game_pasted' });
|
||||
// 解析仍是同一份实现。
|
||||
expect(
|
||||
parsePlatformGameId('https://platform.test/games/detail?id=game_pasted'),
|
||||
).toBe('game_pasted');
|
||||
});
|
||||
|
||||
it('粘贴非法输入本地拦截,不发请求', async () => {
|
||||
const calls = installInvoke((command) => {
|
||||
if (command === CATALOG_COMMAND) {
|
||||
return { games: [], nextCursor: null };
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
});
|
||||
renderPage();
|
||||
await screen.findByText(/还没有开放共创的/);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('平台作品 ID 或链接'), {
|
||||
target: { value: 'https://platform.test/other/path' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Fork 到我的项目' }));
|
||||
|
||||
expect(screen.getByRole('alert').textContent).toContain(
|
||||
'请输入要 Fork 的平台作品 ID 或作品链接',
|
||||
);
|
||||
expect(forkCalls(calls)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('目录为空时给出明确空态,而不是空白', async () => {
|
||||
installInvoke((command) => {
|
||||
if (command === CATALOG_COMMAND) {
|
||||
return {
|
||||
games: [catalogGame('game_locked', '禁止共创', 'forbidden')],
|
||||
nextCursor: null,
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
});
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText(/作品里还没有开放共创的/)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('目录读取失败时给出失败态与重试', async () => {
|
||||
let attempts = 0;
|
||||
installInvoke((command) => {
|
||||
if (command === CATALOG_COMMAND) {
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
throw new Error('读取共创目录失败:请求超时,请稍后重试');
|
||||
}
|
||||
return {
|
||||
games: [catalogGame('game_open', '开放共创的作品', 'full')],
|
||||
nextCursor: null,
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
});
|
||||
renderPage();
|
||||
|
||||
expect(
|
||||
await screen.findByText('读取共创目录失败:请求超时,请稍后重试'),
|
||||
).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试' }));
|
||||
expect(await screen.findByText('开放共创的作品')).not.toBeNull();
|
||||
});
|
||||
|
||||
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) {
|
||||
return { games: [], nextCursor: null };
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
});
|
||||
renderPage();
|
||||
await screen.findByText(/还没有开放共创的/);
|
||||
|
||||
useForkDeepLinkStore.getState().request({
|
||||
gameId: null,
|
||||
message: '链接里没有 gameId,无法确定要 Fork 的作品',
|
||||
});
|
||||
|
||||
expect((await screen.findByRole('alert')).textContent).toBe(
|
||||
'链接里没有 gameId,无法确定要 Fork 的作品',
|
||||
);
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,67 +1,28 @@
|
||||
// @vitest-environment jsdom
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { cleanup, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { InitLocalProjectResult } from '../src/app/types';
|
||||
import {
|
||||
FORK_DEEP_LINK_EVENT,
|
||||
forkDeepLinkRequestFromEvent,
|
||||
subscribeForkDeepLink,
|
||||
useForkDeepLinkStore,
|
||||
} from '../src/features/platform-fork/forkDeepLink';
|
||||
import PlatformGameForkSection from '../src/view/home/PlatformGameForkSection';
|
||||
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;
|
||||
|
||||
type InvokeCall = { command: string; args?: Record<string, unknown> };
|
||||
|
||||
/**
|
||||
* 深链在渲染层的落点:原生事件 → 待处理请求 store。
|
||||
*
|
||||
* 「预填输入框、不自动下载」这条行为在共创页上(`coCreation.test.tsx`),这里只钉住事件解析与订阅,
|
||||
* 免得同一件事在两个文件里各断言一遍。
|
||||
*/
|
||||
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__?: { core?: { invoke?: unknown } } }
|
||||
).__TAURI__ = { core: { invoke } };
|
||||
return calls;
|
||||
}
|
||||
|
||||
function renderSection() {
|
||||
const onProjectCreated = vi.fn();
|
||||
render(<PlatformGameForkSection onProjectCreated={onProjectCreated} />);
|
||||
return { onProjectCreated };
|
||||
}
|
||||
|
||||
function forkInput() {
|
||||
return screen.getByLabelText('平台作品 ID 或链接') as HTMLInputElement;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
eventFake?.restore();
|
||||
eventFake = null;
|
||||
useForkDeepLinkStore.getState().consume();
|
||||
delete (window as unknown as { __TAURI__?: unknown }).__TAURI__;
|
||||
});
|
||||
|
||||
describe('Fork 深链载荷解析', () => {
|
||||
@@ -70,7 +31,10 @@ describe('Fork 深链载荷解析', () => {
|
||||
forkDeepLinkRequestFromEvent({ gameId: 'game_from_link', message: null }),
|
||||
).toEqual({ gameId: 'game_from_link', message: null });
|
||||
expect(
|
||||
forkDeepLinkRequestFromEvent({ gameId: null, message: '链接里没有 gameId' }),
|
||||
forkDeepLinkRequestFromEvent({
|
||||
gameId: null,
|
||||
message: '链接里没有 gameId',
|
||||
}),
|
||||
).toEqual({ gameId: null, message: '链接里没有 gameId' });
|
||||
// 两端空白由渲染层收敛,避免把带空白的 ID 填进输入框。
|
||||
expect(
|
||||
@@ -89,7 +53,7 @@ describe('Fork 深链载荷解析', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('订阅原生事件后把载荷写入待处理请求(同一事件只解析一次)', async () => {
|
||||
it('订阅原生事件后把载荷交给调用方(空载荷不产生请求)', async () => {
|
||||
eventFake = createTauriEventFake();
|
||||
eventFake.install();
|
||||
const received: unknown[] = [];
|
||||
@@ -103,53 +67,31 @@ describe('Fork 深链载荷解析', () => {
|
||||
|
||||
// 没有 gameId 也没有 message 的载荷不产生请求(避免用空链接清空用户输入)。
|
||||
eventFake.emit(FORK_DEEP_LINK_EVENT, {});
|
||||
expect(received).toHaveLength(1);
|
||||
await waitFor(() => expect(received).toHaveLength(1));
|
||||
release();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fork 入口的深链预填', () => {
|
||||
it('预填 gameId 但**不**自动下载,等用户自己确认', async () => {
|
||||
const calls = installInvoke((command) =>
|
||||
command === FORK_COMMAND ? PROJECT_RESULT : null,
|
||||
);
|
||||
const { onProjectCreated } = renderSection();
|
||||
|
||||
useForkDeepLinkStore.getState().request({
|
||||
gameId: 'game_from_link',
|
||||
it('待处理请求取走即清空,同一链接只生效一次', () => {
|
||||
const store = useForkDeepLinkStore.getState();
|
||||
store.request({ gameId: 'game_once', message: null });
|
||||
expect(useForkDeepLinkStore.getState().consume()).toEqual({
|
||||
gameId: 'game_once',
|
||||
message: null,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(forkInput().value).toBe('game_from_link'),
|
||||
);
|
||||
// 关键行为:收到链接不能自己开始下载(200 MiB 级别的工程源包必须用户确认)。
|
||||
expect(calls.filter((call) => call.command === FORK_COMMAND)).toHaveLength(0);
|
||||
expect(onProjectCreated).not.toHaveBeenCalled();
|
||||
|
||||
// 用户按按钮后走的是同一条提交路径。
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Fork 到我的项目' }));
|
||||
await waitFor(() =>
|
||||
expect(calls.filter((call) => call.command === FORK_COMMAND)).toHaveLength(1),
|
||||
);
|
||||
expect(
|
||||
calls.find((call) => call.command === FORK_COMMAND)?.args,
|
||||
).toMatchObject({ gameId: 'game_from_link', name: null, planning: false });
|
||||
});
|
||||
|
||||
it('链接不合法时展示可读原因且不预填、不请求', async () => {
|
||||
const calls = installInvoke(() => PROJECT_RESULT);
|
||||
renderSection();
|
||||
|
||||
useForkDeepLinkStore.getState().request({
|
||||
gameId: null,
|
||||
message: '链接里没有 gameId,无法确定要 Fork 的作品',
|
||||
});
|
||||
|
||||
expect((await screen.findByRole('alert')).textContent).toBe(
|
||||
'链接里没有 gameId,无法确定要 Fork 的作品',
|
||||
);
|
||||
expect(forkInput().value).toBe('');
|
||||
expect(calls.filter((call) => call.command === FORK_COMMAND)).toHaveLength(0);
|
||||
expect(useForkDeepLinkStore.getState().consume()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('深链订阅的清理', () => {
|
||||
it('注销后事件不再进入回调', async () => {
|
||||
eventFake = createTauriEventFake();
|
||||
eventFake.install();
|
||||
const handler = vi.fn();
|
||||
const release = await subscribeForkDeepLink(handler);
|
||||
eventFake.flushRegistrationEvals();
|
||||
release();
|
||||
|
||||
eventFake.emit(FORK_DEEP_LINK_EVENT, { gameId: 'game_after_release' });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
// @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: 'Fork 项目 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: 作者没有开放这个作品的共创授权,无法 Fork',
|
||||
),
|
||||
).toEqual({
|
||||
kind: 'forbidden',
|
||||
message: '作者没有开放这个作品的共创授权,无法 Fork',
|
||||
});
|
||||
expect(
|
||||
classifyPlatformForkFailure(
|
||||
'fork-source-not-found: 该作品不存在或已被删除,无法 Fork',
|
||||
),
|
||||
).toEqual({ kind: 'notFound', message: '该作品不存在或已被删除,无法 Fork' });
|
||||
expect(
|
||||
classifyPlatformForkFailure('fork-source-not-available: 该作品当前不能被 Fork(未公开、已下架或没有公开版本)'),
|
||||
).toEqual({
|
||||
kind: 'unavailable',
|
||||
message: '该作品当前不能被 Fork(未公开、已下架或没有公开版本)',
|
||||
});
|
||||
// 未命中前缀:原样展示,不吞掉排障信息。
|
||||
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(
|
||||
'请输入要 Fork 的平台作品 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(
|
||||
'请输入要 Fork 的平台作品 ID 或作品链接',
|
||||
);
|
||||
});
|
||||
|
||||
it('403 未开放授权:展示服务端文案、不建项目且可重试', async () => {
|
||||
installInvoke(() => {
|
||||
throw new Error('permission-denied: 作者没有开放这个作品的共创授权,无法 Fork');
|
||||
});
|
||||
const { onProjectCreated } = renderSection();
|
||||
|
||||
typeGameInput('game_locked');
|
||||
submitForm();
|
||||
|
||||
expect((await screen.findByRole('alert')).textContent).toBe(
|
||||
'作者没有开放这个作品的共创授权,无法 Fork',
|
||||
);
|
||||
expect(onProjectCreated).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Fork 到我的项目' }),
|
||||
).toHaveProperty('disabled', false);
|
||||
});
|
||||
|
||||
it('409 作品不可用与 401 未登录:各自给出对应提示且不建项目', async () => {
|
||||
let failure = new Error(
|
||||
'fork-source-not-available: 该作品当前不能被 Fork(未公开、已下架或没有公开版本)',
|
||||
);
|
||||
installInvoke(() => {
|
||||
throw failure;
|
||||
});
|
||||
const { onProjectCreated } = renderSection();
|
||||
|
||||
typeGameInput('game_private');
|
||||
submitForm();
|
||||
expect((await screen.findByRole('alert')).textContent).toContain(
|
||||
'该作品当前不能被 Fork',
|
||||
);
|
||||
|
||||
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(/正在 Fork:下载/u)).not.toBeNull();
|
||||
expect(screen.getByRole('button', { name: '正在 Fork…' })).toHaveProperty(
|
||||
'disabled',
|
||||
true,
|
||||
);
|
||||
// 同一 tick 内重复提交:ref 守卫必须挡住第二次请求。
|
||||
submitForm();
|
||||
expect(forkCalls(calls)).toHaveLength(1);
|
||||
|
||||
resolveFork(PROJECT_RESULT);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(/正在 Fork/u)).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
it('成功:用解析出的作品 ID 调命令、进入新项目并展示 Fork 来源', 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('Fork 自平台作品 game_parent_1');
|
||||
// 来源记录事实保留;对外口径不再展开「参考副本 / 能不能再发布」那套解释。
|
||||
expect(statusText).toContain('.agent/fork-source.json');
|
||||
expect(statusText).not.toContain('可玩参考');
|
||||
expect(statusText).not.toContain('不能直接重新发布');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
classifyPlatformForkFailure,
|
||||
parsePlatformGameId,
|
||||
} from '../src/features/platform-fork/platformGameForkModel';
|
||||
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: 作者没有开放这个作品的共创授权,无法 Fork',
|
||||
),
|
||||
).toEqual({
|
||||
kind: 'forbidden',
|
||||
message: '作者没有开放这个作品的共创授权,无法 Fork',
|
||||
});
|
||||
expect(
|
||||
classifyPlatformForkFailure(
|
||||
'fork-source-not-found: 该作品不存在或已被删除,无法 Fork',
|
||||
),
|
||||
).toEqual({
|
||||
kind: 'notFound',
|
||||
message: '该作品不存在或已被删除,无法 Fork',
|
||||
});
|
||||
expect(
|
||||
classifyPlatformForkFailure(
|
||||
'fork-source-not-available: 该作品当前不能被 Fork(未公开、已下架或没有公开版本)',
|
||||
),
|
||||
).toEqual({
|
||||
kind: 'unavailable',
|
||||
message: '该作品当前不能被 Fork(未公开、已下架或没有公开版本)',
|
||||
});
|
||||
// 未命中前缀:原样展示,不吞掉排障信息。
|
||||
expect(classifyPlatformForkFailure(new Error('网络不可用'))).toEqual({
|
||||
kind: 'other',
|
||||
message: '网络不可用',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user