Files
Genarrative/apps/ai-game-creator-shell/src/features/template-library/templateLibraryGrid.ts
T
kdletters 5db407c3dc
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m12s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m18s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m25s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m22s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m49s
Project CI / AI game creator shell Rust crates (push) Successful in 2m36s
Project CI / Repository checks (push) Failing after 1m11s
Project CI / Frontend tests (push) Failing after 4m37s
Project CI / AI game creator shell web tests (push) Failing after 4m6s
Project CI / Native shell tests (push) Successful in 9m39s
Project CI / Backend tests (push) Successful in 10m12s
新增 AGC 模板库与由模板创建项目链路 (#396)
## 交付范围

- OSS `agc-dev` bucket 的 `templates/` 前缀作为 AGC 游戏模板库(**去掉 `agc/` 这一层**;`agc/` 继续只放客户端安装包与更新清单)。
- 模板正文改为 zip(zip 根 == AGC 项目根,如 `game/index.html`);清单补齐 `tags`、封面字段与包大小/摘要,`templateVersion` 用于判断是否需要重下。
- 客户端:Rust 模板库模块、模板库全屏页、首页模板推荐位、左侧导航入口。

## 客户端行为

- `fetch_game_template_library`:读取并校验清单(≤4 MiB),缓存到 `<app_data>/templates/index.json`;网络失败回退本机缓存并标注 `source=cache`。
- `download_game_template`:按清单下载 zip(≤512 MiB),校验字节数与 SHA-256 后解压到 `<app_data>/templates/installed/<id>/<version>/`,最后写 `installed.json` 作为安装完成标记。
- `create_automatic_local_game_project_from_template`:先把模板文件铺进新项目目录,再走既有 `init_local_game_project_at` 补 `.agent` 清单与标准目录;失败删除半成品目录。
- 解压只接受普通文件与目录(拒绝绝对路径、`..`、盘符、符号链接),安装目录名由标识符白名单拼出;远端只信「受信任 OSS 主机 + 对象键」,清单里的地址字段不参与请求。
- UI:搜索(空白分隔多关键词「与」)、标签/运行时筛选、仅看已下载、封面卡片与已下载徽标;「使用模板」在版本落后时先重下再建项。

## 文档与脚本

- 主规范 `docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md`;里程碑与实施计划见 `docs/project-memory/plans/`;`decision-log.md` 记录库路径、清单 schema、安装缓存目录与 CSP 约定。
- 发布脚本 `scripts/agc-template-library-publish.mjs`(`--dry-run` 校验 → 上传 → 回读校验)。
- 首页「灵感推荐」替换为模板库推荐位,删除本机灵感图目录与 `InspirationGallery.tsx`;`tauri.conf.json` 的 `img-src` 放行受信任 OSS 主机用于加载封面。

## 验证

- `cargo test --bin genarrative-ai-game-creator-shell template_library`:9 passed;`--ignored` 真连检查 `fetches_the_live_template_library_index` 通过。
- `npx vitest run src/features/template-library`:9 passed。
- `npm run typecheck`、`cargo fmt --check`、`npm run check:encoding`、`node scripts/check-doc-index.mjs`、`git diff --check` 均通过。
- OSS 侧匿名复核:`templates/index.json`、各 `cover.png` 与 `template.zip` 均返回 200。

## 未做 / 待确认

- 本轮未做客户端点击级人工验收(下一步单独验证并把结果补进此 PR)。
- 模板封面目前是占位标题卡,正式封面由后续模板包提供。

---------

Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/396
2026-09-17 17:48:06 +08:00

97 lines
3.1 KiB
TypeScript

/**
* 模板库卡片网格的布局计算(纯函数)。
*
* 页面用 `react-window` 的 `FixedSizeGrid` 做虚拟滚动:只渲染可视区域的行,
* 因此这里负责把「容器宽度 + 条目数」换算成列数、列宽、行高与行数,
* 保证卡片尺寸、封面比例与行高完全确定(虚拟列表要求固定行高)。
*/
import type { GameTemplateEntry } from './templateLibraryModel';
/** 卡片最小宽度(与 CSS 里旧的 `minmax(250px,1fr)` 口径一致)。 */
export const TEMPLATE_CARD_MIN_WIDTH = 250;
/** 卡片之间的水平/垂直间隙。 */
export const TEMPLATE_CARD_GAP = 14;
/** 封面宽高比:16:9。 */
export const TEMPLATE_CARD_COVER_RATIO = 9 / 16;
/** 卡片封面以下的文字与按钮区固定高度。 */
export const TEMPLATE_CARD_TEXT_HEIGHT = 150;
/** 额外预渲染的行数,减小快速滚动时的白屏。 */
export const TEMPLATE_GRID_OVERSCAN_ROWS = 2;
export type TemplateGridLayout = {
columnCount: number;
/** FixedSizeGrid 的列宽(含卡片右侧间隙)。 */
columnWidth: number;
/** FixedSizeGrid 的行高(含卡片下方间隙)。 */
rowHeight: number;
rowCount: number;
};
export function computeTemplateGridColumns(containerWidth: number): number {
if (!Number.isFinite(containerWidth) || containerWidth <= 0) {
return 1;
}
const columns = Math.floor(
(containerWidth + TEMPLATE_CARD_GAP) /
(TEMPLATE_CARD_MIN_WIDTH + TEMPLATE_CARD_GAP),
);
return Math.max(1, columns);
}
export function computeTemplateRowHeight(columnWidth: number): number {
const cardWidth = Math.max(
TEMPLATE_CARD_MIN_WIDTH,
Math.round(columnWidth) - TEMPLATE_CARD_GAP,
);
return (
Math.ceil(cardWidth * TEMPLATE_CARD_COVER_RATIO) +
TEMPLATE_CARD_TEXT_HEIGHT +
TEMPLATE_CARD_GAP
);
}
export function computeTemplateGridLayout({
containerWidth,
itemCount,
}: {
containerWidth: number;
itemCount: number;
}): TemplateGridLayout {
const columnCount = computeTemplateGridColumns(containerWidth);
const columnWidth = Math.max(1, Math.floor(containerWidth / columnCount));
return {
columnCount,
columnWidth,
rowHeight: computeTemplateRowHeight(columnWidth),
rowCount: Math.max(0, Math.ceil(Math.max(0, itemCount) / columnCount)),
};
}
/** 按行切分,行尾补 `null` 占位,保证虚拟列表的列索引与条目一一对应。 */
export function buildTemplateRows(
templates: readonly GameTemplateEntry[],
columnCount: number,
): Array<Array<GameTemplateEntry | null>> {
if (columnCount <= 0) {
return [];
}
const rows: Array<Array<GameTemplateEntry | null>> = [];
for (let index = 0; index < templates.length; index += columnCount) {
const row: Array<GameTemplateEntry | null> = [];
for (let column = 0; column < columnCount; column += 1) {
row.push(templates[index + column] ?? null);
}
rows.push(row);
}
return rows;
}
/** 虚拟列表的稳定 key:行内槽位固定,避免筛选后复用错卡片。 */
export function templateGridItemKey(
rowIndex: number,
columnIndex: number,
): string {
return `template-cell-${rowIndex}-${columnIndex}`;
}