diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 756394e86..eff4c5325 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -5770,7 +5770,14 @@ iframe.preview-frame { background: transparent; } -.game-resource-book-scene-card { +/* + * 卡片宿主节点。`--preview` 是「所有资源」卡那一摞预览的宿主:几何与外观和栏目卡的 + * 宿主完全共用(它同样是 0x0 的定位包装 + `renderCard` 出的真实卡片), + * 但刻意不叫 `.game-resource-book-scene-card`——转场层按这个类名收集 FLIP 节点, + * 而同一张资源卡在它自己的栏目摞里已经是一个可选中、带记录的宿主了。 + */ +.game-resource-book-scene-card, +.game-resource-book-preview-card { position: absolute; z-index: 25; pointer-events: none; @@ -5811,12 +5818,16 @@ iframe.preview-frame { border-left-color: #cc8060; } -.game-resource-book-scene-card .game-resource-card { +.game-resource-book-scene-card .game-resource-card, +.game-resource-book-preview-card .game-resource-card { transition: box-shadow 180ms ease; } .game-resource-book-scene--main .game-resource-book-scene-card + .game-resource-card, +.game-resource-book-scene--main + .game-resource-book-preview-card .game-resource-card { border-radius: 10px; box-shadow: 0 5px 12px rgb(112 70 52 / 10%); @@ -6148,6 +6159,8 @@ iframe.preview-frame { .game-resource-book-scene-titlebar, .game-resource-book-scene-card, .game-resource-book-scene-card .game-resource-card, + .game-resource-book-preview-card, + .game-resource-book-preview-card .game-resource-card, .game-resource-book-thumbnail { transition: none !important; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 029ff4fb1..9644a0850 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -152,7 +152,6 @@ import { import { buildResourceBookAllResourcesSections, buildResourceBookScenePlan, - RESOURCE_BOOK_OVERVIEW_STACK_LIMIT, type ResourceBookAllResourcesSection, type ResourceBookOverviewRect, resourceBookSceneCardKey, @@ -946,10 +945,14 @@ function ResourceBookThumbnail({ return groups; }, new Map()), ); - // 「所有资源」卡的正文只保留同一套占位摞结构:全量资源的张数由标题栏计数徽标表达, - // 卡片本体不进这一格(同一张资源卡已经在它所属栏目的摞里挂载过)。 + /** + * 「所有资源」卡的正文只保留**一摞**占位:它的预览摞也是跨类型的一摞 + * (见 `buildResourceBookScenePlan`),占位数量必须和真实摞数量对齐, + * 卡片高度才落在与栏目卡同一条口径上。真实卡片本体由画本场景铺在占位之上, + * 这一格里的占位始终是空的(栏目卡也一样)。 + */ const bodyStacks = isResourceBookAllTarget(category) - ? stacks.slice(0, RESOURCE_BOOK_OVERVIEW_STACK_LIMIT) + ? stacks.slice(0, 1) : stacks; return ( @@ -1253,29 +1256,49 @@ function ResourceBookScene({ (expanded || (card.presentation === 'overview' && resourceBookState.view === 'main')); + const hostStyle = { + left: 0, + top: 0, + width: 0, + height: 0, + opacity: cardVisible ? 1 : 0, + // 总览摞里下标小的卡片压在上层,保证最上面那张是清晰的。 + zIndex: expanded + ? undefined + : card.stackCount - card.stackIndex, + pointerEvents: + expanded && visibleResourceIds.has(card.resource.id) + ? 'auto' + : 'none', + } as CSSProperties; + /** + * 「所有资源」卡的预览只画不接管交互,也不参与转场。 + * + * 同一张资源卡在它自己的栏目摞里已经是一个可选中、带 FLIP 记录的节点; + * 预览这一份刻意用另一个宿主类(几何与 `.game-resource-book-scene-card` + * 共用同一条规则),这样转场层不会看到同一个 `card:*` 键有两个宿主—— + * 那会让两条记录互相顶掉,动画落到错误的那一份上。 + */ + if (isResourceBookAllTarget(group.category)) { + return ( + + ); + } return (
{renderCard(card.resource, card.layout)}
@@ -2824,6 +2847,9 @@ export default function ProjectDevelopmentView({ const previousKeys = resourceBookPlanKeysRef.current; const currentKeys = new Map(); for (const group of resourceBookScenePlan) { + // 「所有资源」卡的预览不参与转场:它只是同一张资源卡的第二个宿主节点, + // 一旦进了淡出集合,回来时会被按子画布坐标重新贴到画布别处。 + if (isResourceBookAllTarget(group.category)) continue; for (const card of group.cards) { // 只跟踪"当前看得见"的卡片:转场期间正在淡出的节点不需要再补一次淡出。 const visible = diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts index e04641de0..2d46a21bf 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceBookLayout.ts @@ -42,6 +42,14 @@ const overviewStackRotations = [0.5, -1.5, 2.5] as const; export const RESOURCE_BOOK_OVERVIEW_STACK_LIMIT = overviewStackRotations.length; +/** + * 「所有资源」卡那一摞的类型标签占位值。 + * + * 这一摞跨类型,没有真实类型名可用;它只在总览摞的分组里当 key, + * 不参与任何持久化或跨端口径。 + */ +const RESOURCE_BOOK_ALL_STACK_LABEL = 'all'; + export function groupResourceBookStacks( resources: readonly ProjectResource[], ): ResourceBookStack[] { @@ -265,13 +273,23 @@ export function buildResourceBookScenePlan({ const rect = overviewRects.get(category); const cards: ResourceBookSceneCardPlan[] = []; const categoryResources = resourcesByCategory.get(category) ?? []; - const stacks = groupResourceBookStacks(categoryResources); + /** + * 「所有资源」卡的总览摞只铺一列。 + * + * 它拿的是全量投影(与计数徽标同一份),按类型分列会一口气铺出十几列、在总览里 + * 立刻失控;这里把它当成一摞,交给与栏目卡同一个选取函数:没有视图中心时该函数 + * 直接按投影顺序取前 `RESOURCE_BOOK_OVERVIEW_STACK_LIMIT` 张,和栏目卡每摞的上限同口径。 + */ + const stacks = isResourceBookAllTarget(category) + ? [ + [ + RESOURCE_BOOK_ALL_STACK_LABEL, + categoryResources, + ] as ResourceBookStack, + ] + : groupResourceBookStacks(categoryResources); const childLayout = presentation === 'child'; - // 「所有资源」的卡片在各栏目摞里已经渲染过;这里再铺一遍会让同一张资源卡在 - // 总览里挂载两份(`data-resource-card-id` 查询与 FLIP 的 `card:*` 键都会撞车)。 - const rendersCards = - cardsReady && presentation && !isResourceBookAllTarget(category); - if (rendersCards) { + if (cardsReady && presentation) { stacks.forEach(([, items], stackColumn) => { // 总览只铺有限张,数量由栏目标题栏的“N 项”表达;子画布渲染全部资源。 const stackItems = childLayout diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index d8477f256..6243a9824 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -8993,6 +8993,50 @@ export function registerProjectAgentStatusTests() { allResourcesCount, ); + // 2b) 「所有资源」卡必须真的铺出预览:与栏目卡同一颗卡片渲染器(`.game-resource-card`), + // 条数按总览摞上限取全量投影的前 N 张;计数徽标与实际喂进去的资源数同源, + // 因此不会出现「标题说 N 项、预览 0 张」。 + const allPreviewCards = () => + Array.from( + document.querySelectorAll( + '.game-resource-book-preview-card[data-resource-book-category="all"] .game-resource-card', + ), + ); + await waitFor(() => + expect( + allPreviewCards().map((card) => card.dataset.resourceCardId), + ).toEqual( + allResources + .slice(0, RESOURCE_BOOK_OVERVIEW_STACK_LIMIT) + .map((resource) => resource.id), + ), + ); + expect(allPreviewCards()).toHaveLength( + Math.min(RESOURCE_BOOK_OVERVIEW_STACK_LIMIT, allResourcesCount), + ); + // 同一张卡的结构也一致:预览摞里是完整卡片(视觉体 + 选中按钮),不是另一套占位。 + expect( + allPreviewCards()[0]?.querySelector('.game-resource-card-visual'), + ).not.toBeNull(); + expect( + allPreviewCards()[0]?.querySelector('.game-resource-card-select'), + ).not.toBeNull(); + // 预览是只读的第二份宿主:不进无障碍树(否则同一张卡会被读两遍)。 + expect( + allPreviewCards()[0]?.closest('[aria-hidden="true"]'), + ).not.toBeNull(); + // 宿主几何与栏目卡共用同一条规则,视觉上不是另写一套。 + const previewStyles = readFileSync( + resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'), + 'utf8', + ); + const previewHostRule = styleRuleBody( + previewStyles, + '\\.game-resource-book-preview-card', + ); + expect(previewHostRule).toMatch(/position:\s*absolute/); + expect(previewHostRule).toMatch(/transform-origin:\s*0 0/); + // 3) 点它进入的视图包含来自多个栏目的资源,而不是某一个栏目。 fireEvent.click(screen.getByRole('button', { name: '打开所有资源' })); await waitFor(() => @@ -9026,6 +9070,91 @@ export function registerProjectAgentStatusTests() { expect(readCountBadge('.game-resource-all-page')).toBe(allResourcesCount); }); + /** + * 空项目下「所有资源」卡不能破版:沿用栏目卡同一条空态(`暂无资源`), + * 预览摞一张都不挂、计数为 0 项,卡片结构与入口行仍在。 + */ + it('keeps the all-resources tile on the shared empty state for an empty project', async () => { + const manifest = createGameCreationAppManifest( + 'workbench-all-resources-empty', + '空资源项目', + ); + manifest.assets = []; + manifest.versions = []; + const allResources = projectResourcesFromReadModels(manifest, [], []); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: args?.expectedProjectId, + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: args?.expectedProjectId, + mode: args?.mode, + revision: 1, + positions: args?.positions, + updatedAt: 1, + }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + render( + React.createElement(ProjectDevelopmentView, { + projectName: manifest.name, + projectPath: '/tmp/workbench-all-resources-empty', + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }), + ); + + expect(allResources).toHaveLength(0); + const allTile = await waitFor(() => { + const tile = screen + .getByRole('button', { name: '打开所有资源' }) + .closest('.game-resource-book-thumbnail'); + expect(tile).not.toBeNull(); + return tile!; + }); + + expect( + allTile.querySelector('.game-resource-book-thumbnail-body')?.textContent, + ).toBe('暂无资源'); + expect( + allTile.querySelector('.game-resource-book-thumbnail-footer') + ?.textContent, + ).toContain('打开所有资源'); + expect( + document.querySelectorAll('.game-resource-book-preview-card'), + ).toHaveLength(0); + expect( + document.querySelector( + '.game-resource-book-scene-titlebar[data-resource-book-category="all"] .game-resource-book-titlebar-meta small', + )?.textContent, + ).toBe('0 项'); + }); + /** * 「资源总览」大标题旁边不再有任何副标题。 * diff --git a/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts b/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts index 22b8a309a..449b7ed23 100644 --- a/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts @@ -289,11 +289,13 @@ describe('buildResourceBookScenePlan', () => { }); /** - * 资源总览第 0 张「所有资源」卡走的是同一条渲染管线:它有栏目标题栏(计数即全量 - * 资源数),但正文不铺卡片——同一张资源卡在各栏目摞里已经渲染过,再铺一遍会让 - * `data-resource-card-id` 与 FLIP 的 `card:*` 键在同一份计划里出现两次。 + * 资源总览第 0 张「所有资源」卡走同一条渲染管线:标题栏的计数是全量资源数, + * 预览摞则是全量投影按稳定顺序的前 N 张,且只铺一列(跨类型的一摞)。 + * + * 它是同一张资源卡的第二份宿主(各栏目摞里仍各有一份),所以这里既钉预览的来源与 + * 上限,也钉各栏目摞没有被改动。 */ - it('reports the full count for the all-resources tile without duplicating cards', () => { + it('puts the first overview-stack slice of the full projection on the all-resources tile', () => { const documentResource = resource('design-0', 'document'); const allResources = [...resources, documentResource]; const allPositions = new Map([ @@ -335,24 +337,51 @@ describe('buildResourceBookScenePlan', () => { (group) => group.category === RESOURCE_BOOK_ALL_TARGET, )!; expect(allGroup.resourceCount).toBe(allResources.length); - expect(allGroup.cards).toHaveLength(0); expect(allGroup.titlebarActive).toBe(false); expect(allGroup.titlebarOpacity).toBe(1); + expect(allGroup.cards.map((card) => card.resource.id)).toEqual( + allResources + .slice(0, RESOURCE_BOOK_OVERVIEW_STACK_LIMIT) + .map((item) => item.id), + ); + // 跨类型也只铺一列:不然 66 项会一口气铺出十几列。 + expect( + Array.from(new Set(allGroup.cards.map((card) => card.stackColumn))), + ).toEqual([0]); + expect( + allGroup.cards.every((card) => card.presentation === 'overview'), + ).toBe(true); - // 各栏目自己的摞照旧,且整份计划里没有任何一张资源卡被挂两次。 - const cardKeys = plan - .flatMap((group) => group.cards) - .map((card) => card.key); - expect(new Set(cardKeys).size).toBe(cardKeys.length); + // 各栏目自己的摞照旧:预览是叠加的一份,不改其它栏目卡。 expect( plan .find((group) => group.category === 'document')! - .cards.map((card) => card.key), - ).toEqual([resourceBookSceneCardKey('design-0')]); + .cards.map((card) => card.resource.id), + ).toEqual(['design-0']); expect( plan.find((group) => group.category === 'unclassified')!.cards.length, ).toBeGreaterThan(0); }); + + it('keeps the all-resources tile preview empty for an empty projection', () => { + const plan = buildResourceBookScenePlan({ + ...base, + visibleCategoryOrder: [RESOURCE_BOOK_ALL_TARGET], + resourcesByCategory: new Map([ + [RESOURCE_BOOK_ALL_TARGET, []], + ]), + overviewRects: new Map([ + [RESOURCE_BOOK_ALL_TARGET, rect], + ]), + visibleResourceIds: new Set(), + categoryViewCenters: new Map(), + state: { view: 'main', category: null, phase: 'idle', token: 0 }, + }); + + expect(plan).toHaveLength(1); + expect(plan[0]!.resourceCount).toBe(0); + expect(plan[0]!.cards).toHaveLength(0); + }); }); describe('buildResourceBookAllResourcesSections', () => {