模板库卡片列表接入虚拟滚动
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 6m16s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m56s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m17s
Project CI / Backend tests (pull_request) Failing after 9s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 5m33s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m29s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m40s
Project CI / AI game creator shell web tests (pull_request) Failing after 4m2s
Project CI / Frontend tests (pull_request) Failing after 4m19s
Project CI / Native shell tests (pull_request) Successful in 8m46s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 6m16s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m56s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m17s
Project CI / Backend tests (pull_request) Failing after 9s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 5m33s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m29s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m40s
Project CI / AI game creator shell web tests (pull_request) Failing after 4m2s
Project CI / Frontend tests (pull_request) Failing after 4m19s
Project CI / Native shell tests (pull_request) Successful in 8m46s
- 用 workspace 已有的 react-window@1.8.11 的 FixedSizeGrid:只渲染可视行 + 2 行 overscan,1000 条不再一次性挂 1000 个卡片节点 - 新增 templateLibraryGrid.ts 纯函数布局契约(列数/列宽/行高/行数/按行切分),单测覆盖 - 卡片抽成 memo 化的 TemplateCard;筛选条件变化时把滚动位置复位到顶部 - 筛选区(运行时/标签)改为可独立滚动区块,标签膨胀不再挤压卡片区 - 页面回归改用固定视口:断言 1000 条只渲染 ≤40 张卡、滚动高度按 250 行计算 - 技术方案补虚拟滚动契约说明
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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,139 @@
|
||||
import { BadgeCheck, Download, Loader2, Play } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import type { GameTemplateEntry } from '../../features/template-library/templateLibraryModel';
|
||||
import {
|
||||
formatGameTemplateSize,
|
||||
needsTemplateDownload,
|
||||
templateRuntimeLabel,
|
||||
} from '../../features/template-library/templateLibraryModel';
|
||||
|
||||
export type TemplateCardActions = {
|
||||
busyKind: 'download' | 'create' | null;
|
||||
busyTemplateId: string | null;
|
||||
onDownload: (template: GameTemplateEntry) => void;
|
||||
onUse: (template: GameTemplateEntry) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 虚拟列表里的单个模板卡片:高度由行高契约固定(封面 16:9 + 固定文字区),
|
||||
* 用 memo 包住,滚动时只重渲染可视区域内的少量卡片。
|
||||
*/
|
||||
function TemplateCardView({
|
||||
template,
|
||||
busyKind,
|
||||
busyTemplateId,
|
||||
onDownload,
|
||||
onUse,
|
||||
}: { template: GameTemplateEntry } & TemplateCardActions) {
|
||||
const busy = busyTemplateId === template.id;
|
||||
const needsDownload = needsTemplateDownload(template);
|
||||
const busyLabel =
|
||||
busy && busyKind === 'download'
|
||||
? '正在下载模板'
|
||||
: busy && busyKind === 'create'
|
||||
? '正在创建项目'
|
||||
: '';
|
||||
const meta = [
|
||||
templateRuntimeLabel(template.runtime),
|
||||
template.engine,
|
||||
`v${template.templateVersion}`,
|
||||
formatGameTemplateSize(template.zipSizeBytes),
|
||||
]
|
||||
.filter((value) => value && value.trim())
|
||||
.join(' · ');
|
||||
|
||||
return (
|
||||
<article
|
||||
className="grid h-full content-start grid-rows-[auto_minmax(0,1fr)] overflow-hidden rounded-xl border border-(--platform-subpanel-border) [background:var(--platform-subpanel-fill)]"
|
||||
data-template-id={template.id}
|
||||
data-template-installed={template.installed ? 'true' : 'false'}
|
||||
>
|
||||
<div className="relative 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"
|
||||
title={`已下载 v${template.installedVersion ?? template.templateVersion}`}
|
||||
>
|
||||
<BadgeCheck size={12} aria-hidden="true" />
|
||||
已下载
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid min-h-0 content-start gap-2 overflow-hidden p-3">
|
||||
<strong className="truncate text-[13px] text-(--platform-text-strong)">
|
||||
{template.title}
|
||||
</strong>
|
||||
<span className="truncate text-[10px] text-(--platform-text-soft)">
|
||||
{meta}
|
||||
</span>
|
||||
{template.summary ? (
|
||||
<p className="m-0 line-clamp-2 text-[11px] leading-4 text-(--platform-neutral-text)">
|
||||
{template.summary}
|
||||
</p>
|
||||
) : null}
|
||||
{template.tags.length > 0 ? (
|
||||
<div className="flex max-h-5.5 flex-wrap gap-1 overflow-hidden">
|
||||
{template.tags.map((tag) => (
|
||||
<span
|
||||
className="rounded-full bg-(--platform-nav-item-hover-fill) px-2 py-0.5 text-[10px] text-(--platform-text-soft)"
|
||||
key={tag}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-auto flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-(--platform-warm-text) bg-transparent px-2.5 py-1.5 text-[11px] text-(--platform-warm-text) disabled:cursor-default disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onClick={() => onUse(template)}
|
||||
>
|
||||
{busy && busyKind === 'create' ? (
|
||||
<Loader2 className="animate-spin" size={12} aria-hidden="true" />
|
||||
) : (
|
||||
<Play 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}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export const TemplateCard = memo(TemplateCardView);
|
||||
@@ -1,25 +1,27 @@
|
||||
import { PlatformRuntimeStatusToast } from '@genarrative/shared/components';
|
||||
import {
|
||||
ArrowLeft,
|
||||
BadgeCheck,
|
||||
Download,
|
||||
Loader2,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Search,
|
||||
SearchX,
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { FixedSizeGrid, type GridChildComponentProps } from 'react-window';
|
||||
|
||||
import type { GameTemplateEntry } from '../../features/template-library/templateLibraryModel';
|
||||
import {
|
||||
formatGameTemplateSize,
|
||||
needsTemplateDownload,
|
||||
templateRuntimeLabel,
|
||||
} from '../../features/template-library/templateLibraryModel';
|
||||
buildTemplateRows,
|
||||
computeTemplateGridLayout,
|
||||
TEMPLATE_CARD_GAP,
|
||||
TEMPLATE_GRID_OVERSCAN_ROWS,
|
||||
templateGridItemKey,
|
||||
} from '../../features/template-library/templateLibraryGrid';
|
||||
import type { GameTemplateEntry } from '../../features/template-library/templateLibraryModel';
|
||||
import { templateRuntimeLabel } from '../../features/template-library/templateLibraryModel';
|
||||
import type { TemplateLibraryController } from '../../features/template-library/useTemplateLibrary';
|
||||
import { TemplateCard, type TemplateCardActions } from './TemplateCard';
|
||||
|
||||
type TemplateLibraryViewProps = {
|
||||
controller: TemplateLibraryController;
|
||||
@@ -76,126 +78,30 @@ const chipClass =
|
||||
const activeChipClass =
|
||||
'cursor-pointer rounded-full border border-(--platform-warm-text) bg-transparent px-2.5 py-1 text-[11px] text-(--platform-warm-text)';
|
||||
|
||||
function TemplateCard({
|
||||
template,
|
||||
busyKind,
|
||||
busyTemplateId,
|
||||
onDownload,
|
||||
onUse,
|
||||
}: {
|
||||
template: GameTemplateEntry;
|
||||
busyKind: 'download' | 'create' | null;
|
||||
busyTemplateId: string | null;
|
||||
onDownload: (template: GameTemplateEntry) => void;
|
||||
onUse: (template: GameTemplateEntry) => void;
|
||||
}) {
|
||||
const busy = busyTemplateId === template.id;
|
||||
const needsDownload = needsTemplateDownload(template);
|
||||
const busyLabel =
|
||||
busy && busyKind === 'download'
|
||||
? '正在下载模板'
|
||||
: busy && busyKind === 'create'
|
||||
? '正在创建项目'
|
||||
: '';
|
||||
const meta = [
|
||||
templateRuntimeLabel(template.runtime),
|
||||
template.engine,
|
||||
`v${template.templateVersion}`,
|
||||
formatGameTemplateSize(template.zipSizeBytes),
|
||||
]
|
||||
.filter((value) => value && value.trim())
|
||||
.join(' · ');
|
||||
type TemplateGridCellData = TemplateCardActions & {
|
||||
rows: Array<Array<GameTemplateEntry | null>>;
|
||||
};
|
||||
|
||||
function TemplateGridCell({
|
||||
columnIndex,
|
||||
rowIndex,
|
||||
style,
|
||||
data,
|
||||
}: GridChildComponentProps<TemplateGridCellData>) {
|
||||
const template = data.rows[rowIndex]?.[columnIndex] ?? null;
|
||||
if (!template) {
|
||||
return <div style={style} />;
|
||||
}
|
||||
return (
|
||||
<article
|
||||
className="grid content-start overflow-hidden rounded-xl border border-(--platform-subpanel-border) [background:var(--platform-subpanel-fill)]"
|
||||
data-template-id={template.id}
|
||||
data-template-installed={template.installed ? 'true' : 'false'}
|
||||
>
|
||||
<div className="relative 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"
|
||||
title={`已下载 v${template.installedVersion ?? template.templateVersion}`}
|
||||
>
|
||||
<BadgeCheck size={12} aria-hidden="true" />
|
||||
已下载
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid gap-2 p-3">
|
||||
<strong className="truncate text-[13px] text-(--platform-text-strong)">
|
||||
{template.title}
|
||||
</strong>
|
||||
<span className="truncate text-[10px] text-(--platform-text-soft)">
|
||||
{meta}
|
||||
</span>
|
||||
{template.summary ? (
|
||||
<p className="m-0 line-clamp-2 text-[11px] leading-4 text-(--platform-neutral-text)">
|
||||
{template.summary}
|
||||
</p>
|
||||
) : null}
|
||||
{template.tags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{template.tags.map((tag) => (
|
||||
<span
|
||||
className="rounded-full bg-(--platform-nav-item-hover-fill) px-2 py-0.5 text-[10px] text-(--platform-text-soft)"
|
||||
key={tag}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-(--platform-warm-text) bg-transparent px-2.5 py-1.5 text-[11px] text-(--platform-warm-text) disabled:cursor-default disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onClick={() => onUse(template)}
|
||||
>
|
||||
{busy && busyKind === 'create' ? (
|
||||
<Loader2 className="animate-spin" size={12} aria-hidden="true" />
|
||||
) : (
|
||||
<Play 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}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<div style={{ ...style, padding: TEMPLATE_CARD_GAP / 2 }}>
|
||||
<TemplateCard
|
||||
template={template}
|
||||
busyKind={data.busyKind}
|
||||
busyTemplateId={data.busyTemplateId}
|
||||
onDownload={data.onDownload}
|
||||
onUse={data.onUse}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -227,13 +133,90 @@ export default function TemplateLibraryView({
|
||||
clearNotice,
|
||||
} = controller;
|
||||
|
||||
const gridRef = useRef<FixedSizeGrid>(null);
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
const showEmptyLibrary = status === 'ready' && templates.length === 0;
|
||||
const showNoMatch =
|
||||
status === 'ready' && templates.length > 0 && visibleTemplates.length === 0;
|
||||
const showGrid = status === 'ready' && visibleTemplates.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const element = viewportRef.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
const measure = () => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const width = Math.round(rect.width);
|
||||
const height = Math.round(rect.height);
|
||||
setViewportSize((current) =>
|
||||
current.width === width && current.height === height
|
||||
? current
|
||||
: { width, height },
|
||||
);
|
||||
};
|
||||
measure();
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
window.addEventListener('resize', measure);
|
||||
return () => window.removeEventListener('resize', measure);
|
||||
}
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [showGrid]);
|
||||
|
||||
const layout = useMemo(
|
||||
() =>
|
||||
computeTemplateGridLayout({
|
||||
containerWidth: viewportSize.width,
|
||||
itemCount: visibleTemplates.length,
|
||||
}),
|
||||
[viewportSize.width, visibleTemplates.length],
|
||||
);
|
||||
const rows = useMemo(
|
||||
() => buildTemplateRows(visibleTemplates, layout.columnCount),
|
||||
[visibleTemplates, layout.columnCount],
|
||||
);
|
||||
|
||||
// 换筛选条件回到列表顶部:否则筛选后条目变少会把视口留在空白处,看起来像“卡住”。
|
||||
useEffect(() => {
|
||||
gridRef.current?.scrollTo({ scrollTop: 0 });
|
||||
}, [
|
||||
filters.query,
|
||||
filters.tags,
|
||||
filters.runtime,
|
||||
filters.installedOnly,
|
||||
layout.columnCount,
|
||||
]);
|
||||
|
||||
const handleDownload = useMemo(
|
||||
() => (template: GameTemplateEntry) => {
|
||||
void downloadTemplate(template).catch(() => undefined);
|
||||
},
|
||||
[downloadTemplate],
|
||||
);
|
||||
const handleUse = useMemo(
|
||||
() => (template: GameTemplateEntry) => {
|
||||
void createProjectFromTemplate(template).catch(() => undefined);
|
||||
},
|
||||
[createProjectFromTemplate],
|
||||
);
|
||||
const cellData = useMemo<TemplateGridCellData>(
|
||||
() => ({
|
||||
rows,
|
||||
busyKind,
|
||||
busyTemplateId,
|
||||
onDownload: handleDownload,
|
||||
onUse: handleUse,
|
||||
}),
|
||||
[rows, busyKind, busyTemplateId, handleDownload, handleUse],
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="platform-theme platform-theme--light grid h-full min-h-0 w-full content-start gap-4 overflow-x-hidden overflow-y-auto px-6 pb-10 pt-5 max-[760px]:px-4"
|
||||
className="platform-theme platform-theme--light flex h-full min-h-0 w-full flex-col gap-3.5 overflow-hidden px-6 pb-5 pt-5 max-[760px]:px-4"
|
||||
aria-label="模板库"
|
||||
>
|
||||
<header className="flex flex-wrap items-center gap-3">
|
||||
@@ -268,7 +251,8 @@ export default function TemplateLibraryView({
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-3 rounded-xl border border-(--platform-subpanel-border) [background:var(--platform-subpanel-fill)] p-3">
|
||||
{/* 标签/运行时筛选区可独立滚动:标签数量随库量增长时不会把卡片区挤出窗口。 */}
|
||||
<div className="grid max-h-[24vh] shrink-0 gap-3 overflow-y-auto rounded-xl border border-(--platform-subpanel-border) [background:var(--platform-subpanel-fill)] p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className="inline-flex min-w-[220px] flex-1 items-center gap-2 rounded-lg border border-(--platform-subpanel-border) bg-transparent px-2.5 py-1.5">
|
||||
<Search
|
||||
@@ -381,22 +365,30 @@ export default function TemplateLibraryView({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{visibleTemplates.length > 0 ? (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(250px,1fr))] gap-3.5">
|
||||
{visibleTemplates.map((template) => (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
busyKind={busyKind}
|
||||
busyTemplateId={busyTemplateId}
|
||||
onDownload={(next) => {
|
||||
void downloadTemplate(next).catch(() => undefined);
|
||||
}}
|
||||
onUse={(next) => {
|
||||
void createProjectFromTemplate(next).catch(() => undefined);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{showGrid ? (
|
||||
<div
|
||||
className="min-h-0 flex-1"
|
||||
ref={viewportRef}
|
||||
data-template-grid-viewport="true"
|
||||
>
|
||||
{viewportSize.width > 0 && viewportSize.height > 0 ? (
|
||||
<FixedSizeGrid
|
||||
ref={gridRef}
|
||||
columnCount={layout.columnCount}
|
||||
columnWidth={layout.columnWidth}
|
||||
rowCount={layout.rowCount}
|
||||
rowHeight={layout.rowHeight}
|
||||
width={viewportSize.width}
|
||||
height={viewportSize.height}
|
||||
overscanRowCount={TEMPLATE_GRID_OVERSCAN_ROWS}
|
||||
itemData={cellData}
|
||||
itemKey={({ rowIndex, columnIndex }) =>
|
||||
templateGridItemKey(rowIndex, columnIndex)
|
||||
}
|
||||
>
|
||||
{TemplateGridCell}
|
||||
</FixedSizeGrid>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildTemplateRows,
|
||||
computeTemplateGridColumns,
|
||||
computeTemplateGridLayout,
|
||||
computeTemplateRowHeight,
|
||||
TEMPLATE_CARD_GAP,
|
||||
TEMPLATE_CARD_MIN_WIDTH,
|
||||
TEMPLATE_CARD_TEXT_HEIGHT,
|
||||
} from '../src/features/template-library/templateLibraryGrid';
|
||||
import type { GameTemplateEntry } from '../src/features/template-library/templateLibraryModel';
|
||||
|
||||
function entry(id: string): GameTemplateEntry {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
summary: '',
|
||||
tags: [],
|
||||
runtime: 'html',
|
||||
engine: 'phaser',
|
||||
engineVersion: '4.2.1',
|
||||
templateVersion: '0.1.0',
|
||||
updatedAt: '2026-09-17T00:00:00Z',
|
||||
entry: 'game/index.html',
|
||||
zipUrl: `https://oss.example/templates/v1/${id}/template.zip`,
|
||||
zipSizeBytes: 1024,
|
||||
zipSha256: 'a'.repeat(64),
|
||||
coverUrl: `https://oss.example/templates/v1/${id}/cover.svg`,
|
||||
coverWidth: 960,
|
||||
coverHeight: 540,
|
||||
installed: false,
|
||||
installedVersion: null,
|
||||
installedAtMillis: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('computeTemplateGridColumns', () => {
|
||||
it('fits as many min-width columns as the container allows', () => {
|
||||
expect(computeTemplateGridColumns(0)).toBe(1);
|
||||
expect(computeTemplateGridColumns(200)).toBe(1);
|
||||
expect(computeTemplateGridColumns(TEMPLATE_CARD_MIN_WIDTH)).toBe(1);
|
||||
// 两列边界:2*250 + 14 = 514
|
||||
const twoColumnWidth = 2 * TEMPLATE_CARD_MIN_WIDTH + TEMPLATE_CARD_GAP;
|
||||
expect(computeTemplateGridColumns(twoColumnWidth)).toBe(2);
|
||||
expect(computeTemplateGridColumns(twoColumnWidth - 1)).toBe(1);
|
||||
// 1200px:4 列((1200+14)/(250+14) = 4.59)
|
||||
expect(computeTemplateGridColumns(1200)).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeTemplateRowHeight', () => {
|
||||
it('keeps cover ratio + fixed text block', () => {
|
||||
// 列宽 300 → 卡片 286 → 封面 286*9/16 = 160.875 → 161
|
||||
expect(computeTemplateRowHeight(300)).toBe(
|
||||
161 + TEMPLATE_CARD_TEXT_HEIGHT + TEMPLATE_CARD_GAP,
|
||||
);
|
||||
// 极窄时按最小卡片宽度兜底,避免行高被压成 0
|
||||
expect(computeTemplateRowHeight(10)).toBeGreaterThan(
|
||||
TEMPLATE_CARD_TEXT_HEIGHT,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeTemplateGridLayout', () => {
|
||||
it('derives columns, row height and row count for a big library', () => {
|
||||
const layout = computeTemplateGridLayout({
|
||||
containerWidth: 1200,
|
||||
itemCount: 1000,
|
||||
});
|
||||
expect(layout.columnCount).toBe(4);
|
||||
expect(layout.columnWidth).toBe(300);
|
||||
expect(layout.rowHeight).toBe(
|
||||
161 + TEMPLATE_CARD_TEXT_HEIGHT + TEMPLATE_CARD_GAP,
|
||||
);
|
||||
expect(layout.rowCount).toBe(250);
|
||||
// 虚拟列表只渲染可视行,滚动高度仍由总行数决定
|
||||
expect(layout.rowCount * layout.rowHeight).toBeGreaterThan(80000);
|
||||
});
|
||||
|
||||
it('handles inline and empty libraries', () => {
|
||||
expect(
|
||||
computeTemplateGridLayout({ containerWidth: 0, itemCount: 5 }),
|
||||
).toEqual({
|
||||
columnCount: 1,
|
||||
columnWidth: 1,
|
||||
rowHeight: computeTemplateRowHeight(1),
|
||||
rowCount: 5,
|
||||
});
|
||||
expect(
|
||||
computeTemplateGridLayout({ containerWidth: 1200, itemCount: 0 })
|
||||
.rowCount,
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTemplateRows', () => {
|
||||
it('chunks entries per row and pads the tail with nulls', () => {
|
||||
const rows = buildTemplateRows([entry('a'), entry('b'), entry('c')], 2);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]?.map((item) => item?.id)).toEqual(['a', 'b']);
|
||||
expect(rows[1]?.map((item) => item?.id ?? null)).toEqual(['c', null]);
|
||||
});
|
||||
|
||||
it('returns no rows for an invalid column count', () => {
|
||||
expect(buildTemplateRows([entry('a')], 0)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
// @vitest-environment jsdom
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { computeTemplateGridLayout } from '../src/features/template-library/templateLibraryGrid';
|
||||
import type {
|
||||
GameTemplateEntry,
|
||||
TemplateLibraryFilters,
|
||||
@@ -106,6 +107,39 @@ function cardFor(title: string): HTMLElement {
|
||||
return card;
|
||||
}
|
||||
|
||||
// 虚拟列表需要可测量的视口:jsdom 没有布局,这里给网格容器固定尺寸并补 ResizeObserver/scrollTo。
|
||||
const GRID_VIEWPORT = { width: 1200, height: 800 };
|
||||
|
||||
beforeAll(() => {
|
||||
const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
|
||||
Element.prototype.getBoundingClientRect = function getBoundingClientRect() {
|
||||
const element = this as HTMLElement;
|
||||
if (element.dataset?.templateGridViewport === 'true') {
|
||||
return {
|
||||
width: GRID_VIEWPORT.width,
|
||||
height: GRID_VIEWPORT.height,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: GRID_VIEWPORT.width,
|
||||
bottom: GRID_VIEWPORT.height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
}
|
||||
return originalGetBoundingClientRect.call(this);
|
||||
};
|
||||
if (typeof Element.prototype.scrollTo !== 'function') {
|
||||
Element.prototype.scrollTo = () => undefined;
|
||||
}
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub);
|
||||
});
|
||||
|
||||
describe('TemplateLibraryView', () => {
|
||||
it('renders a card per template with cover, meta, tags and installed badge', () => {
|
||||
render(<TemplateLibraryView controller={controller()} onBack={() => {}} />);
|
||||
@@ -140,15 +174,19 @@ describe('TemplateLibraryView', () => {
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders the page as the launcher scroll container', () => {
|
||||
// 回归点:`.launcher-main` 只给带 platform-theme 的直接子元素 height:100% + overflow:auto,
|
||||
// 缺这个类时整页没有滚动容器(模板一多就滚不动)。
|
||||
it('keeps the launcher theme contract and owns its own scroll viewport', () => {
|
||||
// 回归点:`.launcher-main` 只给带 platform-theme 的直接子元素 height:100%;
|
||||
// 卡片列表改由虚拟网格自己的视口滚动(整页不再随模板数量变长)。
|
||||
const { container } = render(
|
||||
<TemplateLibraryView controller={controller()} onBack={() => {}} />,
|
||||
);
|
||||
const page = container.querySelector('section[aria-label="模板库"]');
|
||||
expect(page?.className).toContain('platform-theme');
|
||||
expect(page?.className).toContain('overflow-y-auto');
|
||||
const viewport = container.querySelector(
|
||||
'[data-template-grid-viewport="true"]',
|
||||
);
|
||||
expect(viewport).not.toBeNull();
|
||||
expect(viewport?.querySelector('article')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('offers 更新 instead of 下载 when the installed version is stale', () => {
|
||||
@@ -344,7 +382,7 @@ describe('大库量渲染(1000 条假数据)', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
it('renders every card and keeps filters consistent at 1000 entries', () => {
|
||||
it('virtualizes a 1000 entries library instead of rendering every card', () => {
|
||||
const { container } = render(
|
||||
<TemplateLibraryView
|
||||
controller={controller({
|
||||
@@ -357,8 +395,23 @@ describe('大库量渲染(1000 条假数据)', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelectorAll('article')).toHaveLength(1000);
|
||||
// 虚拟列表只渲染可视区域(1200×800 视口 → 4 列 × 约 3 行 + 2 行 overscan)。
|
||||
const renderedCards = container.querySelectorAll('article').length;
|
||||
expect(renderedCards).toBeGreaterThan(0);
|
||||
expect(renderedCards).toBeLessThanOrEqual(40);
|
||||
expect(screen.getByText('共 1000 个模板 · 已下载 334 个')).toBeTruthy();
|
||||
// 滚动高度仍按全部行数计算。
|
||||
const layout = computeTemplateGridLayout({
|
||||
containerWidth: GRID_VIEWPORT.width,
|
||||
itemCount: bulk.length,
|
||||
});
|
||||
expect(layout.columnCount).toBe(4);
|
||||
expect(layout.rowCount).toBe(250);
|
||||
const totalHeight = `${250 * layout.rowHeight}px`;
|
||||
const hasSpacer = Array.from(container.querySelectorAll('div')).some(
|
||||
(element) => (element as HTMLElement).style.height === totalHeight,
|
||||
);
|
||||
expect(hasSpacer).toBe(true);
|
||||
// 标签筛选条会随库量膨胀,这里先记录当前聚合出来的规模(1000 条 × 批次标签)。
|
||||
const tagButtons = screen
|
||||
.getAllByRole('button')
|
||||
|
||||
@@ -97,6 +97,14 @@ AGC_TEMPLATE_LIBRARY_SYNTHETIC_COUNT=300 AGC_DEV_CARGO_FEATURES=template-library
|
||||
|
||||
### 1000 条实测结论
|
||||
|
||||
### 卡片列表虚拟滚动(react-window)
|
||||
|
||||
- 列表改用 workspace 里已有的 `react-window@1.8.11` 的 `FixedSizeGrid`(`react-arborist` 已在用同一版本,不引入新包;类型来自 devDependency `@types/react-window`)。
|
||||
- 布局契约收在纯函数 `templateLibraryGrid.ts`(单测覆盖):列数 = `floor((容器宽 + gap) / (最小卡宽 + gap))`、列宽 = 容器宽 / 列数、行高 = `卡片宽 × 9/16 + 文字区 150 + gap`;`buildTemplateRows` 按行切分并在行尾补 `null` 占位。
|
||||
- 卡片抽成 `TemplateCard`(`memo`),网格只渲染可视行 + 2 行 overscan;筛选条件(关键词/标签/运行时/仅看已下载)变化时把滚动位置复位到顶部,避免"从筛选切回全量后停在空白处"。
|
||||
- 筛选区(运行时/标签)改成可独立滚动的区块(`max-h-[24vh]`),标签数量随库量增长时不再把卡片区挤出窗口。
|
||||
- 回归:`templateLibraryGrid.test.ts` 覆盖列数/行高/行数/切行;页面测试用固定视口断言「1000 条只渲染 ≤ 40 张卡片,滚动高度仍按 250 行计算」。
|
||||
|
||||
- 页面能正常渲染 1000 张卡片(头部显示「共 1000 个模板 · 已下载 335 个」),并且滚动容器生效(窗口高度压到 430px 时右侧出现滚动条,页面内容被裁切而不是溢出到窗口外)。
|
||||
- 需要后续收口的两点(本次未改):① 标签筛选条随库量膨胀——1000 条时聚合出 35 个标签、占三行;② 一次性渲染 1000 个卡片节点并触发 1000 次封面请求。建议标签只展示 Top N + 「更多」,卡片列表加分页或虚拟滚动。
|
||||
- 前端回归:1000 条渲染 + 已安装过滤(334)/标签过滤(50)/关键词过滤数量自洽,见 `apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx`。
|
||||
|
||||
Generated
+23
@@ -118,6 +118,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",
|
||||
@@ -131,6 +132,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"
|
||||
@@ -8421,6 +8423,16 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-window": {
|
||||
"version": "1.8.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz",
|
||||
"integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/semver": {
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
|
||||
@@ -26451,6 +26463,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",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"focus-trap-react": "^12.0.3",
|
||||
"lexical": "^0.47.0",
|
||||
@@ -26461,6 +26474,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",
|
||||
"tailwindcss": "^4.1.14",
|
||||
@@ -28483,6 +28497,15 @@
|
||||
"peer": true,
|
||||
"requires": {}
|
||||
},
|
||||
"@types/react-window": {
|
||||
"version": "1.8.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz",
|
||||
"integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"@types/semver": {
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
|
||||
|
||||
Reference in New Issue
Block a user