diff --git a/apps/ai-game-creator-shell/src/view/template-library/index.tsx b/apps/ai-game-creator-shell/src/view/template-library/index.tsx index 154e2ddc0..7d93b1c2c 100644 --- a/apps/ai-game-creator-shell/src/view/template-library/index.tsx +++ b/apps/ai-game-creator-shell/src/view/template-library/index.tsx @@ -1,3 +1,4 @@ +import { PlatformRuntimeStatusToast } from '@genarrative/shared/components'; import { ArrowLeft, BadgeCheck, @@ -9,10 +10,13 @@ import { SearchX, SlidersHorizontal, } from 'lucide-react'; +import { useEffect } from 'react'; +import { createPortal } from 'react-dom'; import type { GameTemplateEntry } from '../../features/template-library/templateLibraryModel'; import { formatGameTemplateSize, + needsTemplateDownload, templateRuntimeLabel, } from '../../features/template-library/templateLibraryModel'; import type { TemplateLibraryController } from '../../features/template-library/useTemplateLibrary'; @@ -22,6 +26,51 @@ type TemplateLibraryViewProps = { onBack: () => void; }; +const TEMPLATE_LIBRARY_TOAST_MILLIS = 2600; + +/** + * 模板库提示统一走浮层 toast:下载完成、开始建项目这类过程提示不再占用页面内位置, + * 页面里只保留可操作的错误与空态。 + */ +function TemplateLibraryToast({ + message, + onDismiss, +}: { + message: string; + onDismiss: () => void; +}) { + useEffect(() => { + if (!message) { + return; + } + const timer = window.setTimeout(onDismiss, TEMPLATE_LIBRARY_TOAST_MILLIS); + return () => { + window.clearTimeout(timer); + }; + }, [message, onDismiss]); + + if (!message) { + return null; + } + return createPortal( +
+ + {message} + +
, + document.body, + ); +} + const chipClass = 'cursor-pointer rounded-full border border-(--platform-subpanel-border) bg-transparent px-2.5 py-1 text-[11px] text-(--platform-text-soft) transition hover:border-(--platform-warm-text) hover:text-(--platform-warm-text)'; const activeChipClass = @@ -41,6 +90,7 @@ function TemplateCard({ onUse: (template: GameTemplateEntry) => void; }) { const busy = busyTemplateId === template.id; + const needsDownload = needsTemplateDownload(template); const busyLabel = busy && busyKind === 'download' ? '正在下载模板' @@ -118,19 +168,26 @@ function TemplateCard({ )} 使用模板 - + {/* 已下载且版本一致时不再提供下载入口;只有缺包或版本落后才显示(落后时按「更新」)。 */} + {needsDownload ? ( + + ) : null} {busyLabel ? ( {busyLabel} @@ -167,6 +224,7 @@ export default function TemplateLibraryView({ refresh, downloadTemplate, createProjectFromTemplate, + clearNotice, } = controller; const showEmptyLibrary = status === 'ready' && templates.length === 0; @@ -286,14 +344,7 @@ export default function TemplateLibraryView({ ) : null} - {notice ? ( -

- {notice} -

- ) : null} + {error ? (
{ expect(blankWebCard.textContent).toContain('网页 · none · v0.1.0 · 2.0 KB'); expect(blankWebCard.textContent).toContain('空白'); expect(blankWebCard.textContent).toContain('零依赖最小网页工程'); + // 已下载且版本一致:不再显示下载入口,只留「使用模板」。 + expect( + within(blankWebCard).queryByRole('button', { name: /下载/ }), + ).toBeNull(); + expect( + within(blankWebCard).getByRole('button', { name: /使用模板/ }), + ).toBeTruthy(); const blankCanvasCard = cardFor('空白二维画布工程'); expect(blankCanvasCard.textContent).not.toContain('已下载'); expect(blankCanvasCard.querySelector('img')?.getAttribute('src')).toBe( `${OSS_BASE}/templates/v1/blank-2d-canvas/cover.svg`, ); + expect( + within(blankCanvasCard).getByRole('button', { name: /下载/ }), + ).toBeTruthy(); + }); + + it('offers 更新 instead of 下载 when the installed version is stale', () => { + const stale = template({ + id: 'blank-web', + title: '空白网页工程', + summary: '零依赖最小网页工程', + tags: ['空白', '网页'], + installed: true, + installedVersion: '0.0.9', + installedAtMillis: 1789000000000, + }); + render( + {}} + />, + ); + + const card = cardFor('空白网页工程'); + expect(within(card).getByRole('button', { name: /更新/ })).toBeTruthy(); + expect(within(card).queryByRole('button', { name: /^下载/ })).toBeNull(); }); it('routes search, tag, runtime and installed-only controls through the controller', () => { @@ -245,6 +280,40 @@ describe('TemplateLibraryView', () => { screen.getByText('远端清单暂时读不到,当前展示本机缓存'), ).toBeTruthy(); }); + + it('renders process notices as a floating toast outside the page and auto-dismisses it', () => { + vi.useFakeTimers(); + const clearNotice = vi.fn(); + try { + const { container } = render( + {}} + />, + ); + + const toast = document.body.querySelector( + '[data-template-library-toast="true"]', + ); + expect(toast).not.toBeNull(); + expect(toast?.textContent).toContain('已下载模板「空白网页工程」'); + expect(toast?.querySelector('[role="status"]')?.textContent).toContain( + '已下载模板「空白网页工程」', + ); + // 提示不再占用页面内位置。 + expect( + container.querySelector('[data-template-library-toast]'), + ).toBeNull(); + + vi.advanceTimersByTime(2600); + expect(clearNotice).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); }); describe('TemplateRecommendations', () => { diff --git a/docs/project-memory/plans/【里程碑】AGC模板库客户端接入-2026-09-17.md b/docs/project-memory/plans/【里程碑】AGC模板库客户端接入-2026-09-17.md index 9059a5348..f6483d226 100644 --- a/docs/project-memory/plans/【里程碑】AGC模板库客户端接入-2026-09-17.md +++ b/docs/project-memory/plans/【里程碑】AGC模板库客户端接入-2026-09-17.md @@ -29,7 +29,7 @@ AGC 客户端能读取公共 OSS 上的游戏模板库,并把「浏览 → 筛 2. `download_game_template` 对字节数与 SHA-256 不一致、越界对象键、非 `templates/` 前缀的包一律拒绝,且不落半成品目录。 3. zip 解压拒绝绝对路径、`..`、盘符、符号链接;安装完成后才写 `installed.json` 作为已下载判据。 4. `create_automatic_local_game_project_from_template` 建出的项目同时具备模板文件、`.agent` 清单与标准目录;失败时不留项目目录。 -5. 模板库页可按关键词、标签、运行时与「仅看已下载」筛选;卡片显示封面与已下载徽标;「使用模板」在版本落后时先重下再建项。 +5. 模板库页可按关键词、标签、运行时与「仅看已下载」筛选;卡片显示封面与已下载徽标;已下载且版本一致时不再显示下载入口(落后显示「更新」);过程提示以浮层 toast 呈现,不占页面内位置;「使用模板」在版本落后时先重下再建项。 6. 首页推荐位展示模板库内容并可进入模板库页;左侧导航有模板库入口且为独立全屏页。 7. 定向 Rust 单测 9 项(含线上清单 fixture)、前端模型 9 项 + 页面/推荐位 8 项、`tsc` 类型检查、`npm run check:encoding`、`git diff --check` 全部通过;可选真连检查能读线上清单、下载安装线上模板并据此建项目。 diff --git a/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md b/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md index a22a96145..2435b532e 100644 --- a/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md +++ b/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md @@ -66,6 +66,8 @@ templates/ - `src/features/template-library/templateLibraryModel.ts`:清单类型、搜索(空白分隔多关键词「与」)、标签/运行时/已下载筛选、标签选项聚合、体积格式化等纯函数。 - `src/features/template-library/useTemplateLibrary.ts`:一次拉清单,暴露筛选状态、下载与「用模板建项目」;下载成功后只就地更新该条目的已下载状态。 - `src/view/template-library/index.tsx`:模板库全屏页(返回、刷新、搜索、运行时/标签筛选、仅看已下载、卡片显示封面与已下载徽标、下载/使用模板)。 + - 卡片动作按安装状态收口:已下载且版本一致时**不再显示下载入口**,只留「使用模板」;版本落后才显示「更新」;缺包显示「下载」。 + - 过程提示(下载完成、开始建项目)走浮层 toast(复用 `packages/shared` 的 `PlatformRuntimeStatusToast`,`document.body` 浮层 + 2.6 秒自动消失),不再占用页面内位置;页面内只保留可操作的错误与空态。 - 首页「灵感推荐」替换为「模板库」推荐位(`src/view/home/TemplateRecommendations.tsx`):只展示封面、标题、运行时与已下载徽标,点击进入模板库页面;首页不再直接触发建项目。 - 左侧导航新增模板库入口(`LauncherView = 'template-library'`)。 - `src-tauri/tauri.conf.json` 的 `csp` / `devCsp` 在 `img-src` 放行 `https://agc-dev.oss-rg-china-mainland.aliyuncs.com`,用于封面图;`connect-src` 原本已放行同一域名。