统一生成占位右键删除保护
让 generation 占位右键删除在历史和选择副作用前进入统一请求入口。 保留未收口完美像素身份并恢复普通生成中的删除确认。 补充层命令定向测试并同步技术方案与决策记录。
This commit is contained in:
@@ -6246,3 +6246,10 @@
|
||||
- 剩余边界:confirm 成功后浏览器立即崩溃、且 operation 首次 PATCH 尚未落库时,仍可能留下 object-only 记录。这里的 object-only 是指 OSS 中已有私有源图文件、数据库也已有对应 `asset_object / objectKey` 登记,但尚无 `perfectPixelOperation` journal、项目 resource、素材库 asset、完美像素结果或画布结果图层。用户界面不可见且刷新后无法复用该 identity,再次点击可能重新上传;影响限于不可达的源图存储与垃圾记录累积,不代表结果已生成、重复扣费、越权或数据泄露。
|
||||
- 本 PR 的修复边界到此为止:只保证 operation 已形成后,strict 保存失败或超时不会丢失 identity、不会重新上传,并且迟到 PATCH 不会继续触发 POST;不继续引入服务端 durable upload journal、上传 reservation、孤儿对象扫描/回收或历史数据清理,也不宣称撤销已发送的 PATCH。彻底消除上述崩溃窗口需要独立设计、评审和交付,不作为本 PR 的合并阻断项。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-04 generation 占位右键删除复用统一请求保护
|
||||
|
||||
- 缺陷:快捷键删除已在写历史前过滤未收口完美像素 operation,但 generation 占位的右键菜单仍直接调用低层 `removeCanvasGenerationDialogById`。低层会保留受保护 operation,上层却已写入 `delete-generation-result` 历史、清空选择并关闭交互,形成“占位未删但出现伪历史和 UI 副作用”的不一致;普通 generating dialog 也会绕过既有删除确认。
|
||||
- 决策:纯 generation-dialog 的右键删除在任何历史或选择副作用前委托给 `requestRemoveCanvasGenerationDialog`。未收口完美像素只激活原占位并显示继续对账/原样重试提示;普通 generating 进入现有确认弹窗;终态占位才执行真实删除。层命令保留低层回调给快捷键和混合选择的既有可删除目标,不扩大本次改动为删除系统重构。
|
||||
- 验证:层命令定向测试构造 `pending-confirmation + perfectPixelOperation` 右键目标,断言请求保护入口只调用一次、历史为零、选择保持、低层删除未调用且菜单收口。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
- strict 的本次结果事实零写入边界截至首个最终 PNG PUT:所有可预判的引用、归属、类型、静态编码、元数据、网格适用性和 CPU 处理错误必须在此前失败;前置 owner-scoped 项目 / 素材读取仍可能按既有语义懒建默认 canvas / folder,这些基础记录不属于本次完美像素结果。最终 PNG 的 OSS PUT / HEAD 位于数据库事务外;验证上传结果后,asset object、project resource、账号素材与可选 canvas completion 由单个受 runtime service identity 保护的 SpacetimeDB procedure 在一次事务中原子提交。operation 以 `owner + project + canvasCompletion.dialogId` 为作用域,task / object / resource / asset ID 稳定派生,object key 携带规范请求与输入 / 输出摘要形成的 fingerprint;同内容重放只返回原结果,输入漂移或部分既有事实失败关闭。HTTP timeout/drop 不能撤销已发往远端的 procedure,客户端仍须按稳定 `taskId / objectKey / resourceId` 对账,不能把未收到回包等同于未提交。
|
||||
- `POST /api/editor/images/pixel-art-snaps` 是有副作用的 unsafe POST。客户端不得为它配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发出后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前以 `401` 拒绝、刷新 token 后的既有认证恢复不属于业务副作用重放,保持通用行为。POST 回包中的 `project / resource / asset` 不是结果 verdict;首次成功回包、未知异常、人工 exact replay 和刷新恢复都只读取项目 GET。`perfectPixelOperation.submittedAt / reconcileUntil` 从稳定请求快照写入时建立统一 75 秒绝对窗口,POST 回包不能续期;读取必须立即执行一次,随后退避间隔不超过 5 秒,窗口已过期时仍执行一次即时 GET。每次项目读取使用 `requestJson.deadlineAt` 覆盖缺 token 补票、业务 fetch、401 refresh、重试退避与响应体读取;窗口内单次最多 10 秒且不得越过 `reconcileUntil`,过期后的唯一即时读取最多额外 10 秒。固定判据为:匹配 task 的唯一 resource 加已收口 dialog / 关联图层才是画布成功;dialog 不存在但存在匹配 task resource 才是 asset-only 成功;dialog 仍 generating、dialog 不存在且无匹配 resource、项目始终不可读或窗口耗尽均保持 unknown。素材库刷新只在项目终态后 fire-and-forget,同步抛错、异步拒绝或永久挂起都不得阻塞 verdict、项目快照应用和执行锁释放。
|
||||
- unknown 状态持久化为原 generation dialog 上的 `pending-confirmation + perfectPixelOperation`,普通删除和随源图层清理不得移除该 operation;用户只能继续 GET 对账或显式按原 identity 重放。人工重试只刷新观察窗口,POST JSON 必须与持久请求 byte-for-byte 一致,不得按当前画布、目录、类型或标题重建,也不得创建第二个 dialog / task / object / resource / asset。hydrate 后只做 GET,不自动 POST、上传或重建请求。处理成功但事务内权威 dialog 已删除时,后端保留 object / resource / asset 并返回 asset-only 事实,canvas / revision 不变;前端只有在项目 GET 看见匹配 task resource 后才能提示“已保存到素材库”。现有布局 CAS 没有 deletion tombstone,completion 与其它已持久化布局编辑冲突时继续按权威 revision 守卫收口;尚未防抖落库的本地编辑合并不在本批范围。
|
||||
- 删除 generation dialog 的按钮、快捷键和右键菜单必须在写画布历史、清选择或执行低层移除前经过同一请求保护入口。未收口完美像素 operation 只激活原占位并提示继续对账/原样重试,不写 `delete-generation-result` 伪历史、不清选择也不移除 identity;普通 generating dialog 继续进入既有删除确认,只有可立即删除的终态占位才真正写历史并清理。
|
||||
- 完美像素并发闸回归测试不得通过进程级队列 Atomic 的 before/after 判断“本用例未入队”。过期 deadline 用例只断言 `504`;queue guard 的 Drop 归还由独立用例覆盖,不引入 `--test-threads=1`、全局串行锁或其它串行化兜底。
|
||||
- 项目快照对账生成占位时必须检查全部同 ID 原始记录,不得用首项短路:通用 queued completion 只要任一记录未收口就执行既有第二次 GET;完美像素要求 operation dialog 唯一,命中多条时失败关闭为 `conflict`,不得按首条记录猜测成功。
|
||||
|
||||
|
||||
@@ -1801,6 +1801,48 @@ export function ImageCanvasEditorView({
|
||||
},
|
||||
[openLayerGenerationDialog],
|
||||
);
|
||||
const removeCanvasGenerationDialog = useCallback(
|
||||
(dialogId: string) => {
|
||||
captureCanvasHistory({ type: 'delete-generation-result', count: 1 });
|
||||
removeCanvasGenerationDialogById(dialogId);
|
||||
setSelectedLayerId(null);
|
||||
setSelectedLayerIds([]);
|
||||
setImageContextMenu(null);
|
||||
setContextMenu(null);
|
||||
setActiveTool('select');
|
||||
},
|
||||
[
|
||||
captureCanvasHistory,
|
||||
removeCanvasGenerationDialogById,
|
||||
setActiveTool,
|
||||
setContextMenu,
|
||||
setImageContextMenu,
|
||||
setSelectedLayerId,
|
||||
setSelectedLayerIds,
|
||||
],
|
||||
);
|
||||
const requestRemoveCanvasGenerationDialog = useCallback(
|
||||
(dialog: CanvasGenerationDialogState) => {
|
||||
if (isUnsettledPerfectPixelOperationDialog(dialog)) {
|
||||
activateCanvasGenerationDialog(dialog);
|
||||
showGenerationWarning(
|
||||
'完美像素结果尚未确认,当前占位用于保留原操作身份;请继续核对或按原操作重试。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (dialog.status === 'generating') {
|
||||
activateCanvasGenerationDialog(dialog);
|
||||
setPendingGenerationDeleteDialog(dialog);
|
||||
return;
|
||||
}
|
||||
removeCanvasGenerationDialog(dialog.id);
|
||||
},
|
||||
[
|
||||
activateCanvasGenerationDialog,
|
||||
removeCanvasGenerationDialog,
|
||||
showGenerationWarning,
|
||||
],
|
||||
);
|
||||
const contextMenuLayer =
|
||||
contextMenu?.kind === 'layer'
|
||||
? (layers.find((layer) => layer.id === contextMenu.layerId) ?? null)
|
||||
@@ -1842,6 +1884,7 @@ export function ImageCanvasEditorView({
|
||||
selectSingleLayer,
|
||||
onDeleteLayerSideEffects: clearDeletedLayerGenerationState,
|
||||
onDeleteGenerationDialogSideEffects: removeCanvasGenerationDialogById,
|
||||
onRequestDeleteGenerationDialog: requestRemoveCanvasGenerationDialog,
|
||||
exportLayerImage,
|
||||
});
|
||||
const {
|
||||
@@ -1940,26 +1983,6 @@ export function ImageCanvasEditorView({
|
||||
(layerId: string | null) => deleteLayerByIdRef.current(layerId),
|
||||
[],
|
||||
);
|
||||
const removeCanvasGenerationDialog = useCallback(
|
||||
(dialogId: string) => {
|
||||
captureCanvasHistory({ type: 'delete-generation-result', count: 1 });
|
||||
removeCanvasGenerationDialogById(dialogId);
|
||||
setSelectedLayerId(null);
|
||||
setSelectedLayerIds([]);
|
||||
setImageContextMenu(null);
|
||||
setContextMenu(null);
|
||||
setActiveTool('select');
|
||||
},
|
||||
[
|
||||
captureCanvasHistory,
|
||||
removeCanvasGenerationDialogById,
|
||||
setActiveTool,
|
||||
setContextMenu,
|
||||
setImageContextMenu,
|
||||
setSelectedLayerId,
|
||||
setSelectedLayerIds,
|
||||
],
|
||||
);
|
||||
// 中文注释:加载期的剥离只跑一次,当时未到期而被保留的孤儿占位需要这里补上到期清理,
|
||||
// 否则它会一直转到用户下一次加载。两条路径共用同一条文案,用户感知一致。
|
||||
useInlineGenerationPlaceholderExpiry({
|
||||
@@ -1969,28 +1992,6 @@ export function ImageCanvasEditorView({
|
||||
onPlaceholdersExpired: handleInlinePlaceholdersExpired,
|
||||
});
|
||||
|
||||
const requestRemoveCanvasGenerationDialog = useCallback(
|
||||
(dialog: CanvasGenerationDialogState) => {
|
||||
if (isUnsettledPerfectPixelOperationDialog(dialog)) {
|
||||
activateCanvasGenerationDialog(dialog);
|
||||
showGenerationWarning(
|
||||
'完美像素结果尚未确认,当前占位用于保留原操作身份;请继续核对或按原操作重试。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (dialog.status === 'generating') {
|
||||
activateCanvasGenerationDialog(dialog);
|
||||
setPendingGenerationDeleteDialog(dialog);
|
||||
return;
|
||||
}
|
||||
removeCanvasGenerationDialog(dialog.id);
|
||||
},
|
||||
[
|
||||
activateCanvasGenerationDialog,
|
||||
removeCanvasGenerationDialog,
|
||||
showGenerationWarning,
|
||||
],
|
||||
);
|
||||
const confirmRemoveGeneratingDialog = useCallback(() => {
|
||||
const dialog = pendingGenerationDeleteDialog;
|
||||
if (!dialog) {
|
||||
|
||||
@@ -109,8 +109,10 @@ function LayerCommandsHarness({
|
||||
exportLayerImage = vi.fn(),
|
||||
onDeleteLayerSideEffects = vi.fn(),
|
||||
onDeleteGenerationDialogSideEffects = vi.fn(),
|
||||
onRequestDeleteGenerationDialog,
|
||||
onCaptureCanvasHistory = vi.fn(),
|
||||
initialLayers,
|
||||
initialContextMenu,
|
||||
initialSelectedLayerId = 'first',
|
||||
initialSelectedLayerIds = ['first', 'second'],
|
||||
initialCanvasGenerationDialogs,
|
||||
@@ -121,8 +123,12 @@ function LayerCommandsHarness({
|
||||
) => void;
|
||||
onDeleteLayerSideEffects?: (layerId: string) => void;
|
||||
onDeleteGenerationDialogSideEffects?: (dialogId: string) => void;
|
||||
onRequestDeleteGenerationDialog?: (
|
||||
dialog: CanvasGenerationDialogState,
|
||||
) => void;
|
||||
onCaptureCanvasHistory?: (action: CanvasHistoryAction) => void;
|
||||
initialLayers?: CanvasLayer[];
|
||||
initialContextMenu?: CanvasContextMenuState;
|
||||
initialSelectedLayerId?: string | null;
|
||||
initialSelectedLayerIds?: string[];
|
||||
initialCanvasGenerationDialogs?: CanvasGenerationDialogState[];
|
||||
@@ -163,7 +169,7 @@ function LayerCommandsHarness({
|
||||
);
|
||||
const [hoveredLayerId, setHoveredLayerId] = useState<string | null>('first');
|
||||
const [contextMenu, setContextMenu] = useState<CanvasContextMenuState | null>(
|
||||
{
|
||||
initialContextMenu ?? {
|
||||
kind: 'layer',
|
||||
layerId: 'first',
|
||||
x: 0,
|
||||
@@ -202,6 +208,7 @@ function LayerCommandsHarness({
|
||||
selectSingleLayer,
|
||||
onDeleteLayerSideEffects,
|
||||
onDeleteGenerationDialogSideEffects,
|
||||
onRequestDeleteGenerationDialog,
|
||||
exportLayerImage,
|
||||
});
|
||||
|
||||
@@ -666,6 +673,48 @@ describe('useImageCanvasLayerCommands', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('delegates a generation-dialog context delete before history and selection side effects', () => {
|
||||
const dialog = createDurablePerfectPixelDialog(
|
||||
'perfect-pixel-context',
|
||||
'pending-confirmation',
|
||||
);
|
||||
const selectionId = `generation-dialog:${dialog.id}`;
|
||||
const onCaptureCanvasHistory = vi.fn();
|
||||
const onDeleteGenerationDialogSideEffects = vi.fn();
|
||||
const onRequestDeleteGenerationDialog = vi.fn();
|
||||
render(
|
||||
<LayerCommandsHarness
|
||||
initialContextMenu={{
|
||||
kind: 'generation-dialog',
|
||||
dialogId: dialog.id,
|
||||
x: 0,
|
||||
y: 0,
|
||||
canvasPoint: { x: 0, y: 0 },
|
||||
}}
|
||||
initialSelectedLayerId={selectionId}
|
||||
initialSelectedLayerIds={[selectionId]}
|
||||
initialCanvasGenerationDialogs={[dialog]}
|
||||
onCaptureCanvasHistory={onCaptureCanvasHistory}
|
||||
onDeleteGenerationDialogSideEffects={
|
||||
onDeleteGenerationDialogSideEffects
|
||||
}
|
||||
onRequestDeleteGenerationDialog={onRequestDeleteGenerationDialog}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除右键目标' }));
|
||||
|
||||
expect(onRequestDeleteGenerationDialog).toHaveBeenCalledTimes(1);
|
||||
expect(onRequestDeleteGenerationDialog).toHaveBeenCalledWith(dialog);
|
||||
expect(screen.getByTestId('history').textContent).toBe('0');
|
||||
expect(screen.getByTestId('selection').textContent).toBe(
|
||||
`${selectionId}:${selectionId}`,
|
||||
);
|
||||
expect(screen.getByTestId('context').textContent).toBe('closed');
|
||||
expect(onCaptureCanvasHistory).not.toHaveBeenCalled();
|
||||
expect(onDeleteGenerationDialogSideEffects).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes and counts only deletable targets in a mixed durable-dialog selection', () => {
|
||||
const dialog = createDurablePerfectPixelDialog(
|
||||
'perfect-pixel-pending',
|
||||
|
||||
@@ -57,6 +57,9 @@ type LayerCommandsOptions = {
|
||||
selectSingleLayer: (layerId: string | null) => void;
|
||||
onDeleteLayerSideEffects: (targetLayerId: string) => void;
|
||||
onDeleteGenerationDialogSideEffects?: (targetDialogId: string) => void;
|
||||
onRequestDeleteGenerationDialog?: (
|
||||
targetDialog: CanvasGenerationDialogState,
|
||||
) => void;
|
||||
exportLayerImage: (
|
||||
layer: CanvasLayer | null,
|
||||
options?: { mode?: ImageSequenceExportMode },
|
||||
@@ -146,6 +149,7 @@ export function useImageCanvasLayerCommands({
|
||||
selectSingleLayer,
|
||||
onDeleteLayerSideEffects,
|
||||
onDeleteGenerationDialogSideEffects,
|
||||
onRequestDeleteGenerationDialog,
|
||||
exportLayerImage,
|
||||
}: LayerCommandsOptions) {
|
||||
const [canvasClipboard, setCanvasClipboard] =
|
||||
@@ -527,12 +531,27 @@ export function useImageCanvasLayerCommands({
|
||||
const deleteContextLayers = useCallback(() => {
|
||||
const targetIds = getContextTargetLayerIds();
|
||||
const targetLayerIds = getSelectedLayerIds(targetIds);
|
||||
const targetDialogIds = targetIds
|
||||
const targetDialogs = targetIds
|
||||
.map(getCanvasGenerationDialogIdFromSelectionId)
|
||||
.filter((dialogId): dialogId is string => Boolean(dialogId))
|
||||
.filter((dialogId) =>
|
||||
canvasGenerationDialogs.some((dialog) => dialog.id === dialogId),
|
||||
);
|
||||
.flatMap((dialogId) => {
|
||||
const dialog = canvasGenerationDialogs.find(
|
||||
(candidate) => candidate.id === dialogId,
|
||||
);
|
||||
return dialog ? [dialog] : [];
|
||||
});
|
||||
if (
|
||||
!targetLayerIds.length &&
|
||||
targetDialogs.length &&
|
||||
onRequestDeleteGenerationDialog
|
||||
) {
|
||||
closeContextMenus();
|
||||
targetDialogs.forEach((dialog) => {
|
||||
onRequestDeleteGenerationDialog(dialog);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const targetDialogIds = targetDialogs.map((dialog) => dialog.id);
|
||||
if (!targetLayerIds.length && !targetDialogIds.length) {
|
||||
return;
|
||||
}
|
||||
@@ -566,6 +585,7 @@ export function useImageCanvasLayerCommands({
|
||||
getContextTargetLayerIds,
|
||||
onDeleteGenerationDialogSideEffects,
|
||||
onDeleteLayerSideEffects,
|
||||
onRequestDeleteGenerationDialog,
|
||||
selectSingleLayer,
|
||||
setHoveredLayerId,
|
||||
setLayers,
|
||||
|
||||
Reference in New Issue
Block a user