资源总览「所有资源」卡补上资源预览

- 现象与归因:「所有资源」卡的预览区一直是空的,原因是我在 1cc9a2381 里加的闸 `!isResourceBookAllTarget(category)`(当时为避开"同一张资源卡挂两份")让它一张卡片计划都不生成;预览卡从来只由画本场景注入,占位摞本身不含卡片
- 改为真正生成卡片计划:喂进去的仍是全量投影(与标题栏计数徽标同一份口径),但当成一摞交给栏目卡同一个选取函数 `selectResourceBookOverviewCards`——没有视图中心时按投影顺序取前 `RESOURCE_BOOK_OVERVIEW_STACK_LIMIT`(=3) 张,与栏目卡每摞上限同口径;不分类型列,避免 66 项一口气铺出十几列
- 预览用同一个 `renderCard` 与同一套 `resourceBookOverviewCardLayout` 几何渲染,唯一差别是宿主类名换成 `.game-resource-book-preview-card`:转场层按 `.game-resource-book-scene-card` 收集 FLIP 节点并以 `card:${resourceId}` 为键,同一张卡两个宿主会让两条记录互相顶掉,因此预览宿主不进收集范围(`resourceBookController` 一行未改)
- 预览宿主带 `aria-hidden` + `pointer-events: none`:无障碍树与 `getByRole` 里每张资源卡仍然只有栏目卡那一份,不会重复命中;点预览区仍走进「所有资源」页,栏目卡预览卡仍可选中
- 转场淡出集合(`resourceBookPlanKeysRef` 那段)跳过「所有资源」组,避免预览节点被当成"离开视图的卡片"按子画布坐标重新贴到画布别处
- `ResourceBookThumbnail`:该卡正文的占位摞从最多 3 摞改为对齐真实摞数的 1 摞,卡片高度回到与栏目卡同一条口径
- `styles.css`:把 `.game-resource-book-preview-card` 并进 `.game-resource-book-scene-card` 的两条既有规则(宿主几何、主态卡片圆角/阴影)与 `prefers-reduced-motion`;没有新写一套预览样式
- 断言(`project-development.suite.ts`):预览卡的 `data-resource-card-id` 序列恰等于全量投影前 3 个、条数 == `min(3, 计数徽标)`、预览里是完整卡片(视觉体 + 选中按钮)、宿主在 `aria-hidden` 子树里、宿主几何规则与栏目卡共用同一条
- 断言(新增用例):空项目下沿用既有空态「暂无资源」、预览节点 0 个、计数 `0 项`、入口行仍在,卡片不破版
- 单测(`resourceBookLayout.test.ts`):把已被新行为取代的旧断言(`all` 组不出卡片、全计划无重复卡片键)删掉,换成新合同(预览 = 全量投影前 3 张、只铺一列、各栏目摞不变),并补空投影 → 0 张
- 变异验证(两条都跑过并已还原):① 喂进去的资源改回空数组 → `AssertionError: expected +0 to be 4`;② 恢复旧闸(计数仍正确、预览为 0)→ `AssertionError: expected [] to deeply equal [ Array(3) ]`
This commit is contained in:
2026-09-11 15:37:17 +08:00
parent 6bdc8bbd93
commit ea9668e4a9
5 changed files with 256 additions and 41 deletions
+15 -2
View File
@@ -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;
}
@@ -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<string, ProjectResource[]>()),
);
// 「所有资源」卡的正文只保留同一套占位摞结构:全量资源的张数由标题栏计数徽标表达,
// 卡片本体不进这一格(同一张资源卡已经在它所属栏目的摞里挂载过)。
/**
* 「所有资源」卡的正文只保留**一摞**占位:它的预览摞也是跨类型的一摞
* (见 `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 (
<div
className="game-resource-book-preview-card"
data-resource-book-category={group.category}
aria-hidden="true"
key={card.key}
style={hostStyle}
>
{renderCard(card.resource, card.layout)}
</div>
);
}
return (
<div
className={`game-resource-book-scene-card${expanded ? ' is-expanded' : ''}`}
data-resource-book-category={group.category}
aria-hidden={!cardVisible}
key={card.key}
style={
{
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
}
style={hostStyle}
>
{renderCard(card.resource, card.layout)}
</div>
@@ -2824,6 +2847,9 @@ export default function ProjectDevelopmentView({
const previousKeys = resourceBookPlanKeysRef.current;
const currentKeys = new Map<string, ResourceBookSceneCardPlan>();
for (const group of resourceBookScenePlan) {
// 「所有资源」卡的预览不参与转场:它只是同一张资源卡的第二个宿主节点,
// 一旦进了淡出集合,回来时会被按子画布坐标重新贴到画布别处。
if (isResourceBookAllTarget(group.category)) continue;
for (const card of group.cards) {
// 只跟踪"当前看得见"的卡片:转场期间正在淡出的节点不需要再补一次淡出。
const visible =
@@ -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
@@ -8993,6 +8993,50 @@ export function registerProjectAgentStatusTests() {
allResourcesCount,
);
// 2b) 「所有资源」卡必须真的铺出预览:与栏目卡同一颗卡片渲染器(`.game-resource-card`),
// 条数按总览摞上限取全量投影的前 N 张;计数徽标与实际喂进去的资源数同源,
// 因此不会出现「标题说 N 项、预览 0 张」。
const allPreviewCards = () =>
Array.from(
document.querySelectorAll<HTMLElement>(
'.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<string, unknown>) => {
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<HTMLElement>('.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 项');
});
/**
*
*
@@ -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<ResourceBookTarget, ProjectResource[]>([
[RESOURCE_BOOK_ALL_TARGET, []],
]),
overviewRects: new Map<ResourceBookTarget, ResourceBookOverviewRect>([
[RESOURCE_BOOK_ALL_TARGET, rect],
]),
visibleResourceIds: new Set<string>(),
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', () => {