From 066d0fe6f9f722d1399515b00307fccddc992405 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:36:04 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E5=BA=93=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E6=8E=A5=E5=85=A5=E8=99=9A=E6=8B=9F=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 用 workspace 已有的 react-window@1.8.11 的 FixedSizeGrid:只渲染可视行 + 2 行 overscan,1000 条不再一次性挂 1000 个卡片节点 - 新增 templateLibraryGrid.ts 纯函数布局契约(列数/列宽/行高/行数/按行切分),单测覆盖 - 卡片抽成 memo 化的 TemplateCard;筛选条件变化时把滚动位置复位到顶部 - 筛选区(运行时/标签)改为可独立滚动区块,标签膨胀不再挤压卡片区 - 页面回归改用固定视口:断言 1000 条只渲染 ≤40 张卡、滚动高度按 250 行计算 - 技术方案补虚拟滚动契约说明 --- apps/ai-game-creator-shell/package.json | 2 + .../template-library/templateLibraryGrid.ts | 96 ++++++ .../view/template-library/TemplateCard.tsx | 139 +++++++++ .../src/view/template-library/index.tsx | 282 +++++++++--------- .../tests/templateLibraryGrid.test.ts | 108 +++++++ .../tests/templateLibraryView.test.tsx | 67 ++++- ...技术方案】AGC模板库与模板建项-2026-09-17.md | 8 + package-lock.json | 23 ++ 8 files changed, 573 insertions(+), 152 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/template-library/templateLibraryGrid.ts create mode 100644 apps/ai-game-creator-shell/src/view/template-library/TemplateCard.tsx create mode 100644 apps/ai-game-creator-shell/tests/templateLibraryGrid.test.ts diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index ce2317900..fcaf78727 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -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" diff --git a/apps/ai-game-creator-shell/src/features/template-library/templateLibraryGrid.ts b/apps/ai-game-creator-shell/src/features/template-library/templateLibraryGrid.ts new file mode 100644 index 000000000..539c5b7a8 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/template-library/templateLibraryGrid.ts @@ -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> { + if (columnCount <= 0) { + return []; + } + const rows: Array> = []; + for (let index = 0; index < templates.length; index += columnCount) { + const row: Array = []; + 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}`; +} diff --git a/apps/ai-game-creator-shell/src/view/template-library/TemplateCard.tsx b/apps/ai-game-creator-shell/src/view/template-library/TemplateCard.tsx new file mode 100644 index 000000000..c484074ba --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/template-library/TemplateCard.tsx @@ -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 ( +
+
+ + {template.installed ? ( + + + ) : null} +
+
+ + {template.title} + + + {meta} + + {template.summary ? ( +

+ {template.summary} +

+ ) : null} + {template.tags.length > 0 ? ( +
+ {template.tags.map((tag) => ( + + {tag} + + ))} +
+ ) : null} +
+ + {/* 已下载且版本一致时不再提供下载入口;只有缺包或版本落后才显示(落后时按「更新」)。 */} + {needsDownload ? ( + + ) : null} + {busyLabel ? ( + + {busyLabel} + + ) : null} +
+
+
+ ); +} + +export const TemplateCard = memo(TemplateCardView); diff --git a/apps/ai-game-creator-shell/src/view/template-library/index.tsx b/apps/ai-game-creator-shell/src/view/template-library/index.tsx index 520c9192e..0bd4b42b9 100644 --- a/apps/ai-game-creator-shell/src/view/template-library/index.tsx +++ b/apps/ai-game-creator-shell/src/view/template-library/index.tsx @@ -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>; +}; +function TemplateGridCell({ + columnIndex, + rowIndex, + style, + data, +}: GridChildComponentProps) { + const template = data.rows[rowIndex]?.[columnIndex] ?? null; + if (!template) { + return
; + } return ( -
-
- - {template.installed ? ( - - - ) : null} -
-
- - {template.title} - - - {meta} - - {template.summary ? ( -

- {template.summary} -

- ) : null} - {template.tags.length > 0 ? ( -
- {template.tags.map((tag) => ( - - {tag} - - ))} -
- ) : null} -
- - {/* 已下载且版本一致时不再提供下载入口;只有缺包或版本落后才显示(落后时按「更新」)。 */} - {needsDownload ? ( - - ) : null} - {busyLabel ? ( - - {busyLabel} - - ) : null} -
-
-
+
+ +
); } @@ -227,13 +133,90 @@ export default function TemplateLibraryView({ clearNotice, } = controller; + const gridRef = useRef(null); + const viewportRef = useRef(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( + () => ({ + rows, + busyKind, + busyTemplateId, + onDownload: handleDownload, + onUse: handleUse, + }), + [rows, busyKind, busyTemplateId, handleDownload, handleUse], + ); return (
@@ -268,7 +251,8 @@ export default function TemplateLibraryView({
-
+ {/* 标签/运行时筛选区可独立滚动:标签数量随库量增长时不会把卡片区挤出窗口。 */} +
diff --git a/apps/ai-game-creator-shell/tests/templateLibraryGrid.test.ts b/apps/ai-game-creator-shell/tests/templateLibraryGrid.test.ts new file mode 100644 index 000000000..2ddf16993 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/templateLibraryGrid.test.ts @@ -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([]); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx b/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx index a7d2344b3..4182d8c1e 100644 --- a/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx +++ b/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx @@ -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( {}} />); @@ -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( {}} />, ); 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( { />, ); - 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') diff --git a/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md b/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md index 09cba6c0e..2b5795bb4 100644 --- a/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md +++ b/docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md @@ -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`。 diff --git a/package-lock.json b/package-lock.json index 54339f53d..4f7ddc219 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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",