模板库卡片列表接入虚拟滚动
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:
kdletters
2026-09-17 16:36:04 +08:00
parent 19e9dea45c
commit 066d0fe6f9
8 changed files with 573 additions and 152 deletions
+2
View File
@@ -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);
// 1200px4 列((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`
+23
View File
@@ -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",