「所有资源」展开态改回平铺画布:不再为每个栏目渲染标题栏
Project CI / Repository checks (pull_request) Successful in 2m50s
Project CI / Frontend tests (pull_request) Successful in 3m19s
Project CI / Backend tests (pull_request) Successful in 7m23s
Project CI / Native shell tests (pull_request) Successful in 19m4s

展开态真实栏目不再渲染栏目标题栏,整个场景只留 all 那一条钉在视口上、带「资源总览」收起入口的标题栏

原因是分带只为有可见资源的栏目生成,空栏目拿不到标题栏矩形后全部回落到世界原点叠成一摞(空项目必现,现场是 7 条)

ResourceBookSceneCategoryPlan 新增 titlebar 字段,渲染层按它决定是否产出标题栏节点

分带去掉标题栏横带的高度预留与矩形换算,删除 RESOURCE_BOOK_ALL_BAND_TITLE_HEIGHT 与 resourceBookAllTitlebarRects

拖动写回口径不变(resourceBookAllBandLocalPoint 一行未改),零 CSS 变更

resourceBookLayout.test 与 appSurface 两条「所有资源」用例加反向守卫;变异验证改回旧行为时两条同时红(got 7)

decision-log 与 pitfalls 记这次倒因为「缺一路坐标输入时不要静默回落到原点」
This commit is contained in:
2026-09-12 09:38:03 +08:00
parent 5b62d1e4ef
commit 3f7e41e466
7 changed files with 114 additions and 115 deletions
@@ -165,7 +165,6 @@ import {
buildResourceBookScenePlan,
resourceBookAllBandLocalPoint,
type ResourceBookAllLayout,
resourceBookAllTitlebarRects,
type ResourceBookOverviewRect,
resourceBookSceneCardKey,
resourceBookSceneCardLayoutSignature,
@@ -1176,26 +1175,32 @@ function ResourceBookScene({
: undefined;
return (
<Fragment key={group.category}>
<div
className={`game-resource-book-scene-titlebar${active ? ' is-active' : ''}`}
data-resource-book-category={group.category}
style={
{
left: `${titleLayout.left}px`,
top: `${titleLayout.top}px`,
width: titleLayout.width,
opacity: titleLayout.opacity,
transform: titlebarTransform,
transformOrigin: active ? '0 0' : undefined,
} as CSSProperties
}
>
<ResourceBookTitleBar
category={group.category}
countLabel={`${group.resourceCount}`}
onCollapse={active ? onCollapse : undefined}
/>
</div>
{/*
**** `all`
`ResourceBookSceneCategoryPlan.titlebar`
*/}
{group.titlebar ? (
<div
className={`game-resource-book-scene-titlebar${active ? ' is-active' : ''}`}
data-resource-book-category={group.category}
style={
{
left: `${titleLayout.left}px`,
top: `${titleLayout.top}px`,
width: titleLayout.width,
opacity: titleLayout.opacity,
transform: titlebarTransform,
transformOrigin: active ? '0 0' : undefined,
} as CSSProperties
}
>
<ResourceBookTitleBar
category={group.category}
countLabel={`${group.resourceCount}`}
onCollapse={active ? onCollapse : undefined}
/>
</div>
) : null}
{group.cards.map((card) => {
const expanded = card.presentation === 'child';
const cardVisible =
@@ -2307,20 +2312,6 @@ export default function ProjectDevelopmentView({
resourcePositionById,
visibleResourcesByCategory,
]);
/**
* "按矩形钉标题栏"
*
*/
const resourceBookAllTitlebarRectMap = useMemo(
() => resourceBookAllTitlebarRects(resourceBookAllLayout),
[resourceBookAllLayout],
);
/**
*
*/
const resourceBookSceneRects = resourceBookOpensAllResources
? resourceBookAllTitlebarRectMap
: resourceBookOverviewRects;
/**
*
*/
@@ -2990,7 +2981,7 @@ export default function ProjectDevelopmentView({
state: resourceBookState,
visibleCategoryOrder: resourceBookSceneCategoryOrder,
resourcesByCategory: resourceBookSceneResourcesByCategory,
overviewRects: resourceBookSceneRects,
overviewRects: resourceBookOverviewRects,
positions: resourcePositionById,
cardSizes: resourceCardSizeByResourceId,
visibleResourceIds,
@@ -3010,8 +3001,8 @@ export default function ProjectDevelopmentView({
resourceBookAllLayout,
resourceBookCategoryViewCenters,
resourceBookExitingCards,
resourceBookOverviewRects,
resourceBookSceneCategoryOrder,
resourceBookSceneRects,
resourceBookSceneResourcesByCategory,
resourceBookState,
resourceCardSizeByResourceId,
@@ -3022,8 +3013,8 @@ export default function ProjectDevelopmentView({
],
);
/**
* `is-measuring`
*
*
* `is-measuring`
*/
const resourceBookOverviewReady = useMemo(
() =>
@@ -3037,7 +3028,7 @@ export default function ProjectDevelopmentView({
const resourceBookMotionSignatures = useMemo(() => {
const signatures = new Map<string, string>();
for (const group of resourceBookScenePlan) {
const rect = resourceBookSceneRects.get(group.category);
const rect = resourceBookOverviewRects.get(group.category);
signatures.set(
resourceBookSceneTitleKey(group.category),
group.titlebarActive
@@ -3049,7 +3040,7 @@ export default function ProjectDevelopmentView({
}
}
return signatures;
}, [resourceBookScenePlan, resourceBookSceneRects]);
}, [resourceBookScenePlan, resourceBookOverviewRects]);
// 拖拽中的卡片只跟指针走,不参与布局 FLIP。
const resourceBookFrozenMotionKeys = useMemo(() => {
const keys = new Set<string>();
@@ -6248,7 +6239,7 @@ export default function ProjectDevelopmentView({
<ResourceBookScene
plan={resourceBookScenePlan}
resourceBookState={resourceBookState}
overviewRects={resourceBookSceneRects}
overviewRects={resourceBookOverviewRects}
overviewReady={resourceBookOverviewReady}
visibleResourceIds={visibleResourceIds}
viewport={activeResourceCanvasViewport}
@@ -70,13 +70,12 @@ export function groupResourceBookStacks(
/**
* 「所有资源」展开态里一个栏目的分带。
*
* `titlebarTop` 是**标题栏**在世界坐标里的顶边,`originX / originY` 是**卡片空间原点**
* 栏目内局部坐标加它才得到世界坐标`resourceBookChildCardLayout` 的产物仍是局部坐标)。
* 两者分开是因为标题栏要占一条不被卡片压住的横带
* `originX / originY` 是**卡片空间原点**栏目内局部坐标加它才得到世界坐标
* `resourceBookChildCardLayout` 的产物仍是局部坐标)。展开态只铺卡片、不出栏目标题栏
* (见 `ResourceBookSceneCategoryPlan.titlebar`),所以这里没有任何标题栏几何
*/
export type ResourceBookAllBand = {
category: ResourceBookCategory;
titlebarTop: number;
originX: number;
originY: number;
width: number;
@@ -90,21 +89,20 @@ export type ResourceBookAllLayout = {
bounds: { x: number; y: number; width: number; height: number };
};
/** 相邻两条分带之间的竖直留白(上一条的卡片底边到下一条的标题栏顶边)。 */
/** 相邻两条分带之间的竖直留白(上一带最后一张卡到下一条带第一张卡)。 */
export const RESOURCE_BOOK_ALL_BAND_GAP = 48;
/**
* 分带标题栏的高度。取 `.game-resource-book-scene-titlebar .game-resource-book-titlebar`
* 的 `min-height`,让展开态的标题栏与总览/栏目态那一条同高,不为它另立一套尺寸。
*/
export const RESOURCE_BOOK_ALL_BAND_TITLE_HEIGHT = 42;
/**
* 「所有资源」展开态的分带几何:沿用总览的栏目顺序,每个**有可见资源**的栏目纵向占一带。
*
* 卡片坐标是栏目内局部坐标(见 `resourceCanvasSectionExtent`),铺进同一张画布前必须各给
* 一个带原点,否则各栏目全叠在原点。带宽高取可见资源的 extent:被筛掉的栏目不占屏,
* 与总览/旧网格页"只保留有资源的栏目"同口径。
* 与总览"只保留有资源的栏目"同口径。
*
* 带内只有卡片,没有标题栏横带:展开态是"一张平铺画布",唯一的标题栏是钉在视口上的
* `all` 那一条(见 `buildResourceBookScenePlan`)。因此带高就是卡片 extent,带间距就是
* `RESOURCE_BOOK_ALL_BAND_GAP`。没有可见资源的栏目不产生带 —— 这一点是硬约束:
* 曾经"每条带都配一条标题栏"时,空栏目拿不到带矩形,标题栏会全部回落到世界原点叠在一起。
*
* 这里的产物只描述几何,不参与任何持久化:落盘仍只有栏目内局部坐标。
*/
@@ -144,14 +142,9 @@ export function buildResourceBookAllLayout({
}),
cardSizes,
);
const titlebarTop = cursorTop;
const originY =
titlebarTop +
RESOURCE_BOOK_ALL_BAND_TITLE_HEIGHT +
RESOURCE_BOOK_ALL_BAND_GAP / 2;
const originY = cursorTop;
const band: ResourceBookAllBand = {
category,
titlebarTop,
originX: 0,
originY,
width: extent.width,
@@ -161,9 +154,9 @@ export function buildResourceBookAllLayout({
bandByCategory.set(category, band);
minX = Math.min(minX, extent.x);
maxX = Math.max(maxX, extent.x + extent.width);
minY = Math.min(minY, titlebarTop);
minY = Math.min(minY, originY);
maxY = Math.max(maxY, originY + extent.height);
cursorTop = originY + extent.height + RESOURCE_BOOK_ALL_BAND_GAP / 2;
cursorTop = originY + extent.height + RESOURCE_BOOK_ALL_BAND_GAP;
}
return {
bands,
@@ -177,28 +170,6 @@ export function buildResourceBookAllLayout({
};
}
/**
* 分带 → 场景标题栏矩形。
*
* 展开态复用总览态"按矩形把栏目标题栏钉在世界坐标上"的同一条渲染路径,只是矩形来自分带,
* 而不是总览缩略卡的 DOM 测量;`ResourceBookScene` 不需要为这一态知道任何新概念。
*/
export function resourceBookAllTitlebarRects(
layout: ResourceBookAllLayout,
): Map<ResourceBookTarget, ResourceBookOverviewRect> {
return new Map(
layout.bands.map((band) => [
band.category as ResourceBookTarget,
{
left: band.originX,
top: band.titlebarTop,
width: band.width,
height: RESOURCE_BOOK_ALL_BAND_TITLE_HEIGHT,
},
]),
);
}
/**
* 展开态的世界坐标 → 栏目内局部坐标(**落盘前必须走的唯一换算**)。
*
@@ -364,6 +335,14 @@ export type ResourceBookSceneCardPlan = {
export type ResourceBookSceneCategoryPlan = {
category: ResourceBookTarget;
resourceCount: number;
/**
* 这一组要不要渲染栏目标题栏节点。
*
* 展开态只有 `all` 那一条(钉在视口上、带「资源总览」收起入口):展开态是**一张平铺
* 画布**,真实栏目只铺卡片。总览态与栏目态每一组都要(总览态靠它承载计数徽标,
* 栏目态靠它承载当前栏目与返回入口)。
*/
titlebar: boolean;
titlebarActive: boolean;
titlebarOpacity: number;
cards: ResourceBookSceneCardPlan[];
@@ -377,7 +356,8 @@ export type ResourceBookSceneCategoryPlan = {
* - 返回总览时源栏目未回到摞上的卡片按子画布布局淡出;
* - 搜索/排序导致的卸载由 `exitingCards` 保留一帧,用同一套淡出撤下。
* - 「所有资源」组在总览态只出栏目标题栏(计数即全量资源数),卡片本体仍由各栏目摞承载;
* 在**展开态**则反过来:各真实栏目铺满自己分带里的全部卡片`all` 只留标题栏与计数。
* 在**展开态**则反过来:各真实栏目自己分带里的全部卡片平铺在画布上(**不出标题栏**),
* `all` 只留钉住的标题栏与计数。
*/
export function buildResourceBookScenePlan({
state,
@@ -453,7 +433,7 @@ export function buildResourceBookScenePlan({
const categoryResources = resourcesByCategory.get(category) ?? [];
/**
* 展开态:`all` 只出标题栏与全量计数(`titlebarActive` 让它钉在视口上,并带上
* 「资源总览」收起入口);真实栏目把全部卡片铺在自己那条分带里。
* 「资源总览」收起入口);真实栏目把全部卡片铺在自己那条分带里**不渲染标题栏**
*
* 真实栏目这一支仍然按 `presentation === 'child'` 取卡,而不是"展开态就一定出卡":
* "能出卡的判定"只有 `resourceBookCategoryCardPresentation` 一处,避免两边口径分叉。
@@ -463,6 +443,7 @@ export function buildResourceBookScenePlan({
return {
category,
resourceCount: categoryResources.length,
titlebar: true,
titlebarActive: true,
titlebarOpacity: 1,
cards,
@@ -485,8 +466,9 @@ export function buildResourceBookScenePlan({
return {
category,
resourceCount: categoryResources.length,
titlebar: false,
titlebarActive: false,
titlebarOpacity: 1,
titlebarOpacity: 0,
cards,
};
}
@@ -575,6 +557,7 @@ export function buildResourceBookScenePlan({
return {
category,
resourceCount: categoryResources.length,
titlebar: true,
titlebarActive: presentation === 'child',
titlebarOpacity: childLayout || state.view === 'main' ? 1 : 0,
cards,
@@ -9609,9 +9609,9 @@ export function registerProjectAgentStatusTests() {
expect(previewHostRule).toMatch(/position:\s*absolute/);
expect(previewHostRule).toMatch(/transform-origin:\s*0 0/);
// 3) 点它进入的是**同一套画本场景**,不是另一条渲染分支:每个真实栏目铺在自己的分带上,
// 卡片宿主是栏目页那一个 `.game-resource-book-scene-card`、`data-resource-book-category`
// 仍是真实栏目;`all` 自己只留钉住的标题栏与全量计数。
// 3) 点它进入的是**同一套画本场景**,不是另一条渲染分支:真实栏目的卡片平铺在同一张
// 画布上,卡片宿主是栏目页那一个 `.game-resource-book-scene-card`、
// `data-resource-book-category` 仍是真实栏目;`all` 自己只留钉住的标题栏与全量计数。
fireEvent.click(screen.getByRole('button', { name: '打开所有资源' }));
await waitFor(() =>
expect(
@@ -9651,6 +9651,14 @@ export function registerProjectAgentStatusTests() {
);
expect(hostCategories.length).toBeGreaterThan(1);
expect(hostCategories).not.toContain('all');
// 展开态是一张**平铺画布**:真实栏目不出标题栏,整个场景只留 `all` 那一条钉住的。
// 曾经"每条分带配一条标题栏"时,没有可见资源的栏目拿不到带矩形,标题栏会全部回落到
// 世界原点叠成一摞(用户报的破版就是这一摞);下面这条把它钉死。
expect(
document.querySelectorAll(
'.game-resource-book-scene-titlebar:not([data-resource-book-category="all"])',
),
).toHaveLength(0);
// 每张资源恰好一个宿主(展开态没有"第二份宿主"):条数与全量计数同源,
// 因此不会出现「标题说 N 项、画布少几张」。
expect(
@@ -9785,6 +9793,13 @@ export function registerProjectAgentStatusTests() {
expect(
document.querySelectorAll('.game-resource-book-scene-card'),
).toHaveLength(0);
// 空项目恰恰是那一摞的现场:没有任何带 ⇒ 曾经每个真实栏目的标题栏都回落到世界原点
// 叠在一起(最后画的「项目版本」浮在画布中央)。展开态只允许 `all` 那一条标题栏。
expect(
document.querySelectorAll(
'.game-resource-book-scene-titlebar:not([data-resource-book-category="all"])',
),
).toHaveLength(0);
expect(
document.querySelector('.game-resource-page-empty')?.textContent,
).toBe('没有匹配资源');
@@ -4,12 +4,11 @@ import {
buildResourceBookAllLayout,
buildResourceBookScenePlan,
groupResourceBookStacks,
RESOURCE_BOOK_ALL_BAND_TITLE_HEIGHT,
RESOURCE_BOOK_ALL_BAND_GAP,
RESOURCE_BOOK_OVERVIEW_STACK_LIMIT,
type ResourceBookAllBand,
resourceBookAllBandLocalPoint,
type ResourceBookAllLayout,
resourceBookAllTitlebarRects,
resourceBookOverviewCardLayout,
type ResourceBookOverviewRect,
resourceBookSceneCardKey,
@@ -421,7 +420,7 @@ describe('buildResourceBookScenePlan', () => {
['unclassified', resources],
['scene', [sceneResource]],
]),
overviewRects: resourceBookAllTitlebarRects(allLayout),
overviewRects: new Map<ResourceBookTarget, ResourceBookOverviewRect>(),
positions: allPositions,
cardSizes: allCardSizes,
visibleResourceIds: new Set(allResources.map((item) => item.id)),
@@ -439,6 +438,7 @@ describe('buildResourceBookScenePlan', () => {
)!;
// `all` 一张卡都不挂:卡片由各真实栏目承载,否则同一张卡会挂两份、FLIP 键互相顶掉。
expect(allGroup.cards).toEqual([]);
expect(allGroup.titlebar).toBe(true);
expect(allGroup.titlebarActive).toBe(true);
expect(allGroup.titlebarOpacity).toBe(1);
expect(allGroup.resourceCount).toBe(allResources.length);
@@ -449,9 +449,11 @@ describe('buildResourceBookScenePlan', () => {
expect(artGroup.cards.every((card) => card.presentation === 'child')).toBe(
true,
);
// 展开态的栏目标题栏不钉在视口上,但要可见(它随分带一起被视口变换)
// 展开态的真实栏目**不出标题栏**:那一页是一张平铺画布,标题栏只有 `all` 那一条
// 曾经"每条带配一条标题栏"时,没有可见资源的栏目拿不到带矩形,标题栏会集体回落到
// 世界原点叠成一摞(用户报的正是这个)。
expect(artGroup.titlebar).toBe(false);
expect(artGroup.titlebarActive).toBe(false);
expect(artGroup.titlebarOpacity).toBe(1);
// 画出来的是"栏目内局部坐标 + 带原点",拖动起点与它同系(提交时再减回带原点)。
const first = artGroup.cards[0]!;
expect(first.layout.x).toBe(positions.get('art-0')!.x + band.originX);
@@ -491,7 +493,7 @@ describe('buildResourceBookScenePlan', () => {
[RESOURCE_BOOK_ALL_TARGET, resources],
['unclassified', resources],
]),
overviewRects: resourceBookAllTitlebarRects(allLayout),
overviewRects: new Map<ResourceBookTarget, ResourceBookOverviewRect>(),
state: {
view: 'main',
category: null,
@@ -550,13 +552,17 @@ describe('buildResourceBookAllLayout', () => {
it('stacks the bands downward so no two categories share one origin', () => {
const [first, second] = layout.bands;
// 标题栏横带压在卡片区之上,两个带的首尾不重叠:否则拖上面一带的卡会压到下面一带的标题。
expect(first!.titlebarTop).toBeLessThan(first!.originY);
expect(second!.titlebarTop).toBeGreaterThan(first!.originY + first!.height);
// 带里只有卡片,所以带宽从原点直接长出 extent 高;两带之间隔一条固定的留白:
// 否则拖上面一带的卡会压到下面一带的卡上。
expect(first!.originY).toBe(0);
expect(first!.height).toBeGreaterThan(0);
expect(second!.originY).toBe(
first!.originY + first!.height + RESOURCE_BOOK_ALL_BAND_GAP,
);
expect(second!.originY).toBeGreaterThan(first!.originY);
});
it('covers every titlebar and card box in its bounds', () => {
it('covers every card box in its bounds', () => {
const last = layout.bands.at(-1)!;
expect(layout.bounds.x).toBeLessThanOrEqual(0);
expect(layout.bounds.y).toBeLessThanOrEqual(0);
@@ -565,25 +571,11 @@ describe('buildResourceBookAllLayout', () => {
last.originY + last.height,
);
});
it('hands every band a titlebar rect so the scene reuses the overview titlebar path', () => {
const rects = resourceBookAllTitlebarRects(layout);
const audio = layout.bandByCategory.get('audio')!;
expect(rects.get('audio')).toEqual({
left: audio.originX,
top: audio.titlebarTop,
width: audio.width,
height: RESOURCE_BOOK_ALL_BAND_TITLE_HEIGHT,
});
expect(rects.has('character')).toBe(false);
});
});
describe('resourceBookAllBandLocalPoint', () => {
const band: ResourceBookAllBand = {
category: 'document',
titlebarTop: 0,
originX: 40,
originY: 100,
width: 620,
@@ -8469,3 +8469,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 与"历史手动坐标只读恢复、不删除、不重置、不迁移"的关系:**不冲突**。写侧只做"把这一张卡在它自己栏目里的坐标设为手动 + 新 x/y",正是栏目页拖动的同一操作(`moveResourceCanvasPosition` 只改该 `(resourceId, section)` 一条并置 `manuallyPlaced: true`);读时归并(`normalizeResourceCanvasPosition`)与"不触发 automatic 重排"的既有口径都未改动。
- 验证方式:`resourceBookAllBandLocalPoint` 两条单测(减带原点 / 栏目页与无带时原样返回);变异验证:去掉减带原点 → 用例红(实测 `{x:100,y:260}` vs 期望 `{x:100,y:160}`)。真机判据(用户可自证"同一份数据"):在展开态把某张卡拖到新位置 → 进它所属栏目页,同一张卡就在新位置;反向在栏目页拖一张 → 回展开态看它在带内同步。
- 关联文档:`docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md`S7 判据含这条往返)、`apps/ai-game-creator-shell/src/view/project-development/{useProjectResourceCanvasLayout.ts,resourceBookLayout.ts,index.tsx}`
## 2026-09-12 「所有资源」展开态去掉每栏一条标题栏:那一页是一张平铺画布,只留 `all` 那一条
- 背景:用户报「所有资源里的 UI 样式有点问题,还有,我希望这里面所有资源都平铺开,和其他普通画布一样」。真机截图(空项目、0 项)里画布中央浮着一块带阴影的「项目版本 0 项」面板,其余栏目看不见——这就是上一版"每条分带配一条标题栏"的直接后果:**分带只对"有可见资源"的栏目生成**(`buildResourceBookAllLayout``resources.length === 0 → continue`),而标题栏矩形只从分带里取(旧 `resourceBookAllTitlebarRects`),于是空栏目的标题栏拿不到矩形,全部回落到 `ResourceBookScene``rect?.left ?? 0 / rect?.top ?? 0 / width ?? 280px` 默认值 ⇒ **7 个真实栏目的标题栏在世界原点叠成一摞**,最后画的那个(栏目顺序末位)浮在最上面。空项目恰恰是必现现场:一条带都没有。
- 决策:**展开态的真实栏目不再渲染栏目标题栏**(`ResourceBookSceneCategoryPlan` 新增 `titlebar: boolean`,展开态真实栏目为 `false`),整个场景只留 `all` 那一条钉在视口上、带「资源总览」收起入口的标题栏。于是「所有资源」在结构上与栏目页完全同形:**一条钉住的标题栏 + 一张平铺的画布**(点阵底、卡片可拖、可滚轮缩放平移、可框选),不再有"每组一个浮动面板"。分带保留(它仍是"局部坐标 → 世界坐标"的唯一偏移,见 2026-09-11 那条拖动写回决策),但带内**只剩卡片**:删掉标题栏横带的高度预留(`RESOURCE_BOOK_ALL_BAND_TITLE_HEIGHT`)与它的矩形换算(`resourceBookAllTitlebarRects`),带原点直接等于游标,带间距就是 `RESOURCE_BOOK_ALL_BAND_GAP`
- 不做什么:不动总览态与栏目态(两者每一组都要标题栏:总览态靠它承载计数徽标,栏目态靠它承载当前栏目与返回入口);不动拖动写回口径(`resourceBookAllBandLocalPoint` 一行未改);不动转场层;**零 CSS 变更**(`.game-resource-book-scene-titlebar` 规则全部保留,只是展开态少渲染几个节点)。
- 验证方式:① 单测 `resourceBookLayout.test` 钉住"展开态真实栏目 `titlebar === false``all``true`",并删掉只服务旧标题栏矩形的断言与那个已不存在的导出;② appSurface 两条「所有资源」用例各加一条反向守卫 `document.querySelectorAll('.game-resource-book-scene-titlebar:not([data-resource-book-category="all"])')` 长度为 0(空项目那条正是用户现场);③ **变异验证(已实测红灯)**:把展开态真实栏目的 `titlebar` 改回 `true` → 两条守卫同时红,报 `expected … to have a length of +0 but got 7`——这个 `7` 就是七个真实栏目叠在原点的实证。
- 影响范围:`apps/ai-game-creator-shell/src/view/project-development/{resourceBookLayout.ts,index.tsx}``apps/ai-game-creator-shell/tests/{resourceBookLayout.test.ts,appSurface/project-development.suite.ts}``docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md`S7 行措辞)。
- 关联:本条**收窄**(不推翻)2026-09-11 的「所有资源」页画本场景决策:那一条定的是"同一套场景 + 同一颗 renderCard + 分带偏移",本条只改"展开态出不出栏目标题栏"。
@@ -5354,3 +5354,12 @@
- **处理**:push 前按链路逐条跑,不要只跑子集;`&&` 链在某一步失败时,**必须把失败步之后的每一步单独再跑一遍**(上列 10 步 + `git diff --check`),逐条记 exit code(取 exit code 不要接管道)。凡改动碰到 `package.json` / `.husky/` / `scripts/` 下的门禁资产,**`check:git-hooks` 是必跑项**——它钉住 hook 与 lint-staged 配置的形状,增删键都会让它以 `deepStrictEqual` 失败。
- **验证**:本次 `check:git-hooks` 因本机 Windows EBUSY 中断后,把其后各步单独跑齐:`check:spacetime-schema` / `check:production-ops` / `check:preview-deployer` / `check:maintenance-page` / `lint:eslint` / `typecheck` / `check:encoding` 全部 `exit 0` —— 即「因为前一步红而没跑到」的部分本来是全绿的;只有 `check:rustfmt` 红,且红在别人在途的 `.rs``assets.rs:519` 落在其未提交 hunk `+470,62``commands.rs:3079` 落在其未提交 hunk `+3068,58`)。变异验证:把 `*.rs``package.json` 摘掉 → `check:git-hooks` 第 1 个用例以同样的 `deepStrictEqual` operator 变红(`# fail 2`);还原(`package.json` SHA256 与改前一致)后该用例回 `ok`
- **关联**`package.json``lint` / `check:git-hooks` / `check:rustfmt``scripts/check-repository-ci.sh``scripts/git-hooks.test.mjs``.husky/pre-commit`、提交 `dd7cf401a` / `2a7bfadd7` / `15660a98b`
## 2026-09-12 「矩形从哪来」缺一路输入时,别让它静默回落到世界原点:7 条栏目标题栏在空项目里叠成一摞
- **现象**:空项目(0 项)进「所有资源」,画布正中央浮着一块带阴影的「项目版本 0 项」面板,其余六个栏目看不见——用户报"UI 样式有点问题"。它们并不是被谁隐藏,而是**七块面板叠在同一个位置**,只看得见最后渲染的那一块。
- **原因**`ResourceBookScene` 的栏目标题栏位置只有一路输入 `overviewRects.get(category)`,取不到就用 `rect?.left ?? 0 / rect?.top ?? 0 / width ?? 280px` 兜底。而展开态这一路输入来自分带矩形(旧 `resourceBookAllTitlebarRects`),分带又只对**有可见资源**的栏目生成(`buildResourceBookAllLayout``resources.length === 0 → continue`)⇒ 空栏目永远拿不到矩形,全部回落到世界原点;栏目顺序七项,最后画的「项目版本」压在最上面。空项目是必现现场(一条带都没有),用户截图正是这种。
- **识别特征**:出现的不是"某一个错位面板",而是**栏目顺序里最后一项**的名字(本例 = 栏目顺序末位「项目版本」),且它随视口平移一起动 —— 即"坐标系原点上的同类节点堆叠",而不是定位算错。
- **处理**:让"该不该有这个节点"显式化,而不是靠"矩形取不到就兜底":`ResourceBookSceneCategoryPlan``titlebar: boolean`,展开态的真实栏目为 `false`(那一页只留 `all` 一条钉住的标题栏),渲染层按它决定是否产出节点;同时删掉只服务旧形态的 `RESOURCE_BOOK_ALL_BAND_TITLE_HEIGHT` / `resourceBookAllTitlebarRects`。**`?? 0` 兜底本身不是错,错在它同时承担"没有布局信息"和"布局信息恰好是原点"两种语义**——凡是一个坐标有多路来源(测量 / 计算 / 静态),缺某一路时要么显式不渲染,要么让缺路成为可观测的信号,不要静默给默认值。
- **验证**appSurface 两条「所有资源」用例加反向守卫 `querySelectorAll('.game-resource-book-scene-titlebar:not([data-resource-book-category="all"])')` 长度为 0(含空项目那条);变异验证:把展开态真实栏目的 `titlebar` 改回 `true` → 两条同时红,报 `expected … to have a length of +0 but got 7`(7 = 七个真实栏目,实证是"一摞"而不是"一个错位框")。
- **关联**`apps/ai-game-creator-shell/src/view/project-development/{resourceBookLayout.ts,index.tsx}``apps/ai-game-creator-shell/tests/{resourceBookLayout.test.ts,appSurface/project-development.suite.ts}``decision-log.md` 2026-09-12 条。
@@ -46,7 +46,7 @@
| 步骤 | 操作 | 期望结果 | 对应 PRD 条款 | 怎么判"过了" | 已知例外 / 未做项 |
| --- | --- | --- | --- | --- | --- |
| **S7** 栏目导航 | 点总览第 0 张「所有资源」卡 → 栏目缩略卡片进栏目 → 页内底部「下一页」 | 「所有资源」是入口(第 0 张);进入后**与栏目页是同一套画本场景**——卡片宿主同为 `.game-resource-book-scene-card`、每张卡可拖、可滚轮缩放平移,各栏目按**竖向分带**铺在同一张画布上;全部栏目(含空栏目)都可到达;左侧悬浮栏目大纲导航已删除;滚轮不切栏目 | L56、L512、L539541 | `[data-resource-book-view="main"\|"child"]`、当前栏目 `.game-resource-book-scene-titlebar.is-active[data-resource-book-category]`(展开态这一条是 `data-resource-book-category="all"`,带「资源总览」收起入口)、卡片宿主 `.game-resource-book-scene-card[data-resource-book-category="<真实栏目>"]`(每张资源**恰好一个**宿主)、`[data-resource-book-section-scroll]`。**旧的独立网格判据已随分支删除**:`[data-resource-book-all-page="true"]` / `[data-resource-book-all-host="true"]` / `.game-resource-all-*` 都不应再出现。拖动的判据:在展开态拖一张卡,再进它所属栏目页看同一张卡**落在新位置**(两页共用同一份 `resource-layouts/{dependency,type}.json` 的栏目内局部坐标) | 「所有资源」卡渲染真实预览(`game-resource-book-preview-card`**已提交**`ea9668e4a`);旧构建只有占位摞。展开态的视口是内存态(不落盘),只有卡片坐标落盘 |
| **S7** 栏目导航 | 点总览第 0 张「所有资源」卡 → 栏目缩略卡片进栏目 → 页内底部「下一页」 | 「所有资源」是入口(第 0 张);进入后**与栏目页是同一套画本场景**——卡片宿主同为 `.game-resource-book-scene-card`、每张卡可拖、可滚轮缩放平移,各栏目按**竖向分带**铺在同一张画布上(展开态**不出每栏一条标题栏**,整个场景只有 `all` 那一条钉在视口上的标题栏,与栏目页"一条钉住的标题栏 + 一张平铺画布"同形);全部栏目(含空栏目)都可到达;左侧悬浮栏目大纲导航已删除;滚轮不切栏目 | L56、L512、L539541 | `[data-resource-book-view="main"\|"child"]`、当前栏目 `.game-resource-book-scene-titlebar.is-active[data-resource-book-category]`(展开态这一条是 `data-resource-book-category="all"`,带「资源总览」收起入口**展开态 `data-resource-book-category``all` 的标题栏一个都不该有**——空栏目曾因拿不到分带矩形而集体回落到世界原点叠成一摞)、卡片宿主 `.game-resource-book-scene-card[data-resource-book-category="<真实栏目>"]`(每张资源**恰好一个**宿主)、`[data-resource-book-section-scroll]`。**旧的独立网格判据已随分支删除**:`[data-resource-book-all-page="true"]` / `[data-resource-book-all-host="true"]` / `.game-resource-all-*` 都不应再出现。拖动的判据:在展开态拖一张卡,再进它所属栏目页看同一张卡**落在新位置**(两页共用同一份 `resource-layouts/{dependency,type}.json` 的栏目内局部坐标) | 「所有资源」卡渲染真实预览(`game-resource-book-preview-card`**已提交**`ea9668e4a`);旧构建只有占位摞。展开态的视口是内存态(不落盘),只有卡片坐标落盘 |
| **S8** 搜索 | 点右下角 `搜索资源` 按钮或按 `Ctrl/Cmd+F` → 输入 → Esc | 浮层收起**不清搜索条件**;`type=search` 原生 Esc 清空被 `preventDefault` 挡掉;存在搜索时 Dock 按钮高亮 | L55、L322(搜索只隐藏卡片、不重排坐标) | `button[aria-label="搜索资源"]``aria-expanded``title="搜索资源(Ctrl/Cmd+F"``.is-active`;输入框 `input[type=search][aria-label="搜索项目资源"]`,其 `id` 等于按钮的 `aria-controls` | 匹配字段 = **名称 / 路径 / 媒体类型 / 任务标题**,不含 `sourceLabel` |
| **S9** 选择与多选 | 单击卡片 → `Shift`/`Ctrl`/`Meta` 再点 → 空白处拖拽框选 | 点卡 = 选中并在卡片旁浮出工具条;三种修饰键等效追加;框选可见选框 | L51、L6162、L318319 | `data-resource-card-id` + class 含 `is-selected` + `aria-pressed="true"`;浮出容器 `role="toolbar"`(图片 `aria-label="图片工具栏"`,音频 `"素材工具栏"` | ⚠️ **PRD L61–62 要求的「打开详情」按钮在代码中不存在**(测试显式断言为 `null`),按 Issue #309 C3「取消资源详情面板与全屏编辑路由」判,不要判为缺失;真正入口是 `aria-label="选中资源:<栏目> <名>"` 按钮。**只读信息浮层已回归**(2026-09-11,工具条「信息」动作,见 `decision-log`):**C3 取消的仍只是可编辑详情面板与全屏编辑路由** |
| **S10** 快速编辑(图片) | 选中 PNG/JPEG/WEBP 卡 → 工具条「快速编辑」→ 输入 → 提交(提示词下方另有「AI 润色」) | 先 `normalize_local_project_raster_resource`,再 `derive_local_project_resource(editKind='image-reference')` → 服务端 `/api/editor/images/edits`;**产出一张新素材**,源素材与源文件不变,新资源带 `referenceResourceIds` 血缘 | L123、L193、L476478、L557#309 C3/C10 | 提交后 `assets[]` +1、卡片 +1 且 `data-resource-card-id` 为新 id;**源卡仍在且内容未变**;工具条真渲染的只有 `quick-edit``download`;「AI 润色」`aria-label="AI 润色"` + `aria-busy`,成功回填并出现「恢复原文」,失败保留原文并给「AI 润色失败,可重试」 | ① 服务端白名单 17 项(`editor_project.rs:4120-4141`),`character-animation`/`video`/`audio`/`document`/`code` 等**仍会 400**;② 工具条 7 个动作(重绘 / 裁剪扩图 / 去背景 / 像素完美 / 切图集 / 提取 UI 素材 / 角色动画)按 opt-in **不渲染**;③ 润色结果超 `resourceEditPromptMaxLength` 会**截断并提示「已按长度上限截断」**;④ 提示词变了(改写或润色回填)必须换 `operationId`/幂等键,否则 Rust `request_fingerprint` 判「已绑定到不同资源编辑请求」 |