资源总览「所有资源」卡补上资源预览
- 现象与归因:「所有资源」卡的预览区一直是空的,原因是我在 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:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user