卡片浮层改为提交即关:快速编辑 / 生成动画只负责交任务

- 快速编辑与生成动画点提交当帧即关面板(不等 IPC、不留等待态),进度、阶段与结果只由「生成任务」侧栏承载,与图片类生成面板同一口径
- 受理之后的失败不再重开面板:侧栏那条收口为失败 + 提示条,草稿回落成「未完成编辑」等用户重开;只有点击瞬间就失败(名称预检、源素材不在画布、无客户端)留在面板里
- 派生请求身份改为按「入口 + 资源路径」记忆:重开面板再提交时提示词没变就沿用同一 operationId / 幂等键,命中同一 operation 账本;派生成成功后清除
- 删除 canDismissResourceCanvasQuickEdit 与宿主里只为它服务的两块面板状态 ref,clearResourceCanvasFocus 回到「清选中 + 关两块浮层」的直线逻辑
- 三条既有用例按新口径改写为「重开面板再重试,身份不变」;PRD §3.10 与 decision-log 同步
This commit is contained in:
2026-09-21 00:19:42 +08:00
parent 573f9f447a
commit 973e965f4f
7 changed files with 208 additions and 130 deletions
@@ -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;
@@ -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<ResourceDeriveRequest | null>(null);
/** 快速编辑面板的最新状态:清焦点回调只在事件里读,不随面板状态反复重建。 */
const quickEditPanelRef = useRef<QuickEditPanelState | null>(null);
quickEditPanelRef.current = quickEditPanel;
/** 「生成动画」面板的最新状态,读法同上。 */
const characterAnimationPanelRef =
useRef<CharacterAnimationPanelState | null>(null);
characterAnimationPanelRef.current = characterAnimationPanel;
/**
* `resourceEditRequestKey`
*
*
* operation
*/
const resourceEditLastRequestsRef = useRef(
new Map<string, ResourceDeriveRequest>(),
);
/**
* 稿
*
@@ -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,
@@ -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);
@@ -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,
@@ -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: '关闭资源编辑恢复面板' }),
);
// 旧卡片已经不在画布上:草稿按**路径**归属,新投影的正式素材卡照样命中,否则这一笔
// 就再也点不到。
@@ -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`
@@ -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 失败均为本分支既有)。