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

用户看了真机截图判定首页那张内联卡片太丑,撤掉并改成独立一页。

- 删掉首页内联面板:`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:
2026-10-06 11:47:20 +08:00
parent f9f27c2949
commit 7b8c3153b1
15 changed files with 1187 additions and 592 deletions
@@ -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: '网络不可用',
});
});
});