模板库卡片按安装状态收口并改用浮层提示
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (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

- 已下载且版本一致的模板不再显示下载入口,只保留「使用模板」;版本落后显示「更新」,缺包显示「下载」
- 下载完成、开始建项目等过程提示改为浮层 toast(复用 PlatformRuntimeStatusToast,2.6 秒自动消失),不再占用页面内位置;页面内只保留错误与空态
- 补充/更新页面单测:已下载无下载按钮、落后显示更新、toast 渲染在页面外并自动消失
- 同步技术方案与里程碑的交互口径
This commit is contained in:
kdletters
2026-09-17 14:43:43 +08:00
parent 46d395763d
commit ff542085e1
4 changed files with 144 additions and 22 deletions
@@ -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(
<div
className="pointer-events-none fixed bottom-8 left-1/2 z-[1100] -translate-x-1/2"
data-template-library-toast="true"
>
<PlatformRuntimeStatusToast
tone="success"
surface="solid"
size="sm"
shape="pill"
className="shadow-lg"
>
{message}
</PlatformRuntimeStatusToast>
</div>,
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({
)}
使
</button>
<button
type="button"
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-(--platform-subpanel-border) bg-transparent px-2.5 py-1.5 text-[11px] text-(--platform-text-soft) disabled:cursor-default disabled:opacity-50"
disabled={busy}
onClick={() => onDownload(template)}
>
{busy && busyKind === 'download' ? (
<Loader2 className="animate-spin" size={12} aria-hidden="true" />
) : (
<Download size={12} aria-hidden="true" />
)}
</button>
{/* 已下载且版本一致时不再提供下载入口;只有缺包或版本落后才显示(落后时按「更新」)。 */}
{needsDownload ? (
<button
type="button"
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-(--platform-subpanel-border) bg-transparent px-2.5 py-1.5 text-[11px] text-(--platform-text-soft) disabled:cursor-default disabled:opacity-50"
disabled={busy}
onClick={() => onDownload(template)}
>
{busy && busyKind === 'download' ? (
<Loader2
className="animate-spin"
size={12}
aria-hidden="true"
/>
) : (
<Download size={12} aria-hidden="true" />
)}
{template.installed ? '更新' : '下载'}
</button>
) : null}
{busyLabel ? (
<span className="text-[10px] text-(--platform-text-soft)">
{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}
</div>
{notice ? (
<p
className="m-0 text-[12px] text-(--platform-text-soft)"
role="status"
>
{notice}
</p>
) : null}
<TemplateLibraryToast message={notice} onDismiss={clearNotice} />
{error ? (
<div
className="flex flex-wrap items-center gap-3 rounded-lg border border-(--platform-subpanel-border) px-3 py-2 text-[12px] text-(--platform-warm-text)"
@@ -117,12 +117,47 @@ describe('TemplateLibraryView', () => {
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(
<TemplateLibraryView
controller={controller({
templates: [stale],
visibleTemplates: [stale],
})}
onBack={() => {}}
/>,
);
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(
<TemplateLibraryView
controller={controller({
notice: '已下载模板「空白网页工程」',
clearNotice,
})}
onBack={() => {}}
/>,
);
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', () => {
@@ -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` 全部通过;可选真连检查能读线上清单、下载安装线上模板并据此建项目。
@@ -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` 原本已放行同一域名。