From 973e965f4fb22d325289e0028922c285e46d5604 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 21 Sep 2026 00:19:42 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8D=A1=E7=89=87=E6=B5=AE=E5=B1=82=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E6=8F=90=E4=BA=A4=E5=8D=B3=E5=85=B3=EF=BC=9A=E5=BF=AB?= =?UTF-8?q?=E9=80=9F=E7=BC=96=E8=BE=91=20/=20=E7=94=9F=E6=88=90=E5=8A=A8?= =?UTF-8?q?=E7=94=BB=E5=8F=AA=E8=B4=9F=E8=B4=A3=E4=BA=A4=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 快速编辑与生成动画点提交当帧即关面板(不等 IPC、不留等待态),进度、阶段与结果只由「生成任务」侧栏承载,与图片类生成面板同一口径 - 受理之后的失败不再重开面板:侧栏那条收口为失败 + 提示条,草稿回落成「未完成编辑」等用户重开;只有点击瞬间就失败(名称预检、源素材不在画布、无客户端)留在面板里 - 派生请求身份改为按「入口 + 资源路径」记忆:重开面板再提交时提示词没变就沿用同一 operationId / 幂等键,命中同一 operation 账本;派生成成功后清除 - 删除 canDismissResourceCanvasQuickEdit 与宿主里只为它服务的两块面板状态 ref,clearResourceCanvasFocus 回到「清选中 + 关两块浮层」的直线逻辑 - 三条既有用例按新口径改写为「重开面板再重试,身份不变」;PRD §3.10 与 decision-log 同步 --- .../resourceCanvasFocusModel.ts | 18 +- .../src/view/project-development/index.tsx | 199 ++++++++++-------- .../projectResourceLiveIntegration.test.tsx | 69 +++++- .../resourceCanvasFloatingDismiss.test.tsx | 10 - .../resourceCanvasQuickEditDraft.test.tsx | 30 ++- ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 3 +- .../shared-memory/decision-log.md | 9 + 7 files changed, 208 insertions(+), 130 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts index b08cf7620..260e12164 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts @@ -48,9 +48,9 @@ export function isResourceCanvasPanTarget( if (!isResourceCanvasInteractionTarget(target)) return true; return Boolean( target.closest('.game-resource-card') && - !target.closest( - 'button:not(.game-resource-card-select), [role="button"], input, textarea, select, a, audio, video', - ), + !target.closest( + 'button:not(.game-resource-card-select), [role="button"], input, textarea, select, a, audio, video', + ), ); } @@ -85,18 +85,6 @@ export function isResourceCanvasWheelOverlayTarget( ); } -/** - * 画布浮层是否可以被清焦点顺手关掉。 - * - * 对齐美术画布 `closeTransientEditorPanels()`:生成中的面板不关,否则用户看不到 - * 「修改中」状态,也拿不到失败后的重试入口。信息浮层是只读的,没有生成态。 - */ -export function canDismissResourceCanvasQuickEdit( - panel: { readonly status: string } | null | undefined, -): boolean { - return panel?.status !== 'generating'; -} - /** 资源画布上的 AGC 自有弹层:它们开着时画布浮层不参与「点外部关闭」。 */ export type ResourceCanvasHostOverlayState = { readonly isResourcePanelOpen: boolean; 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 257db1344..35f1957e6 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 @@ -166,7 +166,6 @@ import { } from '../../features/resource-canvas/resourceCanvasBottomToolbarModel'; import { ResourceCanvasBottomToolbarView } from '../../features/resource-canvas/ResourceCanvasBottomToolbarView'; import { - canDismissResourceCanvasQuickEdit, isResourceCanvasInteractionTarget, isResourceCanvasPanTarget, isResourceCanvasWheelOverlayTarget, @@ -573,6 +572,20 @@ function pendingResourceEditPhaseLabel(phase: string) { return RESOURCE_CANVAS_RESOURCE_EDIT_PHASE_LABELS[phase] ?? '等待恢复'; } +/** + * 卡片浮层的派生请求身份表键:**入口 + 资源路径**。 + * + * 两块浮层都是「提交即关」,重试的唯一路径是重开面板再提交;身份不跟着资源走的话,重试会变成 + * 一条全新的 operation(原生账本认不出同一件事,还可能重复计费)。同一张素材上的两种入口 + * (快速编辑 / 生成动画)是两条不同的派生请求,所以键里带上入口。 + */ +function resourceEditRequestKey( + kind: 'quick-edit' | 'character-animation', + resourcePath: string, +): string { + return `${kind}:${resourcePath}`; +} + function pendingResourceEditCreatedAtLabel(createdAt: number) { if (!Number.isSafeInteger(createdAt) || createdAt < 0) return '时间未知'; return new Intl.DateTimeFormat('zh-CN', { @@ -2372,13 +2385,15 @@ export default function ProjectDevelopmentView({ /** 「生成动画」的请求身份,与快速编辑同一套复用/重铸口径。 */ const resourceCharacterAnimationRequestRef = useRef(null); - /** 快速编辑面板的最新状态:清焦点回调只在事件里读,不随面板状态反复重建。 */ - const quickEditPanelRef = useRef(null); - quickEditPanelRef.current = quickEditPanel; - /** 「生成动画」面板的最新状态,读法同上。 */ - const characterAnimationPanelRef = - useRef(null); - characterAnimationPanelRef.current = characterAnimationPanel; + /** + * 资源路径 → 这块资源上最后一次派生请求的身份(按入口分键,见 `resourceEditRequestKey`)。 + * + * 卡片浮层提交即关,用户重试只能「重开面板再提交」;身份不跟着资源走的话,那次重试就成了 + * 一条全新的 operation。派生成成功即清掉:下一次编辑本来就该是新的一笔。 + */ + const resourceEditLastRequestsRef = useRef( + new Map(), + ); /** * 打开中的快速编辑面板,这一笔草稿挂在哪个资源路径下。 * @@ -2425,23 +2440,16 @@ export default function ProjectDevelopmentView({ * 清资源画布焦点:对齐美术画布 `clearCanvasFocus()`。 * * 美术画布在画布空白 pointerdown 与 Escape 两处都会走这一步——清空选中让选中工具条 - * 收起,并关掉画布浮层(快速编辑与信息)。资源画布保持同一套时机:点在资源卡或浮层里 - * 不清,点画布空白、按 Esc 才清。生成中的快速编辑面板不关,与美术画布 - * `closeTransientEditorPanels()` 的 `status === 'generating'` 判断一致;信息浮层只读, - * 没有生成态,随焦点一起收起。 + * 收起,并关掉画布浮层(快速编辑 / 生成动画 / 信息)。资源画布保持同一套时机:点在资源卡 + * 或浮层里不清,点画布空白、按 Esc 才清。 + * + * 这里的浮层一律可收:卡片浮层只负责**提交任务**,提交当帧自己就关了(任务进「生成任务」 + * 侧栏跟踪),所以不存在「生成中必须留在画布上」的那一档。 */ const clearResourceCanvasFocus = useCallback(() => { setSelectedResourceIds([]); setResourceInfoResourceId(null); - if (!canDismissResourceCanvasQuickEdit(quickEditPanelRef.current)) { - return; - } closeResourceQuickEditPanel(); - // 「生成动画」与快速编辑同样是挂在资源卡旁的浮层,走同一套「点外部 / Esc 关闭」 - // 时机与「生成中不关」判据,否则按下 Esc 会只收起一个浮层。 - if (characterAnimationPanelRef.current?.status === 'generating') { - return; - } closeResourceCharacterAnimationPanel(); }, [closeResourceCharacterAnimationPanel, closeResourceQuickEditPanel]); @@ -7539,12 +7547,21 @@ export default function ProjectDevelopmentView({ resourceQuickEditDraftPathRef.current = resource.path; setQuickEditPanel(restoredPanelDraft); setQuickEditReferences(remembered ? [...remembered.references] : []); - resourceQuickEditRequestRef.current = { - ...createResourceEditRequestIdentity(restoredPanelDraft.prompt), - sourceLayerId: layer.id, - normalizedAssetId: null, - expectedProjectRevision: null, - }; + /* + 身份跟着**资源**走:上一次尝试失败后重开面板,提示词没变就沿用同一 operationId / 幂等键, + 重试命中同一 operation 账本(提交时由 `resolveResourceEditRequestIdentity` 再判一次)。 + */ + const rememberedRequest = resourceEditLastRequestsRef.current.get( + resourceEditRequestKey('quick-edit', resource.path), + ); + resourceQuickEditRequestRef.current = rememberedRequest + ? { ...rememberedRequest, sourceLayerId: layer.id } + : { + ...createResourceEditRequestIdentity(restoredPanelDraft.prompt), + sourceLayerId: layer.id, + normalizedAssetId: null, + expectedProjectRevision: null, + }; setResourceWorkbenchNotice(''); }, [applyResourceCardFloatingPanelOpen, canvasResources], @@ -7798,30 +7815,36 @@ export default function ProjectDevelopmentView({ const actionProject = { projectPath, projectId: manifest.projectId }; const flowId = crypto.randomUUID(); const sourceLayerId = layer.id; - const previousRequest = resourceQuickEditRequestRef.current; + const requestStoreKey = resourceEditRequestKey('quick-edit', resource.path); + const previousRequest = + resourceEditLastRequestsRef.current.get(requestStoreKey) ?? null; // 请求身份只对它铸造时的那句提示词有效:Rust 的 request_fingerprint 含 prompt // (resource_editor.rs:807-827),改了提示词还拿旧 operationId 重试会被判 // 「operationId 或幂等键已绑定到不同资源编辑请求」(同文件 :5216-5225)。 // 提示词没变时仍复用,失败重试照旧命中同一 operation 账本; // 正规化结果与 revision 是别的事实,换身份时原样保留。 - const request = - previousRequest?.sourceLayerId === sourceLayerId - ? { - ...previousRequest, - ...resolveResourceEditRequestIdentity(previousRequest, prompt), - } - : { - ...createResourceEditRequestIdentity(prompt), - sourceLayerId, - normalizedAssetId: null, - expectedProjectRevision: null, - }; + const request = previousRequest + ? { + ...previousRequest, + ...resolveResourceEditRequestIdentity(previousRequest, prompt), + } + : { + ...createResourceEditRequestIdentity(prompt), + sourceLayerId, + normalizedAssetId: null, + expectedProjectRevision: null, + }; resourceQuickEditRequestRef.current = request; - setQuickEditPanel((current) => - current - ? { ...current, status: 'generating', errorMessage: undefined } - : current, - ); + resourceEditLastRequestsRef.current.set(requestStoreKey, request); + /* + 提交即关面板:这块浮层只负责「把这次修改交出去」,交出去之后它的生命周期就结束了—— + 进度、阶段与结果都在「生成任务」侧栏里跟,画布上不再挂一块等结果的板子。失败也不把面板 + 拉回来:异步失败会回落成未完成草稿(恢复入口)+ 侧栏一条失败 + 提示条;只有**点击瞬间** + 就能判定的失败(名称预检、源素材不在画布上、没有客户端)才留在面板里。 + */ + setQuickEditPanel(null); + setQuickEditSourceLayer(null); + resourceQuickEditDraftPathRef.current = null; // 提交当帧就上「生成任务」侧栏:原生账本要等 Rust 建好 ledger 才读得到,中间那段空白 // 正是验收现场看到的「点了生成,生成任务里什么都没有」。 beginResourceEditSubmission({ @@ -7891,13 +7914,12 @@ export default function ProjectDevelopmentView({ // 正式的可恢复事实已经由原 operation 账本接管,本地草稿再留着只会在恢复入口里 // 多出一条重复条目。键是源素材路径,正规化换掉卡片 id 也不影响这一笔的归属。 discardResourceQuickEditDraft(resource.path); - resourceQuickEditDraftPathRef.current = null; + // 这一笔已经落定:下一次编辑该是新的一笔,不再复用它的身份。 + resourceEditLastRequestsRef.current.delete(requestStoreKey); settleResourceEditSubmission(request.operationId, { status: 'completed', assetId: result.asset.id, }); - setQuickEditPanel(null); - setQuickEditSourceLayer(null); resourceQuickEditRequestRef.current = null; setResourceWorkbenchNotice('快速编辑已产出新素材,正在同步资源与布局…'); } catch (error) { @@ -7906,15 +7928,9 @@ export default function ProjectDevelopmentView({ status: 'failed', error: message, }); - setQuickEditPanel((current) => - current - ? { - ...current, - status: 'failed', - errorMessage: message, - } - : current, - ); + resourceQuickEditRequestRef.current = null; + // 面板已经关了:失败只能落在提示条与「生成任务」侧栏上,草稿留在原处等重开。 + setResourceWorkbenchNotice(`快速编辑失败:${message}`); } }, [ beginResourceEditSubmission, @@ -7960,12 +7976,18 @@ export default function ProjectDevelopmentView({ ...panelDraft, resolution: RESOURCE_CHARACTER_ANIMATION_RESOLUTION, }); - resourceCharacterAnimationRequestRef.current = { - ...createResourceEditRequestIdentity(''), - sourceLayerId: layer.id, - normalizedAssetId: null, - expectedProjectRevision: null, - }; + // 与快速编辑同口径:失败后重开面板,提示词没变就沿用同一 operation 身份。 + const rememberedRequest = resourceEditLastRequestsRef.current.get( + resourceEditRequestKey('character-animation', resource.path), + ); + resourceCharacterAnimationRequestRef.current = rememberedRequest + ? { ...rememberedRequest, sourceLayerId: layer.id } + : { + ...createResourceEditRequestIdentity(''), + sourceLayerId: layer.id, + normalizedAssetId: null, + expectedProjectRevision: null, + }; setResourceWorkbenchNotice(''); }, [applyResourceCardFloatingPanelOpen, canvasResources], @@ -8064,27 +8086,30 @@ export default function ProjectDevelopmentView({ const actionProject = { projectPath, projectId: manifest.projectId }; const flowId = crypto.randomUUID(); const sourceLayerId = layer.id; - const previousRequest = resourceCharacterAnimationRequestRef.current; + const requestStoreKey = resourceEditRequestKey( + 'character-animation', + resource.path, + ); + const previousRequest = + resourceEditLastRequestsRef.current.get(requestStoreKey) ?? null; // 与快速编辑同一套口径:提示词没变就复用 operationId / 幂等键,失败重试命中同一 // operation 账本;改了提示词必须重铸,否则 Rust 的 request_fingerprint 会判不一致。 - const request = - previousRequest?.sourceLayerId === sourceLayerId - ? { - ...previousRequest, - ...resolveResourceEditRequestIdentity(previousRequest, prompt), - } - : { - ...createResourceEditRequestIdentity(prompt), - sourceLayerId, - normalizedAssetId: null, - expectedProjectRevision: null, - }; + const request = previousRequest + ? { + ...previousRequest, + ...resolveResourceEditRequestIdentity(previousRequest, prompt), + } + : { + ...createResourceEditRequestIdentity(prompt), + sourceLayerId, + normalizedAssetId: null, + expectedProjectRevision: null, + }; resourceCharacterAnimationRequestRef.current = request; - setCharacterAnimationPanel((current) => - current - ? { ...current, status: 'generating', errorMessage: undefined } - : current, - ); + resourceEditLastRequestsRef.current.set(requestStoreKey, request); + // 与快速编辑同口径:提交即关面板,进度与结果都在「生成任务」侧栏里跟。 + setCharacterAnimationPanel(null); + setCharacterAnimationSourceLayer(null); // 与快速编辑同口径:这一笔从按下按钮那一刻就进「生成任务」侧栏。 beginResourceEditSubmission({ operationId: request.operationId, @@ -8153,6 +8178,8 @@ export default function ProjectDevelopmentView({ setCharacterAnimationPanel(null); setCharacterAnimationSourceLayer(null); resourceCharacterAnimationRequestRef.current = null; + // 落定后不再复用这笔身份:下一次编辑是新的一笔。 + resourceEditLastRequestsRef.current.delete(requestStoreKey); settleResourceEditSubmission(request.operationId, { status: 'completed', assetId: result.asset.id, @@ -8164,15 +8191,9 @@ export default function ProjectDevelopmentView({ status: 'failed', error: message, }); - setCharacterAnimationPanel((current) => - current - ? { - ...current, - status: 'failed', - errorMessage: message, - } - : current, - ); + resourceCharacterAnimationRequestRef.current = null; + // 面板已经关了:失败落在提示条与「生成任务」侧栏上,重开面板再试命中同一 operation。 + setResourceWorkbenchNotice(`生成动画失败:${message}`); } }, [ beginResourceEditSubmission, diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx index 97dff5c85..920ae1b9c 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx @@ -841,8 +841,25 @@ describe('project resource live canvas integration', () => { '把角色头发设定改为红色', ); fireEvent.click(within(panel).getByRole('button', { name: '修改' })); - expect(await screen.findByRole('alert')).not.toBeNull(); - fireEvent.click(within(panel).getByRole('button', { name: '修改' })); + // 提交即关面板:失败也不把面板拉回来,重试只能重开面板(任务与失败都在「生成任务」侧栏里)。 + await waitFor(() => + expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull(), + ); + fireEvent.click(await findResourceSelectButton('source-art.png')); + const retryToolbar = await screen.findByRole('toolbar', { + name: '图片工具栏', + }); + fireEvent.click( + within(retryToolbar).getByRole('button', { name: '快速编辑' }), + ); + const retryPanel = await screen.findByRole('dialog', { + name: '快速编辑图片', + }); + // 草稿回填,提示词没变 → 沿用上一次那笔 operation 身份。 + expect( + within(retryPanel).getByLabelText('快速编辑提示词').textContent, + ).toContain('把角色头发设定改为红色'); + fireEvent.click(within(retryPanel).getByRole('button', { name: '修改' })); await waitFor(() => expect(deriveCalls).toHaveLength(2)); expect(deriveCalls[0]?.operationId).toBe(deriveCalls[1]?.operationId); @@ -988,13 +1005,28 @@ describe('project resource live canvas integration', () => { '把角色头发改成红色', ); fireEvent.click(within(panel).getByRole('button', { name: '修改' })); - expect(await screen.findByRole('alert')).not.toBeNull(); + // 提交即关面板,重开面板继续改:草稿回填的是原文。 + await waitFor(() => + expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull(), + ); + fireEvent.click(await findResourceSelectButton('source-art.png')); + const retryToolbar = await screen.findByRole('toolbar', { + name: '图片工具栏', + }); + fireEvent.click( + within(retryToolbar).getByRole('button', { name: '快速编辑' }), + ); + const retryPanel = await screen.findByRole('dialog', { + name: '快速编辑图片', + }); // 失败后提示词仍可改:润色一次,回填的是润色结果,而不是原文。 - fireEvent.click(within(panel).getByRole('button', { name: 'AI 润色' })); + fireEvent.click( + within(retryPanel).getByRole('button', { name: 'AI 润色' }), + ); await waitFor(() => { expect( - within(panel).getByLabelText('快速编辑提示词').textContent, + within(retryPanel).getByLabelText('快速编辑提示词').textContent, ).toContain('亮红色'); }); expect(polishCalls[0]).toMatchObject({ prompt: '把角色头发改成红色' }); @@ -1002,7 +1034,7 @@ describe('project resource live canvas integration', () => { '图片素材的快速编辑提示词', ); - fireEvent.click(within(panel).getByRole('button', { name: '修改' })); + fireEvent.click(within(retryPanel).getByRole('button', { name: '修改' })); await waitFor(() => expect(deriveCalls).toHaveLength(2)); // Rust 的 request_fingerprint 含 prompt:提示词换过就必须换 operationId / 幂等键, // 否则后端会判「已绑定到不同资源编辑请求」。 @@ -1031,17 +1063,32 @@ describe('project resource live canvas integration', () => { '把角色头发改成红色', ); fireEvent.click(within(panel).getByRole('button', { name: '修改' })); - expect(await screen.findByRole('alert')).not.toBeNull(); + // 提交即关面板:重开面板后提示词仍是原文(草稿回填),再改再润色都在这一份草稿上。 + await waitFor(() => + expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull(), + ); + fireEvent.click(await findResourceSelectButton('source-art.png')); + const retryToolbar = await screen.findByRole('toolbar', { + name: '图片工具栏', + }); + fireEvent.click( + within(retryToolbar).getByRole('button', { name: '快速编辑' }), + ); + const retryPanel = await screen.findByRole('dialog', { + name: '快速编辑图片', + }); - fireEvent.click(within(panel).getByRole('button', { name: 'AI 润色' })); + fireEvent.click( + within(retryPanel).getByRole('button', { name: 'AI 润色' }), + ); expect( - await within(panel).findByText('AI 润色失败,可重试'), + await within(retryPanel).findByText('AI 润色失败,可重试'), ).not.toBeNull(); expect( - within(panel).getByLabelText('快速编辑提示词').textContent, + within(retryPanel).getByLabelText('快速编辑提示词').textContent, ).toContain('把角色头发改成红色'); - fireEvent.click(within(panel).getByRole('button', { name: '修改' })); + fireEvent.click(within(retryPanel).getByRole('button', { name: '修改' })); await waitFor(() => expect(deriveCalls).toHaveLength(2)); // 提示词没变,身份也不该变:重试仍然命中同一 operation 账本。 expect(deriveCalls[1]?.operationId).toBe(deriveCalls[0]?.operationId); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx index db87b2e61..ce3da42c4 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx @@ -12,7 +12,6 @@ import { useImageCanvasFloatingOptionDismiss, } from '../../../src/components/image-editor/useImageCanvasFloatingOptionDismiss'; import { - canDismissResourceCanvasQuickEdit, isResourceCanvasHostOverlayOpen, isResourceCanvasInteractionTarget, isResourceCanvasPanTarget, @@ -134,15 +133,6 @@ describe('resourceCanvasFocusModel', () => { document.body.innerHTML = ''; }); - test('快速编辑生成中不参与清焦点', () => { - expect(canDismissResourceCanvasQuickEdit(null)).toBe(true); - expect(canDismissResourceCanvasQuickEdit({ status: 'idle' })).toBe(true); - expect(canDismissResourceCanvasQuickEdit({ status: 'failed' })).toBe(true); - expect(canDismissResourceCanvasQuickEdit({ status: 'generating' })).toBe( - false, - ); - }); - test('AGC 自有弹层开着时画布浮层不接管点外部关闭', () => { const closed = { isResourcePanelOpen: false, diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx index b7c00c4ce..b9383a561 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasQuickEditDraft.test.tsx @@ -561,7 +561,11 @@ describe('快速编辑草稿的保留与恢复', () => { '把夜色改成星空', ); fireEvent.click(within(panel).getByRole('button', { name: '修改' })); - // 先正规化成正式素材、再派生失败:卡片已经重投影,面板仍停在失败态。 + // 提交即关面板:面板只负责把任务交出去,失败也不再把它拉回来(回落成草稿 + 提示条)。 + await waitFor(() => + expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull(), + ); + // 先正规化成正式素材、再派生失败:卡片已经重投影。 await waitFor(() => expect(normalizeCalls).toHaveLength(1)); await waitFor(() => expect(deriveCalls).toHaveLength(1)); await waitFor(() => @@ -576,9 +580,27 @@ describe('快速编辑草稿的保留与恢复', () => { '[data-resource-card-id="task:canvas-task:assets/generated/task-art.png"]', ), ).toBeNull(); - expect(await within(panel).findByRole('alert')).not.toBeNull(); - - await dismissPanel(); + // 失败可见:这一笔在「生成任务」侧栏里收口为失败并带上原因(面板已经关了,结果只在这里)。 + fireEvent.click(await screen.findByRole('button', { name: '生成任务' })); + const taskSidebar = await screen.findByRole('region', { name: '生成任务' }); + expect(within(taskSidebar).getByText('失败')).not.toBeNull(); + expect( + within(taskSidebar).getAllByText(/result-unknown: 测试网络中断/u).length, + ).toBeGreaterThan(0); + // 草稿回到「未完成编辑」等重开。 + fireEvent.click( + await screen.findByRole('button', { name: '管理未完成编辑 (1)' }), + ); + expect( + within( + await screen.findByRole('dialog', { name: '未完成的资源编辑' }), + ).getByRole('button', { name: '继续编辑' }), + ).not.toBeNull(); + fireEvent.click( + within( + screen.getByRole('dialog', { name: '未完成的资源编辑' }), + ).getByRole('button', { name: '关闭资源编辑恢复面板' }), + ); // 旧卡片已经不在画布上:草稿按**路径**归属,新投影的正式素材卡照样命中,否则这一笔 // 就再也点不到。 diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index 72fd548bf..72d6aa638 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -171,7 +171,8 @@ - 前置规范图:`start_local_project_asset_generation` 的 `ui-prototype` 与 `art-spritesheet` 要求项目里已有登记并绑定当前账号的 `assets/art-spec.png`(`kind: icon-spec`、图片媒体类型、来源为画布)。**后台化没有改这条前置判据**:Rust 侧仍是同一口径,前端 `projectHasIconSpecReference` 仍与它逐字对齐,缺前置时对应入口**保持可点击**并给出可执行的原因与指路(仓库口径:视觉不可用但可点击说明原因,不用原生 `disabled`),原因文案包含 `assets/art-spec.png` 与「生成规范 → 图标规范」。 - 权威规范图由工具栏自身可满足:「图标规范」入口在项目尚无权威规范图时按 `assets/art-spec.png` 落盘;已有权威规范图时**不再传 `outputPath`**(Rust 侧 `replace_existing` 固定为 `false`,指向已存在文件会被硬拒绝「outputPath 已存在,禁止静默覆盖」),改为生成一张新的普通图标规范资产。这条前端策略不扩接口。 - 音频入口收敛:音频画布的「生成背景音乐 / 生成音效」由工具栏承载,既有「生成素材」浮层入口**只保留生成视频**(视频能力不删,只是不出现在工具栏里)。 -- 派生/修改类任务并入同一侧栏(2026-09-20):快速编辑、生成动画、视频 / 音效 / 背景音乐与抠图这类走**资源编辑链路**的任务也进「生成任务」侧栏。数据源 = 原生资源编辑账本 `list_pending_local_project_resource_edits` + **本会话的本地提交记录**,按 `operationId` 合并去重:本地那份负责「按下提交当帧可见」,原生那份负责「重开项目、别处提交也看得见,阶段文案以后端为首」。这类任务不写图片类生成账本(`.agent/runtime/asset-generation-tasks/tasks.json`),也不改原生资源编辑账本 schema。配套两条口径:提交期间该笔不再列进「管理未完成编辑」(失败后自动回落成未完成草稿,提示词不丢);同一张素材还有在途提交时,重开面板再提交会被拦下并给出原因,避免重复派生与重复计费。 +- 派生/修改类任务并入同一侧栏(2026-09-20):快速编辑、生成动画、视频 / 音效 / 背景音乐与抠图这类走**资源编辑链路**的任务也进「生成任务」侧栏。数据源 = 原生资源编辑账本 `list_pending_local_project_resource_edits` + **本会话的本地提交记录**,按 `operationId` 合并去重:本地那份负责「按下提交当帧可见」,原生那份负责「重开项目、别处提交也看得见,阶段文案以后端为首」。这类任务不写图片类生成账本(`.agent/runtime/asset-generation-tasks/tasks.json`),也不改原生资源编辑账本 schema。 +- 卡片浮层同一条「提交即关」(2026-09-21):快速编辑 / 生成动画这类挂在资源卡上的浮层只负责**把任务交出去**,点提交当帧即关面板(不等 IPC),进度、阶段与结果都在「生成任务」侧栏里跟;面板内不出现「修改中 / 生成中」这类等待态。只有**点击瞬间就失败**(名称预检、源素材不在画布上、没有客户端)留在面板里可见;**受理之后才失败**只把侧栏那条收口为失败 + 提示条,不重开面板,草稿回落成「未完成编辑」等用户重开。因此原有的「生成中的卡片浮层不参与清焦点」判据一并退役(浮层不会带着进行中的任务留在画布上)。身份跟着资源走:按「入口 + 资源路径」记住最后一次派生请求,重开面板再提交时提示词没变就沿用同一 `operationId` / 幂等键,命中同一 operation 账本;派生成成功即清掉。同一张素材仍有在途提交时,再提交会被拦下并给出原因。 - 本轮明确不做:生成视频(保留在既有浮层入口)、宣发素材、生成游戏场景、画布级「选择工具 / 抓手工具」(AGC 现无画布级工具模式)。这五类都**不得**出现在工具栏里。 - 本地 IPC 边界(前端只做本地收窄,不改接口):提示词上限 `32000` 字符、素材名上限 `120` 字符、比例白名单 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`、尺寸白名单 `0.5K / 1K / 2K`。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a234f9a08..475c49840 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9041,3 +9041,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 理由:用户对「生成」的心智是「交出去就得能看见它在跑」,而不是必须区分两条账本;而防重复必须在**身份重铸之前**拦,等到原生账本判重时已经派生过一次。 - 验证:新增模型用例 9 条、快速编辑两组宿主用例(提交当帧进侧栏并收口 / 在途重复提交被拦),两处新判据做过变异验证;定向 7 个文件 97 条全绿;`tests/appSurface.test.ts` 与改动前基线逐条一致(16 失败均为本分支既有)。 - 待办:远程 CI 与真实客户端验收未跑;`resourceCanvasGenerationTasksSidebarDismiss` 等样式结构用例在本分支既有失败,与本批无关,另行跟踪。 + +## 2026-09-21 卡片浮层改为「提交即关」:快速编辑 / 生成动画只负责交任务 + +- 背景:客户端验收反馈——「生成动画」这类卡片浮层在失焦时不会收起,而且这块的关闭判据一直是东一处西一处拼的(点画布空白、换选中卡、Esc、点画布以外各有一条);动画面板在生成中还会被「生成中不关」判据锁在画布上。产品口径澄清:生成都归「生成任务」侧栏,**卡片浮层只用于提交任务,提交完生命周期就结束**。 +- 决策(提交即关):快速编辑 / 生成动画点提交当帧即关面板,不等 IPC、不留等待态;阶段与结果只由「生成任务」侧栏承载(本地提交记录 + 原生待办按 `operationId` 合并,见 2026-09-20 那条)。只有**点击瞬间就失败**留在面板里;**受理之后才失败**把侧栏收口为失败 + 提示条并回落草稿,不重开面板。 +- 决策(身份跟着资源走):按「入口 + 资源路径」记最后一次派生请求(`resourceEditRequestKey`)。重开面板再提交时提示词没变就沿用同一 `operationId` / 幂等键,重试仍命中同一 operation 账本;派生成成功即清掉,下一次编辑是新的一笔。原「按上次打开的 sourceLayerId 复用」在提交即关之后不再成立(面板重开时层 id 可能已被重投影换掉)。 +- 清理:`canDismissResourceCanvasQuickEdit`(生成中的浮层不参与清焦点)与宿主里两个只为它服务的面板状态 ref 一并删除;`clearResourceCanvasFocus` 恢复成「清选中 + 关两块浮层」的直线逻辑。 +- 影响面:`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、`.../features/resource-canvas/resourceCanvasFocusModel.ts`、`tests/{projectResourceLiveIntegration,resourceCanvasQuickEditDraft,resourceCanvasFloatingDismiss}.test.tsx`、PRD §3.10。 +- 验证:定向 `projectResourceLiveIntegration`(32 条,三条断言面板留在失败态的用例按新口径改写为「重开面板再重试,身份不变」)、`resourceCanvasQuickEditDraft`(10 条)、`resourceCanvasFloatingDismiss`(18 条)全绿;`npm --prefix apps/ai-game-creator-shell run typecheck` 通过;`tests/appSurface.test.ts` 与改动前基线逐条一致(16 失败均为本分支既有)。