From 96971403ee10b7207f7ca58606f556af65b2198d Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 14 Sep 2026 22:36:32 +0800 Subject: [PATCH] =?UTF-8?q?AGC=20=E8=B5=84=E6=BA=90=E7=94=BB=E5=B8=83?= =?UTF-8?q?=EF=BC=9A=E5=AE=9A=E4=BD=8D=E5=88=B0=E7=B4=A0=E6=9D=90=E5=90=8E?= =?UTF-8?q?=E6=8A=8A=E8=A7=86=E5=8F=A3=E5=B1=85=E4=B8=AD=E5=88=B0=E8=AF=A5?= =?UTF-8?q?=E5=8D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因:`handleResourceSelect` 只改选中、不移动画布,而这张画布是 transform 平移的、资源卡不在任何滚动容器里;既有的 `card.scrollIntoView()` 碰不到滚动祖先,卡在视口外时用户看到的就是「定位过去了但依然见不到素材」 - 新增 `centerResourceCanvasOnResource`:读该卡在当前布局位置表里的 x/y 与卡片尺寸,按 `canvasSize / 2 − 卡中心 × scale` 求视口平移量;缩放保持不变(定位不改用户缩放预期,与 `ensureResourceBookContentVisible` 同口径) - 聚焦链在「选中新资源」那一步之后调用它;`scrollIntoView` 保留(栏目页仍有带滚动条的祖先) - 用例:新增「centers the canvas viewport on the located asset so it is actually visible」,用渲染进 DOM 的真实画布尺寸断言 `viewportX + 卡中心 × scale = 画布中心` - 文档:decision-log 补「失去焦点即收起」与「定位必须让素材真的可见」两条决策;AGC 入口矩阵同步自动收起、入口顺序与定位居中口径 验证:tsc --noEmit 通过;appSurface 全量 441 条通过;变异验证:把定位链里的居中调用摘掉,新用例变红(450 vs 400,正好是画布宽 800 的一半与旧视口之差) --- .../src/view/project-development/index.tsx | 56 ++++++++++++ .../appSurface/project-development.suite.ts | 85 +++++++++++++++++++ .../shared-memory/decision-log.md | 4 +- ...C】栏目画布底部工具栏入口矩阵-2026-09-13.md | 2 +- 4 files changed, 145 insertions(+), 2 deletions(-) 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 c990b4f47..dc964bfbe 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 @@ -4650,6 +4650,58 @@ export default function ProjectDevelopmentView({ ], ); + /** + * 把画布视口居中到某张资源卡:定位链在「选中了新资源」之后还必须让用户**看得见**它。 + * + * 这张画布是 transform 平移的,卡不在 DOM 滚动容器里,`scrollIntoView()` 只能碰到既有的 + * 带滚动条祖先,卡在视口外时它不会把卡带回来——所以视口必须由这里显式改。缩放保持不变 + * (只平移),与 `ensureResourceBookContentVisible` 的既有口径一致:定位不改用户的缩放预期。 + */ + const centerResourceCanvasOnResource = useCallback( + (resourceId: string) => { + const category: ResourceBookTarget | null = resourceBookOpensAllResources + ? RESOURCE_BOOK_ALL_TARGET + : activePageCategory; + if (!category || resourceBookView !== 'child') { + return; + } + const position = resourcePositionById.get(resourceId); + if (!position) { + return; + } + const cardSize = resourceCardSizeByResourceId.get(resourceId); + if (!cardSize) { + return; + } + const canvasSize = resourceCanvasElementSize(resourceCanvasRef.current); + if (canvasSize.width <= 0 || canvasSize.height <= 0) { + return; + } + const viewport = normalizeResourceBookViewport( + category === RESOURCE_BOOK_ALL_TARGET + ? resourceBookAllViewportRef.current + : resourceCanvasViewportRef.current, + ); + setResourceCanvasViewport(category, { + scale: viewport.scale, + x: + canvasSize.width / 2 - + (position.x + cardSize.width / 2) * viewport.scale, + y: + canvasSize.height / 2 - + (position.y + cardSize.height / 2) * viewport.scale, + }); + }, + [ + activePageCategory, + resourceBookOpensAllResources, + resourceBookView, + resourceCardSizeByResourceId, + resourcePositionById, + setResourceCanvasViewport, + ], + ); + useLayoutEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.code === 'Space' && !event.repeat) { @@ -5748,10 +5800,14 @@ export default function ProjectDevelopmentView({ pendingResourceFocusRef.current = null; setResourceWorkbenchNotice(''); setHiddenCommittedResourceId(null); + // 既有的 scrollIntoView 只对带滚动条的祖先有效;这张画布靠 transform 平移,视口必须显式居中, + // 否则「定位过去了但素材还在屏幕外」——用户看到的仍是没定位。 + centerResourceCanvasOnResource(intent.resourceId); card.scrollIntoView?.({ block: 'nearest', inline: 'nearest' }); handleResourceSelect(intent.resourceId); }, [ activePageCategory, + centerResourceCanvasOnResource, dependencyLayout.layout.positions, dependencyLayout.settled, handleResourceSelect, 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 20bbefbac..003f0b88a 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 @@ -11198,6 +11198,91 @@ export function registerProjectAgentStatusTests() { expect(screen.queryByText('正在定位生成的素材…')).toBeNull(); }, 20_000); + it('centers the canvas viewport on the located asset so it is actually visible', async () => { + // 「定位到素材」只把卡选中不够:这张画布是 transform 平移的,卡在视口外时 + // `scrollIntoView()` 碰不到任何滚动祖先,用户看到的就是"定位过去了但依然见不到素材"。 + const panel = await renderGenerationLocateView({ + projectId: 'workbench-locate-centers-viewport', + projectPath: '/tmp/workbench-locate-centers-viewport', + openCategory: '角色与对象', + assetId: 'locate-centers-asset', + ledgerRecords: [ + completedGenerationRecord({ + taskId: 'task-locate-centers', + assetId: 'locate-centers-asset', + projectId: 'workbench-locate-centers-viewport', + }), + ], + }); + + // 先把画布量出尺寸:jsdom 里 clientWidth/Height 恒为 0,不量就会静默走"没尺寸不居中"那条分支。 + // 居中使用的是栏目页画布(`.game-resource-page-canvas`),不是外层 `[aria-label]` 容器。 + const canvas = (await screen.findByLabelText( + '资源依赖视图', + )) as HTMLDivElement; + const pageCanvas = canvas.querySelector( + '.game-resource-page-canvas', + ); + expect(pageCanvas).not.toBeNull(); + Object.defineProperties(pageCanvas!, { + clientWidth: { configurable: true, get: () => 800 }, + clientHeight: { configurable: true, get: () => 600 }, + }); + + fireEvent.click( + within(panel).getByRole('button', { name: '定位素材 定位目标素材' }), + ); + + const card = await waitFor(() => { + const element = document.querySelector( + '.game-resource-card[data-resource-card-id="asset:locate-centers-asset"]', + ); + expect(element).not.toBeNull(); + return element!; + }); + await waitFor(() => + expect( + document.querySelector( + '.game-resource-card-select[data-resource-id="asset:locate-centers-asset"][aria-pressed="true"]', + ), + ).not.toBeNull(), + ); + + const readPixels = (name: string) => + Number( + /([\d.-]+)px/u.exec(card.style.getPropertyValue(name))?.[1] ?? 'NaN', + ); + const centerX = + readPixels('--resource-x') + readPixels('--resource-card-width') / 2; + const centerY = + readPixels('--resource-y') + readPixels('--resource-card-height') / 2; + expect(Number.isFinite(centerX)).toBe(true); + expect(Number.isFinite(centerY)).toBe(true); + + const world = document.querySelector( + '.game-resource-page-canvas[data-resource-section-scroll="character"] [data-resource-viewport]', + ); + const [viewportX, viewportY, viewportScale] = ( + world?.getAttribute('data-resource-viewport') ?? '' + ) + .split(',') + .map(Number); + expect(viewportScale).toBeGreaterThan(0); + // 视口平移量必须让卡片中心落在画布中心:`viewportX + centerX × scale = 画布宽 / 2`。 + const canvasWidth = pageCanvas!.clientWidth; + const canvasHeight = pageCanvas!.clientHeight; + expect(canvasWidth).toBeGreaterThan(0); + expect(canvasHeight).toBeGreaterThan(0); + expect(viewportX! + centerX * viewportScale!).toBeCloseTo( + canvasWidth / 2, + 0, + ); + expect(viewportY! + centerY * viewportScale!).toBeCloseTo( + canvasHeight / 2, + 0, + ); + }, 20_000); + it('surfaces the existing clear-search action when the generated asset is filtered out', async () => { const panel = await renderGenerationLocateView({ projectId: 'workbench-locate-hidden', diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 26ee7d423..7dbe69628 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7,7 +7,9 @@ - 背景(验收人在真机上连报三条):① 提交按钮上渲染了后端 `phaseDetail`,「生成图片」的主按钮变成写着「排队中。」的状态胶囊;② 提交后提交面板不关、一直占着屏幕等生成,用户原话「不要显示排队中,点生成直接把窗口藏起来啊,你留个窗口意义何在」;③ 任务进度面是一个工具条按钮 + 非模态浮层,跟网页端美术画布的任务侧栏不是一个形态,用户原话「你把美术画布的照抄过来都不会吗」;④ 「定位到素材」点了没反应,提示条永久停在「正在定位生成的素材…」。 - 决策(提交面板):**点「生成」即同步关闭面板**——不等 IPC、不等排队、不等生成;面板内**不出现**任何阶段文案(主按钮文案恒为动作名)。**只有「点击瞬间就失败」**(后端校验 / 权限拒绝 / start IPC 立即报错)才自动重开面板并带回草稿与原因;**受理之后才失败**只在任务侧栏把该任务收口为失败 + 原因,不重开面板。关闭 ≠ 取消(请求挂在任务与项目内账本上,不挂在面板生命周期上)。 -- 决策(进度面形态):改为**常驻画布的可折叠任务侧栏**(对齐网页端 `ImageCanvasTaskSidebarView`)——展开是两个分栏「排队/生成中」与「已完成」(各带条数,「已完成」封顶 20 条 + 提示「仅显示最近 N 条」),关闭入口只保留头部那一枚 ×(底部重复的关闭按钮与其 border-top 分割线已删除);折叠即整块让出画布、**不留贴边把手**;每项显示状态徽标 / 后端阶段文案 / 已耗时(前端 1s 计时)/ 素材名 + 「定位到素材」;侧栏非模态(不铺遮罩、不做焦点陷阱、不进模态遮挡判据),工具栏入口按钮是**唯一**开合口、常驻并显示 `生成任务 · N`,提交受理后自动展开。**原先的非模态浮层形态已删除,不留平行入口。** +- 决策(进度面形态):改为**常驻画布的可折叠任务侧栏**(对齐网页端 `ImageCanvasTaskSidebarView`)——展开是两个分栏「排队/生成中」与「已完成」(各带条数,「已完成」封顶 20 条 + 提示「仅显示最近 N 条」),关闭入口只保留头部那一枚 ×(底部重复的关闭按钮与其 border-top 分割线已删除);折叠即整块让出画布、**不留贴边把手**;每项显示状态徽标 / 后端阶段文案 / 已耗时(前端 1s 计时)/ 素材名 + 「定位到素材」;侧栏非模态(不铺遮罩、不做焦点陷阱、不进模态遮挡判据),工具栏入口按钮是**唯一**开合口、常驻(资源管理 / 运行两个页签都在)并显示 `生成任务 · N`,提交受理后自动展开。**原先的非模态浮层形态已删除,不留平行入口。** +- 决策(侧栏失去焦点即收起):展开时挂 document 级 `pointerdown` 捕获监听,点在侧栏内部与工具条那枚开合按钮以外的地方即收起。两处必须排除——**侧栏内部**(点任务卡、点「定位到素材」不能收起侧栏)与**开合按钮本身**(它自己负责 toggle,若也被判成「点外部」就会先收起再被 toggle 打开,表现为按钮失灵;按钮带 `data-resource-generation-task-toggle` 标记供排除)。用 `pointerdown` 而不是 `click`:画布空白处的左键 pointerdown 会 `preventDefault()`,document 上的 click 收不到那一次点击。 +- 决策(定位必须让素材真的可见):`handleResourceSelect` 只改选中、不会移动画布,而这张画布是 **transform 平移**的、资源卡不在任何滚动容器里——`card.scrollIntoView()` 碰不到滚动祖先,卡在视口外时"定位过去了但依然见不到素材"。所以聚焦链在选中之后必须**显式把画布视口居中到该卡**(`centerResourceCanvasOnResource`:读该卡在当前位置表里的 `x/y` 与 `resourceCardSizeByResourceId` 的尺寸,按 `canvasSize / 2 − 卡中心 × scale` 求平移量,**缩放保持不变**,与 `ensureResourceBookContentVisible` 的既有口径一致:定位不改用户的缩放预期)。`scrollIntoView` 一并保留(栏目页仍有带滚动条的祖先)。 - 决策(侧栏位置):挂在画布**左侧、标题栏之下、工具栏之上**,宽 300px、**覆盖式**(不 reflow 挤窄画布视口)。理由:右侧已被「智能创作」对话面板占用、顶部是栏目标题栏、底部是栏目工具栏;覆盖式不触碰画布视口数学与资源卡排布,收起即完全让出画布。若产品要求「画布被挤窄」的 flex 兄弟列形态(网页端是那种),需要改 `game-resource-book-manager` 那段布局并单独排期。 - 决策(定位终局化):根因是聚焦 effect 的依赖全是画布自身状态,**手动点定位不改其中任何一项** → effect 不重跑、`pendingResourceFocusRef` 无人消费、提示条永久停在中转文案。修法:新增聚焦请求序号并加入 effect 依赖;handler 重写为「能定位就定位并选中;素材在别的栏目先切栏目;不在投影里给『素材已不在项目里 / 已登记但尚未同步』的结论;挂 intent 后推进序号 + **3 秒有界兜底**;intent 被判 invalid 时也给『定位请求已失效』」,并修掉「已聚焦过」提前返回分支不清提示的同类问题(自动落卡那条链同源)。 - 影响范围:`src/features/resource-canvas/{ResourceCanvasAssetGenerationPanelView.tsx,ResourceCanvasGenerationPanelView.tsx,ResourceCanvasAssetGenerationTasksPanelView.tsx,resourceCanvasAssetGenerationTaskModel.ts,resourceCanvasAssetGenerationQueue.ts}`、`src/view/project-development/index.tsx`,测试 `tests/{resourceCanvasAssetGenerationBackgroundClose.test.tsx,resourceCanvasAssetGenerationTasksPanel.test.tsx,resourceCanvasBottomToolbar.test.tsx,appSurface/project-development.suite.ts}`。**未改** IPC 形状与 Rust 生成通道、未改本地排队语义(仍单条在途)、未改 `packages/**`。 diff --git a/docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md b/docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md index 71a965c78..3c068bfc0 100644 --- a/docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md +++ b/docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md @@ -50,7 +50,7 @@ - **本地排队**:AGC 本地 durable 输出槽按**精确动作指纹**分槽(`run_id = slot-`,材料为 prompt / outputPath / 比例 / 尺寸 / assetKind / assetLabel / replaceExisting / requireSlices;不同 prompt 或素材名各自独立成槽,不再按 `outputPath` 共用;旧 `{outputPath,requireSlices}` 槽账本由同一精确动作懒迁移)。因此后端已具备并行能力,但**本批前端仍按「同一时刻只派发一条」排队**——真并行派发需要并发收口设计(配对读 + manifest CAS + 聚焦意图互不覆盖),留待下一批。所以第二条提交停在**前端本地队列**里(不调用提交 IPC,阶段显示本地排队的「排队中。」),第一条终态后由同一条循环自动补发;判据是「存在 `dispatched && 未终态` 的任务时不派发下一条」。 - **阶段文案只来自后端,且只出现在任务侧栏**:派发之后由任务侧栏渲染后端账本给的 `phaseDetail`(「排队中。」/「正在生成。」/「生成已完成。」/失败原因),提交面板已关闭、不渲染任何阶段文案;前端不拼阶段、不做百分比。 -- **「生成任务」是常驻画布的可折叠侧栏**(形态对齐网页端美术画布的任务侧栏):展开是两个分栏「排队/生成中」与「已完成」(各带条数,「已完成」封顶 20 条 + 列表滚动 + 高度有界),关闭入口只保留头部那一枚 ×(底部重复的关闭按钮与其分割线已删除);折叠即整块让出画布、**不留贴边把手**;位置在画布左侧标题栏之下、**覆盖式**(不 reflow 挤窄画布视口)。侧栏非模态(不铺全屏遮罩、不做焦点陷阱、**不参与** `isResourceCanvasFloatingPanelOpen` / `resourceCanvasHostGenerationPanelOpen` 的遮挡判据),提交受理后自动展开;工具栏入口按钮是**唯一**开合口、**两个页签(资源管理 / 运行)下都常驻**,并显示 `生成任务 · N`。每项的「定位到素材」**每次点击都终局化**(含跨栏目先切栏目、不在投影里给结论、3 秒有界兜底),不允许提示条永久停在「正在定位生成的素材…」。 +- **「生成任务」是常驻画布的可折叠侧栏**(形态对齐网页端美术画布的任务侧栏):展开是两个分栏「排队/生成中」与「已完成」(各带条数,「已完成」封顶 20 条 + 列表滚动 + 高度有界),关闭入口只保留头部那一枚 ×(底部重复的关闭按钮与其分割线已删除);折叠即整块让出画布、**不留贴边把手**;**失去焦点(点侧栏外部,不含那枚开合按钮本身)即自动收起**;位置在画布左侧标题栏之下、**覆盖式**(不 reflow 挤窄画布视口)。侧栏非模态(不铺全屏遮罩、不做焦点陷阱、**不参与** `isResourceCanvasFloatingPanelOpen` / `resourceCanvasHostGenerationPanelOpen` 的遮挡判据),提交受理后自动展开;工具栏入口按钮是**唯一**开合口、**两个页签(资源管理 / 运行)下都常驻**、排在「依赖 / 类型」排列方式**之前**(动作在前、排列方式收行尾),并显示 `生成任务 · N`。每项的「定位到素材」**每次点击都终局化**(含跨栏目先切栏目、不在投影里给结论、3 秒有界兜底),不允许提示条永久停在「正在定位生成的素材…」;定位成功后必须**把画布视口居中到该卡**——这张画布是 transform 平移的,`scrollIntoView()` 碰不到滚动祖先,只改选中会让用户"定位过去了但依然见不到素材"。 ## 5. 参数口径与复用程度