新增 AGC 模板库与由模板创建项目链路 #396
@@ -57,6 +57,7 @@
|
||||
"react-colorful": "^5.8.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-window": "^1.8.11",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"vite": "^6.2.0",
|
||||
@@ -70,6 +71,7 @@
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"typescript": "~5.8.2",
|
||||
"vitest": "^0.34.6"
|
||||
|
||||
@@ -6,6 +6,9 @@ publish = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# 模板库假数据注入(仅本地页面压测/演示用):只有显式开启该 feature 才会编译并在读取清单后
|
||||
# 把条目循环补齐成假数据;计数由 AGC_TEMPLATE_LIBRARY_SYNTHETIC_COUNT 控制(默认 1000)。
|
||||
template-library-fixtures = []
|
||||
cocos-editor = ["cocos-editor-bridge/process-discovery"]
|
||||
cocos-editor-execute = ["cocos-editor", "cocos-editor-bridge/windows-bootstrap"]
|
||||
cocos-editor-injection = ["cocos-editor-execute", "cocos-editor-bridge/windows-injection"]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,133 @@
|
||||
{
|
||||
"schemaVersion": "agc-template-library.v1",
|
||||
"library": "agc-game-templates",
|
||||
"libraryVersion": 1,
|
||||
"updatedAt": "2026-09-17T03:22:43Z",
|
||||
"templates": [
|
||||
{
|
||||
"id": "blank-2d-canvas",
|
||||
"title": "空白二维画布工程",
|
||||
"summary": "原生 Canvas 二维空白工程:自适应画布、按设备像素比缩放与 requestAnimationFrame 主循环已就绪。",
|
||||
"tags": [
|
||||
"空白",
|
||||
"起步工程",
|
||||
"2d",
|
||||
"canvas"
|
||||
],
|
||||
"runtime": "html",
|
||||
"engine": "canvas",
|
||||
"engineVersion": "",
|
||||
"templateVersion": "0.1.0",
|
||||
"updatedAt": "2026-09-17T03:22:43Z",
|
||||
"entry": "game/index.html",
|
||||
"zipKey": "templates/v1/blank-2d-canvas/template.zip",
|
||||
"zipSizeBytes": 1534,
|
||||
"zipSha256": "ff8f84e4793941acaf161738c2795f65c5d5390de8614f51aa9e3a5771767134",
|
||||
"coverKey": "templates/v1/blank-2d-canvas/cover.svg",
|
||||
"coverWidth": 960,
|
||||
"coverHeight": 540,
|
||||
"coverSha256": "afb753dc6d3de0f9fb6e03ec94f2be7221dd04c9d4ab3cf92311879f0af25192",
|
||||
"metadataKey": "templates/v1/blank-2d-canvas/template.json"
|
||||
},
|
||||
{
|
||||
"id": "blank-3d-scene",
|
||||
"title": "空白三维场景工程",
|
||||
"summary": "Three.js 空白场景:空场景、透视相机、网格地面与自适应视口已就绪,适合从零搭三维玩法。",
|
||||
"tags": [
|
||||
"空白",
|
||||
"起步工程",
|
||||
"3d",
|
||||
"three.js"
|
||||
],
|
||||
"runtime": "html",
|
||||
"engine": "three.js",
|
||||
"engineVersion": "0.180.0",
|
||||
"templateVersion": "0.1.0",
|
||||
"updatedAt": "2026-09-17T03:22:43Z",
|
||||
"entry": "game/index.html",
|
||||
"zipKey": "templates/v1/blank-3d-scene/template.zip",
|
||||
"zipSizeBytes": 1644,
|
||||
"zipSha256": "f3f295f4e5adcf1445d75229dc1b583376a9bc96d3f27a257d69ee3b7cace892",
|
||||
"coverKey": "templates/v1/blank-3d-scene/cover.svg",
|
||||
"coverWidth": 960,
|
||||
"coverHeight": 540,
|
||||
"coverSha256": "1429232adaf6df4457e45b4fc8d7ee9fab2e6bffce9f3f0016e91b81bb66c6a7",
|
||||
"metadataKey": "templates/v1/blank-3d-scene/template.json"
|
||||
},
|
||||
{
|
||||
"id": "blank-web",
|
||||
"title": "空白网页工程",
|
||||
"summary": "最小网页工程(HTML + CSS + 原生 JS + Vite),没有任何引擎依赖,适合从零写玩法。",
|
||||
"tags": [
|
||||
"空白",
|
||||
"起步工程",
|
||||
"网页",
|
||||
"原生"
|
||||
],
|
||||
"runtime": "html",
|
||||
"engine": "none",
|
||||
"engineVersion": "",
|
||||
"templateVersion": "0.1.0",
|
||||
"updatedAt": "2026-09-17T03:22:43Z",
|
||||
"entry": "game/index.html",
|
||||
"zipKey": "templates/v1/blank-web/template.zip",
|
||||
"zipSizeBytes": 1212,
|
||||
"zipSha256": "6fa4391f30342e8dcbdcf735f990d2534ea50405f119e4fa5879b83e8f00119e",
|
||||
"coverKey": "templates/v1/blank-web/cover.svg",
|
||||
"coverWidth": 960,
|
||||
"coverHeight": 540,
|
||||
"coverSha256": "326a2753386618971b1311043effa46c2e74bc1cfb116862c0ee4097dcd5086a",
|
||||
"metadataKey": "templates/v1/blank-web/template.json"
|
||||
},
|
||||
{
|
||||
"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:22:43Z",
|
||||
"entry": "game/index.html",
|
||||
"zipKey": "templates/v1/phaser-2d-starter/template.zip",
|
||||
"zipSizeBytes": 8770,
|
||||
"zipSha256": "9026856c3c0b3a42401172e36ce8b450a65e9f11eb9096624d8990d51449d8ce",
|
||||
"coverKey": "templates/v1/phaser-2d-starter/cover.svg",
|
||||
"coverWidth": 960,
|
||||
"coverHeight": 540,
|
||||
"coverSha256": "fdb422027bf54bf755b2b91cd21fa10fdd7ce3f5c7e3ccd9ac3ffba602b12b96",
|
||||
"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:22:43Z",
|
||||
"entry": "game/index.html",
|
||||
"zipKey": "templates/v1/threejs-3d-starter/template.zip",
|
||||
"zipSizeBytes": 1697,
|
||||
"zipSha256": "03096152b17cd6d55e7f6ccd518485136133cb54fd2a5a5d0ac8e0974149155c",
|
||||
"coverKey": "templates/v1/threejs-3d-starter/cover.svg",
|
||||
"coverWidth": 960,
|
||||
"coverHeight": 540,
|
||||
"coverSha256": "7ba013e8a8b515d7aff7146fe401afe5ba3416b9c69db181179705e1bce01beb",
|
||||
"metadataKey": "templates/v1/threejs-3d-starter/template.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { Copy, Minus, Square, X } from 'lucide-react';
|
||||
import { type ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png';
|
||||
import { ActiveProjectRunsPanel } from '../features/app-shell/ActiveProjectRunsPanel';
|
||||
@@ -49,14 +55,21 @@ export function WindowChrome({ children }: WindowChromeProps) {
|
||||
setTitleState(normalizedTitle || WINDOW_CHROME_DEFAULT_TITLE);
|
||||
}, []);
|
||||
|
||||
const contextValue: WindowChromeContextValue = {
|
||||
isWindowChrome: true,
|
||||
title,
|
||||
setTitle,
|
||||
walletSlot,
|
||||
activeProjectRuns,
|
||||
setActiveProjectRuns,
|
||||
};
|
||||
/**
|
||||
* context value 必须 memo:内联对象会让所有 `useWindowChrome()` 消费方在标题栏
|
||||
* 每次渲染时都重新拿到新对象,进而连带重跑它们依赖 context 的 effect。
|
||||
*/
|
||||
const contextValue = useMemo<WindowChromeContextValue>(
|
||||
() => ({
|
||||
isWindowChrome: true,
|
||||
title,
|
||||
setTitle,
|
||||
walletSlot,
|
||||
activeProjectRuns,
|
||||
setActiveProjectRuns,
|
||||
}),
|
||||
[title, setTitle, walletSlot, activeProjectRuns],
|
||||
);
|
||||
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
|
||||
|
||||
@@ -38,6 +38,14 @@ export function useDirectActiveTurns({
|
||||
const [snapshotReadFailed, setSnapshotReadFailed] = useState(false);
|
||||
const mountedRef = useRef(true);
|
||||
const inFlightRef = useRef<Promise<void> | null>(null);
|
||||
/**
|
||||
* 上一次成功读取到的快照签名。
|
||||
*
|
||||
* 轮询每 5 秒跑一次,如果每次都 `setActiveTurns(新数组)`,即使内容一模一样也会
|
||||
* 换掉数组身份:所有依赖 `activeTurns` 的 effect 都会跟着重跑(窗口标题栏的活动项目
|
||||
* 面板就是这么被反复重发布的)。这里只在内容真的变了才更新状态。
|
||||
*/
|
||||
const lastSnapshotSignatureRef = useRef<string>('');
|
||||
const retryTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -72,7 +80,12 @@ export function useDirectActiveTurns({
|
||||
if (!mountedRef.current) {
|
||||
return;
|
||||
}
|
||||
setActiveTurns(Array.isArray(turns) ? turns : []);
|
||||
const nextTurns = Array.isArray(turns) ? turns : [];
|
||||
const nextSignature = JSON.stringify(nextTurns);
|
||||
if (nextSignature !== lastSnapshotSignatureRef.current) {
|
||||
lastSnapshotSignatureRef.current = nextSignature;
|
||||
setActiveTurns(nextTurns);
|
||||
}
|
||||
setSnapshotReadFailed(false);
|
||||
inFlightRef.current = null;
|
||||
return;
|
||||
@@ -99,8 +112,10 @@ export function useDirectActiveTurns({
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !invoke) {
|
||||
setActiveTurns([]);
|
||||
setSnapshotReadFailed(false);
|
||||
lastSnapshotSignatureRef.current = '';
|
||||
// 空态也要保持引用稳定:已经空了就不要再换一个新数组。
|
||||
setActiveTurns((current) => (current.length === 0 ? current : []));
|
||||
setSnapshotReadFailed((current) => (current ? false : current));
|
||||
return;
|
||||
}
|
||||
void refreshActiveTurns();
|
||||
|
||||
@@ -32,8 +32,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,
|
||||
@@ -90,6 +92,11 @@ export function WorkspaceLauncherShell({
|
||||
setAgentChatProjectPath: developerAgent.setAgentChatProjectPath,
|
||||
rememberRecentWorkspace,
|
||||
});
|
||||
const templateLibrary = useTemplateLibrary({
|
||||
onProjectCreated: async (result) => {
|
||||
await homeProject.enterCreatedTemplateProject(result);
|
||||
},
|
||||
});
|
||||
const {
|
||||
projectPath,
|
||||
setProjectPath,
|
||||
@@ -222,17 +229,29 @@ export function WorkspaceLauncherShell({
|
||||
[setProjectPath],
|
||||
);
|
||||
|
||||
/**
|
||||
* 项目卡片面板发布给窗口标题栏的回调必须走 ref。
|
||||
*
|
||||
* `openProject` 来自 `useHomeProjectCreation` 的普通函数(每次渲染都是新身份),
|
||||
* 所以 `openActiveProject` 的引用每渲染都变;如果它进 effect 依赖,就会变成
|
||||
* 「effect 每渲染重跑 → cleanup/setActiveProjectRuns 改 WindowChrome 状态 → 重新渲染」
|
||||
* 的无限 setState 循环(React 报 `Maximum update depth exceeded`)。
|
||||
* 这里只让 effect 依赖真正的数据,回调通过 ref 取最新实现。
|
||||
*/
|
||||
const openActiveProjectRef = useRef(openActiveProject);
|
||||
openActiveProjectRef.current = openActiveProject;
|
||||
|
||||
useEffect(() => {
|
||||
setActiveProjectRuns({
|
||||
activeTurns,
|
||||
currentProjectPath: currentProjectContext?.projectPath ?? null,
|
||||
readFailed: snapshotReadFailed,
|
||||
onOpenProject: openActiveProject,
|
||||
onOpenProject: (projectPath: string) =>
|
||||
openActiveProjectRef.current(projectPath),
|
||||
});
|
||||
}, [
|
||||
activeTurns,
|
||||
currentProjectContext?.projectPath,
|
||||
openActiveProject,
|
||||
setActiveProjectRuns,
|
||||
snapshotReadFailed,
|
||||
]);
|
||||
@@ -580,6 +599,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
|
||||
@@ -587,6 +613,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,96 @@
|
||||
/**
|
||||
* 模板库卡片网格的布局计算(纯函数)。
|
||||
*
|
||||
* 页面用 `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}`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 13 KiB |