From 7b8c3153b1672149af9dac308f44674a2f6b1e73 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 6 Oct 2026 11:47:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=B8=B8=E6=88=8F=E5=85=B1=E5=88=9B):=20?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=E5=86=85=E8=81=94=20Fork=20=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E6=94=B9=E4=B8=BA=E4=BE=A7=E8=BE=B9=E6=A0=8F=E3=80=8C?= =?UTF-8?q?=E5=85=B1=E5=88=9B=E3=80=8D=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户看了真机截图判定首页那张内联卡片太丑,撤掉并改成独立一页。 - 删掉首页内联面板:`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`。 --- .../src-tauri/src/desktop.rs | 1 + .../src/game_distribution_publish.rs | 93 ++++- .../features/app-shell/WorkspaceLauncher.tsx | 22 +- .../platform-fork/platformForkCatalog.ts | 106 ++++++ .../features/platform-fork/usePlatformFork.ts | 106 ++++++ .../src/view/co-creation/index.tsx | 334 +++++++++++++++++ .../src/view/home/PlatformGameForkSection.tsx | 199 ---------- .../src/view/home/index.tsx | 23 +- .../ai-game-creator-shell/src/view/layout.tsx | 15 + .../tests/appSurface.test.ts | 2 + .../tests/appSurface/home.suite.ts | 54 +++ .../tests/coCreation.test.tsx | 353 ++++++++++++++++++ .../tests/platformForkDeepLink.test.tsx | 124 ++---- .../tests/platformGameFork.test.tsx | 267 ------------- .../tests/platformGameForkModel.test.ts | 80 ++++ 15 files changed, 1187 insertions(+), 592 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/platform-fork/platformForkCatalog.ts create mode 100644 apps/ai-game-creator-shell/src/features/platform-fork/usePlatformFork.ts create mode 100644 apps/ai-game-creator-shell/src/view/co-creation/index.tsx delete mode 100644 apps/ai-game-creator-shell/src/view/home/PlatformGameForkSection.tsx create mode 100644 apps/ai-game-creator-shell/tests/coCreation.test.tsx delete mode 100644 apps/ai-game-creator-shell/tests/platformGameFork.test.tsx create mode 100644 apps/ai-game-creator-shell/tests/platformGameForkModel.test.ts diff --git a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs index 51aa2386b..4af109317 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs @@ -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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs index d87718056..865817694 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs @@ -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 { + 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, + cursor: Option, +) -> Result { + 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!( diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 7da317925..310b299ec 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -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' ? ( + + ) : launcherView === 'co-creation' ? ( + { await homeProject.enterCreatedTemplateProject( result, isCurrent, @@ -863,11 +868,6 @@ export function WorkspaceLauncherShell({ ); }} /> - ) : launcherView === 'projects' ? ( - ) : launcherView === 'template-library' && templateLibrary.enabled ? ( ( + command: string, + args?: Record, +) => Promise; + +/** + * 顺序扫描公开目录,直到凑够一屏可 Fork 作品、没有下一页、或撞到页数上限。 + * + * 只做只读请求(匿名目录接口),失败时把错误抛给调用方渲染失败态——不在这里吞掉。 + */ +export async function loadForkableGames( + invoke: CatalogInvoke, + options: { + maxPages?: number; + enough?: number; + pageSize?: number; + } = {}, +): Promise { + 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( + '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 }; +} diff --git a/apps/ai-game-creator-shell/src/features/platform-fork/usePlatformFork.ts b/apps/ai-game-creator-shell/src/features/platform-fork/usePlatformFork.ts new file mode 100644 index 000000000..41227a0bb --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/platform-fork/usePlatformFork.ts @@ -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; +}; + +export function usePlatformFork({ onProjectCreated }: UsePlatformForkOptions) { + const [busy, setBusy] = useState(false); + /** 当前正在 Fork 的作品 ID:列表卡片只让这一张进入加载态。 */ + const [activeGameId, setActiveGameId] = useState(null); + const [error, setError] = useState(''); + const [notice, setNotice] = useState(''); + const [success, setSuccess] = useState(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( + '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, + }; +} diff --git a/apps/ai-game-creator-shell/src/view/co-creation/index.tsx b/apps/ai-game-creator-shell/src/view/co-creation/index.tsx new file mode 100644 index 000000000..64cdc18a9 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/co-creation/index.tsx @@ -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; +}; + +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(null); + const [loading, setLoading] = useState(true); + const [catalogError, setCatalogError] = useState(''); + const [input, setInput] = useState(''); + const [inputError, setInputError] = useState(''); + const inputRef = useRef(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) { + 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 ( +
+
+
+
+

+ 共创 +

+

+ 把平台上的作品整个工程复制到你的项目里继续改造(像 git fork)。 +

+
+ +
+
+ +
+
+ + +
+ {inputError ? ( +

+ {inputError} +

+ ) : null} + {fork.error ? ( +

+ {fork.error} +

+ ) : null} + {fork.busy ? ( +

+ 正在 + Fork:下载、校验并创建项目;内容较大时需要几分钟,请不要关闭客户端。 +

+ ) : null} + {!fork.busy && fork.notice ? ( +

+ {fork.notice}(来源已记录在项目的 .agent/fork-source.json) +

+ ) : null} +
+ +
+
+

+ 支持共创的作品 +

+ {scannedHint ? ( + + {scannedHint} + + ) : null} +
+ + {loading ? ( +

+ 正在读取共创目录… +

+ ) : null} + + {!loading && catalogError ? ( +
+

+ {catalogError} +

+ +
+ ) : null} + + {!loading && !catalogError && games.length === 0 ? ( +

+ 最新{scannedHint ? ` ${catalog?.scannedGames ?? 0} 个` : ''} + 作品里还没有开放共创的;可以在上面直接粘贴作品 ID 试试,或稍后再来。 +

+ ) : null} + + {!catalogError && games.length > 0 ? ( +
+ {games.map((game) => { + const active = fork.activeGameId === game.id; + return ( +
+
+ + + + {game.title || '(无标题)'} + + + 作者 + + {game.author?.name || '—'} + + 类型 + + {game.category || '—'} + + 更新时间 + + {formatCatalogTime(game.createdAt)} + + + + +
+
+ ); + })} +
+ ) : null} +
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/view/home/PlatformGameForkSection.tsx b/apps/ai-game-creator-shell/src/view/home/PlatformGameForkSection.tsx deleted file mode 100644 index 4cd574514..000000000 --- a/apps/ai-game-creator-shell/src/view/home/PlatformGameForkSection.tsx +++ /dev/null @@ -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; -}; - -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(null); - /** 只认最后一次提交:迟到的结果不再改状态,也不进入项目。 */ - const requestSequenceRef = useRef(0); - /** 同一 tick 内的重复提交靠 ref 拦截:`busy` 状态要等下一次渲染才生效。 */ - const busyRef = useRef(false); - const inputRef = useRef(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) { - 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( - '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 ( -
-
-

- 从平台作品 Fork -

- - Fork - -
- {/* 一句话口径,不再展开解释:Fork 就是把整个工程复制一份到你的项目里继续改造。 */} -

- 把平台上的作品整个工程复制到你的项目里继续改造(像 git fork)。输入要 Fork - 的作品 ID 或作品链接即可。 -

-
- setInput(event.target.value)} - /> - -
- {busy ? ( -

- 正在 Fork:下载、校验并创建项目;内容较大时需要几分钟,请不要关闭客户端。 -

- ) : null} - {error ? ( -

- {error} -

- ) : null} - {success ? ( -

- 已创建项目《{success.projectName}》,Fork 自平台作品 {success.gameId} - (来源已记录在项目的 .agent/fork-source.json)。 -

- ) : null} -
- ); -} diff --git a/apps/ai-game-creator-shell/src/view/home/index.tsx b/apps/ai-game-creator-shell/src/view/home/index.tsx index cdce79ac5..64307288d 100644 --- a/apps/ai-game-creator-shell/src/view/home/index.tsx +++ b/apps/ai-game-creator-shell/src/view/home/index.tsx @@ -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; }; 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({ /> ) : null} - - {/* - 「从平台作品 Fork」放在模板库之后:两者都是「从现成内容起步」,但这条走的是平台作品 - 已公开的工程,因此与模板库分开成节。 - */} - ); } diff --git a/apps/ai-game-creator-shell/src/view/layout.tsx b/apps/ai-game-creator-shell/src/view/layout.tsx index 621c98708..a5506bf59 100644 --- a/apps/ai-game-creator-shell/src/view/layout.tsx +++ b/apps/ai-game-creator-shell/src/view/layout.tsx @@ -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({ >