新增 AGC 模板库与由模板创建项目链路
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 7m20s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 7m22s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 8m6s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 8m9s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m44s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m35s
Project CI / Frontend tests (pull_request) Failing after 4m34s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m54s
Project CI / Native shell tests (pull_request) Successful in 9m17s

- 新增 Rust 模板库模块 template_library.rs:读取 OSS 清单、校验并安装模板包、由模板创建项目
- 注册 fetch_game_template_library、download_game_template、create_automatic_local_game_project_from_template 命令
- 新增前端模板库模型与状态链路:搜索、标签/运行时/已下载筛选、下载与建项
- 新增模板库全屏页,并在左侧导航增加模板库入口
- 首页「灵感推荐」替换为模板库推荐位,删除本机灵感图目录与 InspirationGallery 组件
- tauri.conf.json 的 img-src 放行受信任 OSS 主机,用于加载模板封面
- 新增模板库发布脚本 scripts/agc-template-library-publish.mjs
- 新增模板库技术方案、里程碑与实施计划文档,并更新 docs/README.md 索引
- decision-log 记录模板库 OSS 路径、清单 schema、安装缓存目录与 CSP 约定
This commit is contained in:
kdletters
2026-09-17 11:20:13 +08:00
parent 492e9e63af
commit 8ee50e8b94
45 changed files with 2830 additions and 87 deletions
@@ -284,6 +284,7 @@ mod resource_inspect;
mod resource_preview_scheduler;
mod runner;
mod swarm_cli;
mod template_library;
mod tool_plan_handoff;
mod user_input;
mod windows;
@@ -323,6 +324,7 @@ use resource_inspect::*;
use resource_preview_scheduler::*;
use runner::*;
use swarm_cli::*;
use template_library::*;
use user_input::*;
use windows::*;
#[tauri::command]
@@ -2660,7 +2662,10 @@ fn main() {
start_game_creator_external_mcp,
stop_game_creator_external_mcp,
create_automatic_local_game_project,
create_automatic_local_game_project_from_template,
init_local_game_project,
fetch_game_template_library,
download_game_template,
import_local_godot_project,
import_local_cocos_project,
is_local_project_directory_non_empty,
File diff suppressed because it is too large Load Diff
@@ -24,8 +24,8 @@
}
],
"security": {
"csp": "default-src 'self' customprotocol: asset:; img-src 'self' asset: data: blob:; media-src 'self' asset: data: blob:; connect-src 'self' https://agc-dev.oss-rg-china-mainland.aliyuncs.com http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*",
"devCsp": "default-src 'self' customprotocol: asset: http://127.0.0.1:*; img-src 'self' asset: http://127.0.0.1:* data: blob:; media-src 'self' asset: http://127.0.0.1:* data: blob:; connect-src 'self' https://agc-dev.oss-rg-china-mainland.aliyuncs.com http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*"
"csp": "default-src 'self' customprotocol: asset:; img-src 'self' asset: data: blob: https://agc-dev.oss-rg-china-mainland.aliyuncs.com; media-src 'self' asset: data: blob:; connect-src 'self' https://agc-dev.oss-rg-china-mainland.aliyuncs.com http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*",
"devCsp": "default-src 'self' customprotocol: asset: http://127.0.0.1:*; img-src 'self' asset: http://127.0.0.1:* data: blob: https://agc-dev.oss-rg-china-mainland.aliyuncs.com; media-src 'self' asset: http://127.0.0.1:* data: blob:; connect-src 'self' https://agc-dev.oss-rg-china-mainland.aliyuncs.com http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*"
}
},
"bundle": {
@@ -0,0 +1,58 @@
{
"schemaVersion": "agc-template-library.v1",
"library": "agc-game-templates",
"libraryVersion": 1,
"updatedAt": "2026-09-17T03:06:41Z",
"templates": [
{
"id": "phaser-2d-starter",
"title": "Phaser 2D 起步工程",
"summary": "AGC 新建项目使用的默认二维起步工程(Phaser 4 + Vite),解压后即为可运行项目根。",
"tags": [
"起步工程",
"2d",
"phaser",
"像素"
],
"runtime": "html",
"engine": "phaser",
"engineVersion": "4.2.1",
"templateVersion": "0.1.0",
"updatedAt": "2026-09-17T03:06:41Z",
"entry": "game/index.html",
"zipKey": "templates/v1/phaser-2d-starter/template.zip",
"zipSizeBytes": 8823,
"zipSha256": "6d3decc9f770a3e5225c387fb6c907561823a72f2f40da932e71308e6288cc97",
"coverKey": "templates/v1/phaser-2d-starter/cover.png",
"coverWidth": 960,
"coverHeight": 540,
"coverSha256": "5fcf952aa89c302258fcf462344d7d799a8bd2a79070d5412c94ac269e313376",
"metadataKey": "templates/v1/phaser-2d-starter/template.json"
},
{
"id": "threejs-3d-starter",
"title": "Three.js 3D 起步工程",
"summary": "网页三维起步工程(Three.js + Vite),自带可旋转立方体场景、方向光与自适应视口。",
"tags": [
"起步工程",
"3d",
"three.js",
"网页"
],
"runtime": "html",
"engine": "three.js",
"engineVersion": "0.180.0",
"templateVersion": "0.1.0",
"updatedAt": "2026-09-17T03:06:41Z",
"entry": "game/index.html",
"zipKey": "templates/v1/threejs-3d-starter/template.zip",
"zipSizeBytes": 1700,
"zipSha256": "dd353052fd61990ace3aa4bafeb8b48a7121133f33c976dd4865fff9761fc44b",
"coverKey": "templates/v1/threejs-3d-starter/cover.png",
"coverWidth": 960,
"coverHeight": 540,
"coverSha256": "3a4d3080853737663d970a21a63e9d6b5cb523b31c5463d2c2dd94fddf66f187",
"metadataKey": "templates/v1/threejs-3d-starter/template.json"
}
]
}
@@ -25,8 +25,10 @@ import {
type ProjectManifestSnapshotSource,
rereadAuthoritativeProjectManifestSnapshot,
} from '../../view/project-development/projectResourceLiveUpdateModel';
import TemplateLibraryView from '../../view/template-library';
import { useDirectActiveTurns } from '../agent-runtime/directActiveTurns';
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
import { useTemplateLibrary } from '../template-library/useTemplateLibrary';
import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet';
import {
DeveloperAgentDialogs,
@@ -83,6 +85,11 @@ export function WorkspaceLauncherShell({
setAgentChatProjectPath: developerAgent.setAgentChatProjectPath,
rememberRecentWorkspace,
});
const templateLibrary = useTemplateLibrary({
onProjectCreated: async (result) => {
await homeProject.enterCreatedTemplateProject(result);
},
});
const {
projectPath,
setProjectPath,
@@ -567,6 +574,13 @@ export function WorkspaceLauncherShell({
void openProject(path, 'open');
}}
onProjectPick={() => void homeProject.pickAndOpenProject()}
templateRecommendations={templateLibrary.templates}
templateLibraryLoading={
templateLibrary.status === 'loading' ||
templateLibrary.status === 'idle'
}
templateLibraryError={templateLibrary.error}
onTemplateLibraryOpen={() => setLauncherView('template-library')}
/>
) : launcherView === 'projects' ? (
<ProjectsPage
@@ -574,6 +588,11 @@ export function WorkspaceLauncherShell({
homeProject={homeProject}
recentProjects={recentProjects}
/>
) : launcherView === 'template-library' ? (
<TemplateLibraryView
controller={templateLibrary}
onBack={() => setLauncherView('home')}
/>
) : launcherView === 'agent-chat' ? (
<DeveloperAgentPanel
controller={developerAgent}
@@ -555,6 +555,35 @@ export function useHomeProjectCreation({
}
}
/**
* 模板库建出的项目:模板文件与项目脚手架已在 Rust 侧一次落盘,
* 这里只负责登记最近项目并走标准进项目通道(含会话预览核验与代次闸门)。
*/
async function enterCreatedTemplateProject(result: InitLocalProjectResult) {
const invoke = resolveTauriInvoke();
if (!invoke) {
throw new Error('需要在陶泥儿客户端内运行');
}
await enterProjectDevelopment({
projectPath: result.projectPath,
projectName:
result.manifest.name || projectNameFromPath(result.projectPath),
projectKind: 'web',
manifest: result.manifest,
projectRevision: await readCurrentProjectRevision(
invoke,
result.projectPath,
),
creationType: null,
startMode: null,
initialPrompt: '',
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
createdAt: Date.now(),
});
}
async function openProject(nextProjectPath: string, mode: 'open' | 'create') {
if (mode === 'create') {
await createProjectFromProjectPage(nextProjectPath);
@@ -1005,6 +1034,7 @@ export function useHomeProjectCreation({
renameProject,
pickAndOpenProject,
pickAndCreateProject,
enterCreatedTemplateProject,
confirmCreateInNonEmptyFolder,
cancelCreateInNonEmptyFolder,
};
@@ -0,0 +1,183 @@
import { describe, expect, it } from 'vitest';
import {
collectGameTemplateRuntimes,
collectGameTemplateTags,
EMPTY_TEMPLATE_LIBRARY_FILTERS,
filterGameTemplates,
formatGameTemplateSize,
type GameTemplateEntry,
isTemplateLibraryFiltersEmpty,
needsTemplateDownload,
templateMatchesQuery,
templateRuntimeLabel,
toggleGameTemplateTag,
} from './templateLibraryModel';
function template(
overrides: Partial<GameTemplateEntry> & Pick<GameTemplateEntry, 'id'>,
): GameTemplateEntry {
return {
title: '未命名模板',
summary: '',
tags: [],
runtime: 'html',
engine: 'phaser',
engineVersion: '4.2.1',
templateVersion: '1.0.0',
updatedAt: '2026-09-17T00:00:00Z',
entry: 'game/index.html',
zipUrl:
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/templates/v1/demo/template.zip',
zipSizeBytes: 2048,
zipSha256: 'a'.repeat(64),
coverUrl:
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/templates/v1/demo/cover.png',
coverWidth: 960,
coverHeight: 540,
installed: false,
installedVersion: null,
installedAtMillis: null,
...overrides,
};
}
const matchThree = template({
id: 'match-3',
title: '三消经营',
summary: '三消与模拟经营的融合模板',
tags: ['三消', '经营'],
engine: 'phaser',
installed: true,
installedVersion: '1.0.0',
});
const pixelFarm = template({
id: 'pixel-farm',
title: '像素农场',
summary: '像素风种植玩法',
tags: ['经营', '像素'],
engine: 'godot',
runtime: 'godot',
templateVersion: '2.0.0',
installed: true,
installedVersion: '1.0.0',
});
const spaceShooter = template({
id: 'space-shooter',
title: '太空射击',
summary: '纵版弹幕射击',
tags: ['射击'],
runtime: 'unity',
engine: 'unity',
installed: false,
});
const templates: GameTemplateEntry[] = [matchThree, pixelFarm, spaceShooter];
describe('templateMatchesQuery', () => {
it('matches title, summary, tags and engine case-insensitively', () => {
expect(templateMatchesQuery(matchThree, '三消')).toBe(true);
expect(templateMatchesQuery(matchThree, '经营')).toBe(true);
expect(templateMatchesQuery(matchThree, 'PHASER')).toBe(true);
expect(templateMatchesQuery(matchThree, '弹幕')).toBe(false);
});
it('requires every whitespace separated term to match', () => {
expect(templateMatchesQuery(pixelFarm, '像素 种植')).toBe(true);
expect(templateMatchesQuery(pixelFarm, '像素 弹幕')).toBe(false);
expect(templateMatchesQuery(pixelFarm, ' ')).toBe(true);
});
});
describe('filterGameTemplates', () => {
it('filters by tag, runtime, query and installed state together', () => {
expect(
filterGameTemplates(templates, {
...EMPTY_TEMPLATE_LIBRARY_FILTERS,
tags: ['经营'],
}).map((entry) => entry.id),
).toEqual(['match-3', 'pixel-farm']);
expect(
filterGameTemplates(templates, {
...EMPTY_TEMPLATE_LIBRARY_FILTERS,
runtime: 'godot',
}).map((entry) => entry.id),
).toEqual(['pixel-farm']);
expect(
filterGameTemplates(templates, {
...EMPTY_TEMPLATE_LIBRARY_FILTERS,
installedOnly: true,
query: '经营',
tags: ['像素'],
}).map((entry) => entry.id),
).toEqual(['pixel-farm']);
});
it('returns everything when no filter is active', () => {
expect(
filterGameTemplates(templates, EMPTY_TEMPLATE_LIBRARY_FILTERS),
).toHaveLength(3);
expect(isTemplateLibraryFiltersEmpty(EMPTY_TEMPLATE_LIBRARY_FILTERS)).toBe(
true,
);
expect(
isTemplateLibraryFiltersEmpty({
...EMPTY_TEMPLATE_LIBRARY_FILTERS,
query: ' x ',
}),
).toBe(false);
});
});
describe('tag and runtime options', () => {
it('orders tags by frequency and drops blanks', () => {
const withBlank = [
...templates,
template({ id: 'blank-tag', tags: ['', ' ', '经营'] }),
];
expect(collectGameTemplateTags(withBlank)).toEqual([
'经营',
'三消',
'射击',
'像素',
]);
});
it('collects distinct runtimes and labels them', () => {
expect(collectGameTemplateRuntimes(templates)).toEqual([
'godot',
'html',
'unity',
]);
expect(templateRuntimeLabel('html')).toBe('网页');
expect(templateRuntimeLabel('cocos')).toBe('Cocos');
expect(templateRuntimeLabel('')).toBe('未标注运行时');
expect(templateRuntimeLabel('custom-engine')).toBe('custom-engine');
});
it('toggles tags without mutating the previous filters', () => {
const next = toggleGameTemplateTag(EMPTY_TEMPLATE_LIBRARY_FILTERS, '经营');
expect(next.tags).toEqual(['经营']);
expect(toggleGameTemplateTag(next, '经营').tags).toEqual([]);
expect(EMPTY_TEMPLATE_LIBRARY_FILTERS.tags).toEqual([]);
});
});
describe('needsTemplateDownload', () => {
it('requires a download when missing or when the installed version is stale', () => {
expect(needsTemplateDownload(matchThree)).toBe(false);
expect(needsTemplateDownload(pixelFarm)).toBe(true);
expect(needsTemplateDownload(spaceShooter)).toBe(true);
});
});
describe('formatGameTemplateSize', () => {
it('formats bytes, kilobytes and megabytes', () => {
expect(formatGameTemplateSize(0)).toBe('--');
expect(formatGameTemplateSize(512)).toBe('512 B');
expect(formatGameTemplateSize(2048)).toBe('2.0 KB');
expect(formatGameTemplateSize(5 * 1024 * 1024)).toBe('5.0 MB');
});
});
@@ -0,0 +1,208 @@
/**
* AGC 模板库的前端模型:清单类型、搜索与筛选的纯函数。
*
* 真源在 OSS 清单与 Rust 侧(`fetch_game_template_library`);这里只做展示层派生,
* 不缓存业务真相,也不拼远端地址(URL 由 Rust 侧按受信任 OSS 前缀给出)。
*/
export type GameTemplateLibrarySource = 'network' | 'cache';
export type GameTemplateEntry = {
id: string;
title: string;
summary: string;
tags: string[];
runtime: string;
engine: string;
engineVersion: string;
templateVersion: string;
updatedAt: string;
entry: string;
zipUrl: string;
zipSizeBytes: number;
zipSha256: string;
coverUrl: string;
coverWidth: number;
coverHeight: number;
installed: boolean;
installedVersion: string | null;
installedAtMillis: number | null;
};
export type GameTemplateLibrarySnapshot = {
schemaVersion: string;
library: string;
libraryVersion: number;
updatedAt: string;
fetchedAtMillis: number;
source: GameTemplateLibrarySource;
templates: GameTemplateEntry[];
};
export type InstalledGameTemplate = {
templateId: string;
templateVersion: string;
installedAtMillis: number;
zipSha256: string;
fileCount: number;
projectDir: string;
};
export type TemplateLibraryFilters = {
query: string;
tags: readonly string[];
runtime: string;
installedOnly: boolean;
};
export const EMPTY_TEMPLATE_LIBRARY_FILTERS: TemplateLibraryFilters = {
query: '',
tags: [],
runtime: '',
installedOnly: false,
};
const RUNTIME_LABELS: Record<string, string> = {
html: '网页',
unity: 'Unity',
godot: 'Godot',
cocos: 'Cocos',
};
export function templateRuntimeLabel(runtime: string): string {
const normalized = runtime.trim().toLowerCase();
if (!normalized) return '未标注运行时';
return RUNTIME_LABELS[normalized] ?? runtime.trim();
}
/**
* 空白分隔的多个关键词之间是「与」关系:每个词都必须命中标题、简介、标签或引擎,
* 这样「三消 像素」不会退化成命中任意一个就出现的宽泛搜索。
*/
export function templateMatchesQuery(
template: GameTemplateEntry,
query: string,
): boolean {
const terms = query
.toLowerCase()
.split(/\s+/u)
.filter((term) => term.length > 0);
if (terms.length === 0) {
return true;
}
const haystack = [
template.title,
template.summary,
template.engine,
template.runtime,
template.tags.join(' '),
]
.join(' ')
.toLowerCase();
return terms.every((term) => haystack.includes(term));
}
export function filterGameTemplates(
templates: readonly GameTemplateEntry[],
filters: TemplateLibraryFilters,
): GameTemplateEntry[] {
const selectedTags = filters.tags
.map((tag) => tag.trim().toLowerCase())
.filter((tag) => tag.length > 0);
const runtime = filters.runtime.trim().toLowerCase();
return templates.filter((template) => {
if (filters.installedOnly && !template.installed) {
return false;
}
if (runtime && template.runtime.trim().toLowerCase() !== runtime) {
return false;
}
if (selectedTags.length > 0) {
const templateTags = template.tags.map((tag) => tag.toLowerCase());
if (!selectedTags.some((tag) => templateTags.includes(tag))) {
return false;
}
}
return templateMatchesQuery(template, filters.query);
});
}
/** 标签按出现次数降序,次数相同按名称排序,保证筛选条顺序稳定。 */
export function collectGameTemplateTags(
templates: readonly GameTemplateEntry[],
): string[] {
const counts = new Map<string, number>();
for (const template of templates) {
for (const tag of template.tags) {
const trimmed = tag.trim();
if (!trimmed) continue;
counts.set(trimmed, (counts.get(trimmed) ?? 0) + 1);
}
}
return [...counts.entries()]
.sort(
([leftTag, leftCount], [rightTag, rightCount]) =>
rightCount - leftCount || leftTag.localeCompare(rightTag, 'zh-CN'),
)
.map(([tag]) => tag);
}
export function collectGameTemplateRuntimes(
templates: readonly GameTemplateEntry[],
): string[] {
const runtimes = new Set<string>();
for (const template of templates) {
const runtime = template.runtime.trim().toLowerCase();
if (runtime) runtimes.add(runtime);
}
return [...runtimes].sort((left, right) =>
left.localeCompare(right, 'zh-CN'),
);
}
export function formatGameTemplateSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) {
return '--';
}
if (bytes < 1024) {
return `${Math.round(bytes)} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function isTemplateLibraryFiltersEmpty(
filters: TemplateLibraryFilters,
): boolean {
return (
!filters.query.trim() &&
filters.tags.length === 0 &&
!filters.runtime.trim() &&
!filters.installedOnly
);
}
export function toggleGameTemplateTag(
filters: TemplateLibraryFilters,
tag: string,
): TemplateLibraryFilters {
const exists = filters.tags.includes(tag);
return {
...filters,
tags: exists
? filters.tags.filter((value) => value !== tag)
: [...filters.tags, tag],
};
}
/**
* 已安装版本低于清单版本时必须重新下载;已安装且版本一致才算可直接使用。
*/
export function needsTemplateDownload(template: GameTemplateEntry): boolean {
return (
!template.installed ||
template.installedVersion !== template.templateVersion
);
}
@@ -0,0 +1,243 @@
/**
* 模板库状态链路:拉取清单、下载模板、用模板建项目。
*
* 远端真相全在 Rust 侧命令里(受信任 OSS 前缀 + 摘要校验 + 本机安装记录);
* 这里只维护界面状态,并在下载成功后把对应条目的安装状态就地更新,
* 避免为了一个"已下载"徽标再打一次清单请求。
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { resolveTauriInvoke } from '../../app/tauri';
import type { InitLocalProjectResult } from '../../app/types';
import {
collectGameTemplateRuntimes,
collectGameTemplateTags,
EMPTY_TEMPLATE_LIBRARY_FILTERS,
filterGameTemplates,
type GameTemplateEntry,
type GameTemplateLibrarySnapshot,
type InstalledGameTemplate,
isTemplateLibraryFiltersEmpty,
needsTemplateDownload,
type TemplateLibraryFilters,
toggleGameTemplateTag,
} from './templateLibraryModel';
export type TemplateLibraryStatus = 'idle' | 'loading' | 'ready' | 'error';
export type TemplateLibraryBusyKind = 'download' | 'create';
type UseTemplateLibraryOptions = {
/** 项目已建好:由调用方负责进入项目工作区(模板库不碰工作区状态)。 */
onProjectCreated: (result: InitLocalProjectResult) => Promise<void> | void;
};
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function useTemplateLibrary({
onProjectCreated,
}: UseTemplateLibraryOptions) {
const [snapshot, setSnapshot] = useState<GameTemplateLibrarySnapshot | null>(
null,
);
const [status, setStatus] = useState<TemplateLibraryStatus>('idle');
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [filters, setFilters] = useState<TemplateLibraryFilters>(
EMPTY_TEMPLATE_LIBRARY_FILTERS,
);
const [busyTemplateId, setBusyTemplateId] = useState<string | null>(null);
const [busyKind, setBusyKind] = useState<TemplateLibraryBusyKind | null>(
null,
);
const loadingRef = useRef(false);
const refresh = useCallback(async () => {
if (loadingRef.current) {
return;
}
const invoke = resolveTauriInvoke();
if (!invoke) {
setStatus('error');
setError('需要在陶泥儿客户端内运行');
return;
}
loadingRef.current = true;
setStatus('loading');
setError('');
try {
const next = await invoke<GameTemplateLibrarySnapshot>(
'fetch_game_template_library',
);
setSnapshot(next);
setStatus('ready');
setNotice(
next.source === 'cache' ? '远端清单暂时读不到,当前展示本机缓存' : '',
);
} catch (nextError) {
setStatus('error');
setError(errorMessage(nextError));
} finally {
loadingRef.current = false;
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const downloadTemplate = useCallback(async (template: GameTemplateEntry) => {
const invoke = resolveTauriInvoke();
if (!invoke) {
throw new Error('需要在陶泥儿客户端内运行');
}
setBusyTemplateId(template.id);
setBusyKind('download');
setError('');
try {
const installed = await invoke<InstalledGameTemplate>(
'download_game_template',
{
templateId: template.id,
templateVersion: template.templateVersion,
},
);
setSnapshot((current) =>
current
? {
...current,
templates: current.templates.map((entry) =>
entry.id === template.id
? {
...entry,
installed: true,
installedVersion: installed.templateVersion,
installedAtMillis: installed.installedAtMillis,
}
: entry,
),
}
: current,
);
setNotice(`已下载模板「${template.title}`);
return installed;
} catch (nextError) {
setError(errorMessage(nextError));
throw nextError;
} finally {
setBusyTemplateId(null);
setBusyKind(null);
}
}, []);
const createProjectFromTemplate = useCallback(
async (template: GameTemplateEntry) => {
const invoke = resolveTauriInvoke();
if (!invoke) {
throw new Error('需要在陶泥儿客户端内运行');
}
try {
if (needsTemplateDownload(template)) {
await downloadTemplate(template);
}
setBusyTemplateId(template.id);
setBusyKind('create');
setError('');
setNotice(`正在用模板「${template.title}」创建项目`);
const result = await invoke<InitLocalProjectResult>(
'create_automatic_local_game_project_from_template',
{
templateId: template.id,
templateVersion: template.templateVersion,
name: null,
planning: false,
},
);
await onProjectCreated(result);
setNotice(`已用模板「${template.title}」创建项目`);
return result;
} catch (nextError) {
setError(errorMessage(nextError));
throw nextError;
} finally {
setBusyTemplateId(null);
setBusyKind(null);
}
},
[downloadTemplate, onProjectCreated],
);
const templates = useMemo(
() => snapshot?.templates ?? [],
[snapshot?.templates],
);
const visibleTemplates = useMemo(
() => filterGameTemplates(templates, filters),
[templates, filters],
);
const tagOptions = useMemo(
() => collectGameTemplateTags(templates),
[templates],
);
const runtimeOptions = useMemo(
() => collectGameTemplateRuntimes(templates),
[templates],
);
const installedCount = useMemo(
() => templates.filter((template) => template.installed).length,
[templates],
);
const filtersActive = !isTemplateLibraryFiltersEmpty(filters);
const setQuery = useCallback((query: string) => {
setFilters((current) => ({ ...current, query }));
}, []);
const selectRuntime = useCallback((runtime: string) => {
setFilters((current) => ({
...current,
runtime: current.runtime === runtime ? '' : runtime,
}));
}, []);
const toggleTag = useCallback((tag: string) => {
setFilters((current) => toggleGameTemplateTag(current, tag));
}, []);
const setInstalledOnly = useCallback((installedOnly: boolean) => {
setFilters((current) => ({ ...current, installedOnly }));
}, []);
const clearFilters = useCallback(() => {
setFilters(EMPTY_TEMPLATE_LIBRARY_FILTERS);
}, []);
return {
snapshot,
status,
error,
notice,
templates,
visibleTemplates,
tagOptions,
runtimeOptions,
installedCount,
filters,
filtersActive,
setQuery,
selectRuntime,
toggleTag,
setInstalledOnly,
clearFilters,
busyTemplateId,
busyKind,
refresh,
downloadTemplate,
createProjectFromTemplate,
clearNotice: useCallback(() => setNotice(''), []),
};
}
export type TemplateLibraryController = ReturnType<typeof useTemplateLibrary>;
@@ -1,81 +0,0 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
// TODO: 后续改为由服务器下发灵感资源;届时保留此组件的展示与预览交互,移除本地目录扫描。
const INSPIRATION_IMAGES = Object.entries(
import.meta.glob('./assets/inspiration/*.{webp,png,jpg,jpeg}', {
eager: true,
import: 'default',
query: '?url',
}),
)
.sort(([left], [right]) =>
left.localeCompare(right, undefined, { numeric: true }),
)
.map(([, image]) => image as string);
export default function InspirationGallery() {
const [selectedImage, setSelectedImage] = useState<string | null>(null);
useEffect(() => {
if (!selectedImage) {
return;
}
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setSelectedImage(null);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener('keydown', handleKeyDown);
};
}, [selectedImage]);
return (
<>
<div className="columns-3 gap-3 max-[760px]:columns-2 max-[460px]:columns-1">
{INSPIRATION_IMAGES.map((image, index) => (
<button
className="mb-3 block w-full cursor-zoom-in break-inside-avoid overflow-hidden rounded-lg border-0 bg-transparent p-0 shadow-(--platform-panel-shadow) transition duration-200 hover:-translate-y-0.5 hover:shadow-(--platform-profile-action-shadow) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--platform-warm-text)"
type="button"
key={image}
aria-label={`查看灵感图片 ${index + 1}`}
onClick={() => setSelectedImage(image)}
>
<img
className="block h-auto w-full"
src={image}
alt=""
loading="lazy"
decoding="async"
/>
</button>
))}
</div>
{selectedImage
? createPortal(
<div
className="fixed inset-0 z-[1000] grid cursor-zoom-out place-items-center bg-black/80 p-6"
role="dialog"
aria-modal="true"
aria-label="查看灵感图片"
onClick={() => setSelectedImage(null)}
>
<img
className="block max-h-[92vh] max-w-[92vw] cursor-default object-contain shadow-2xl"
src={selectedImage}
alt="放大的灵感图片"
onClick={(event) => event.stopPropagation()}
/>
</div>,
document.body,
)
: null}
</>
);
}
@@ -0,0 +1,96 @@
import { BadgeCheck, Loader2, Package } from 'lucide-react';
import type { GameTemplateEntry } from '../../features/template-library/templateLibraryModel';
import { templateRuntimeLabel } from '../../features/template-library/templateLibraryModel';
type TemplateRecommendationsProps = {
templates: readonly GameTemplateEntry[];
loading: boolean;
error: string;
onOpenLibrary: () => void;
};
const RECOMMENDATION_LIMIT = 6;
/**
* 首页模板推荐:只做展示与跳转,下载与建项目都在模板库页面里完成,
* 避免首页的卡片点击直接产生项目副作用。
*/
export default function TemplateRecommendations({
templates,
loading,
error,
onOpenLibrary,
}: TemplateRecommendationsProps) {
if (loading && templates.length === 0) {
return (
<div className="flex items-center gap-2 py-6 text-[12px] text-(--platform-text-soft)">
<Loader2 className="animate-spin" size={14} aria-hidden="true" />
</div>
);
}
if (templates.length === 0) {
return (
<div className="grid justify-items-center gap-2 py-6">
<Package
className="text-(--platform-icon-text)"
size={22}
aria-hidden="true"
/>
<span className="text-[12px] text-(--platform-text-soft)">
{error || '模板库暂时没有可用的模板'}
</span>
<button
className="cursor-pointer border-0 bg-transparent p-0 text-[12px] text-(--platform-warm-text)"
type="button"
onClick={onOpenLibrary}
>
</button>
</div>
);
}
return (
<div className="grid grid-cols-3 gap-3 max-[760px]:grid-cols-2 max-[460px]:grid-cols-1">
{templates.slice(0, RECOMMENDATION_LIMIT).map((template) => (
<button
className="grid cursor-pointer content-start overflow-hidden rounded-lg border border-(--platform-subpanel-border) bg-transparent p-0 text-left shadow-(--platform-panel-shadow) transition duration-200 hover:-translate-y-0.5 hover:shadow-(--platform-profile-action-shadow) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--platform-warm-text)"
type="button"
key={template.id}
aria-label={`查看模板 ${template.title}`}
data-template-id={template.id}
onClick={onOpenLibrary}
>
<span className="relative block aspect-video w-full overflow-hidden bg-black/20">
<img
className="block h-full w-full object-cover"
src={template.coverUrl}
alt=""
loading="lazy"
decoding="async"
/>
{template.installed ? (
<span className="absolute right-2 top-2 inline-flex items-center gap-1 rounded-full bg-black/60 px-2 py-0.5 text-[10px] text-white">
<BadgeCheck size={11} aria-hidden="true" />
</span>
) : null}
</span>
<span className="grid gap-1 p-2.5">
<strong className="truncate text-[12px] text-(--platform-text-strong)">
{template.title}
</strong>
<span className="truncate text-[10px] text-(--platform-text-soft)">
{[templateRuntimeLabel(template.runtime), template.engine]
.filter((value) => value.trim())
.join(' · ')}
</span>
</span>
</button>
))}
</div>
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Some files were not shown because too many files have changed in this diff Show More