From 39f7b06297cf306c14af6c46de6b5fba250de52a Mon Sep 17 00:00:00 2001 From: menghao Date: Fri, 31 Jul 2026 11:31:08 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=BC=BA=E8=B5=84=E6=BA=90=E5=86=B2?= =?UTF-8?q?=E7=AA=81=E6=8F=90=E7=A4=BA=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按当前 scope 保留已丢弃手动意图的重新拖动提示 阻止资源重试结果与通用定时器静默清除冲突提示 在新手动布局使用最新 revision 保存成功后解除提示 同步收紧资源画布产品与技术合同 --- .../useProjectResourceCanvasLayout.ts | 42 ++++++++++++++----- .../useProjectResourceCanvasLayout.test.ts | 18 ++++++++ ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 2 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 4 files changed, 52 insertions(+), 12 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts index c84e13f23..745ca2c37 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts @@ -123,6 +123,7 @@ export function useProjectResourceCanvasLayout({ }); const writeQueueRef = useRef([]); const activeWriteIntentRef = useRef(null); + const redragRequiredScopeEpochRef = useRef(null); const pumpWritesRef = useRef<() => void>(() => undefined); const enqueueResourceSyncRef = useRef< (scopeEpoch: number, conflictRetries?: number) => void @@ -282,6 +283,9 @@ export function useProjectResourceCanvasLayout({ if (!isSafeProjectResourceCanvasLayoutRevision(expectedRevision)) { removeWriteIntent(intent); rebuildOptimisticLayout(scope.epoch); + if (intent.kind === 'manual') { + redragRequiredScopeEpochRef.current = null; + } setNotice( intent.kind === 'resources' ? '布局保存失败,已保留当前会话布局' @@ -322,6 +326,7 @@ export function useProjectResourceCanvasLayout({ removeWriteIntent(intent); if (result.status === 'updated') { if (intent.kind === 'manual') { + redragRequiredScopeEpochRef.current = null; setNotice('布局已保存'); } if ( @@ -350,14 +355,15 @@ export function useProjectResourceCanvasLayout({ const willRetryResourceSync = needsResourceSync && nextRetry <= MAX_RESOURCE_SYNC_CONFLICT_RETRIES; + const redragRequired = + intent.kind === 'manual' || discardedQueuedManualIntent; + if (redragRequired) { + redragRequiredScopeEpochRef.current = currentScope.epoch; + } if (willRetryResourceSync) { enqueueResourceSyncRef.current(currentScope.epoch, nextRetry); } - if ( - intent.kind === 'manual' || - discardedQueuedManualIntent || - !willRetryResourceSync - ) { + if (redragRequired || !willRetryResourceSync) { setNotice('布局已在其他窗口更新,请重新拖动'); } } @@ -370,11 +376,19 @@ export function useProjectResourceCanvasLayout({ } removeWriteIntent(intent); rebuildOptimisticLayout(currentScope.epoch); - setNotice( - intent.kind === 'resources' - ? '布局保存失败,已保留当前会话布局' - : '布局保存失败,已恢复上次布局', - ); + if (intent.kind === 'manual') { + redragRequiredScopeEpochRef.current = null; + } + if ( + intent.kind !== 'resources' || + redragRequiredScopeEpochRef.current !== currentScope.epoch + ) { + setNotice( + intent.kind === 'resources' + ? '布局保存失败,已保留当前会话布局' + : '布局保存失败,已恢复上次布局', + ); + } }) .finally(() => { if (activeWriteIntentRef.current === intent) { @@ -403,6 +417,7 @@ export function useProjectResourceCanvasLayout({ initializedScopeEpochRef.current = null; writeQueueRef.current = []; activeWriteIntentRef.current = null; + redragRequiredScopeEpochRef.current = null; }; }, []); @@ -419,6 +434,7 @@ export function useProjectResourceCanvasLayout({ initializedScopeEpochRef.current = null; writeQueueRef.current = []; activeWriteIntentRef.current = null; + redragRequiredScopeEpochRef.current = null; const initialFallback = reconcileResourceCanvasLayout( createEmptyResourceCanvasLayout(projectId, mode), resourcesRef.current, @@ -510,6 +526,12 @@ export function useProjectResourceCanvasLayout({ if (!notice) { return undefined; } + if ( + notice === '布局已在其他窗口更新,请重新拖动' && + redragRequiredScopeEpochRef.current === scopeRef.current.epoch + ) { + return undefined; + } const timeout = window.setTimeout(() => setNotice(''), 2400); return () => window.clearTimeout(timeout); }, [notice]); diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts index 595fff63c..d07892f34 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts @@ -61,6 +61,7 @@ function position( afterEach(() => { cleanup(); window.__TAURI__ = undefined; + vi.restoreAllMocks(); }); describe('useProjectResourceCanvasLayout', () => { @@ -502,6 +503,7 @@ describe('useProjectResourceCanvasLayout', () => { }, ); window.__TAURI__ = { core: { invoke } }; + const setTimeoutSpy = vi.spyOn(window, 'setTimeout'); const { result } = renderHook(() => useProjectResourceCanvasLayout({ projectPath, @@ -552,6 +554,22 @@ describe('useProjectResourceCanvasLayout', () => { ]), ); expect(result.current.notice).toBe('布局已在其他窗口更新,请重新拖动'); + expect(setTimeoutSpy.mock.calls.some(([, delay]) => delay === 2400)).toBe( + false, + ); + + act(() => result.current.commitPosition('resource-a', 'document', 500, 90)); + await waitFor(() => expect(updates).toHaveLength(3)); + await waitFor(() => expect(result.current.notice).toBe('布局已保存')); + expect(setTimeoutSpy.mock.calls.some(([, delay]) => delay === 2400)).toBe( + true, + ); + expect(updates[2]?.expectedRevision).toBe(3); + expect(updates[2]?.positions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ resourceId: 'resource-a', x: 500, y: 90 }), + ]), + ); }); it('ignores a late read from the previous mode', async () => { diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index 34b7c03b9..11a3771c3 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -244,7 +244,7 @@ type UpdateProjectResourceCanvasLayoutResult = - 同一 `projectPath + projectId + mode` 的首次读取与资源集合协调必须分开:资源集合变化不得取消已经发出的读取或保存。同一 scope 内全部手动拖动和资源自动协调写入使用同一 FIFO,任一时刻最多一个 CAS 在途,后一笔必须使用前一笔成功返回的 revision,不能用“最后请求获胜”跳过中间 CAS。切换项目或 mode 后,旧 scope 的在途请求不能阻塞新 scope 队列;前端放弃旧请求槽位并丢弃其迟到响应,后端继续依靠 `expectedProjectId + expectedRevision + 系统锁` 仲裁已发出的请求。 - 某笔 CAS 在途期间,同一 scope 内对相同 `resourceId + section` 重复产生但尚未发送的拖动意图必须折叠为最后坐标;已经在途的请求不得取消,不同资源的顺序不得跨越。队列增长必须受当前资源与分区数量约束,不能随连续 pointer 事件无界累积。 - 用户拖动结束后先乐观更新,再立即提交一次 CAS。成功后以返回布局更新 revision;普通写入失败时恢复最近可信持久布局并提示“布局保存失败,已恢复上次布局”。 -- CAS 冲突时直接载入返回的最新布局并提示“布局已在其他窗口更新,请重新拖动”,丢弃所有基于冲突前快照排队的手动拖动,不得自动重放本地旧坐标或静默覆盖另一窗口结果。即使当前在途请求是允许自动重试的资源协调,只要本次冲突实际清除了任何排队手动拖动,也必须保留重新拖动提示,后续资源协调重试成功不得静默清除。资源自动协调可以基于冲突返回的新 revision 有界重试,单次资源签名最多追加 `2` 次,持续跨窗口写入时不得无限自旋。 +- CAS 冲突时直接载入返回的最新布局并提示“布局已在其他窗口更新,请重新拖动”,丢弃所有基于冲突前快照排队的手动拖动,不得自动重放本地旧坐标或静默覆盖另一窗口结果。即使当前在途请求是允许自动重试的资源协调,只要本次冲突实际清除了任何排队手动拖动,也必须按当前 scope 保留重新拖动提示;后续资源协调成功、失败或通用提示定时器都不得静默清除,只有新的手动布局成功保存或切换 scope 才能解除。资源自动协调可以基于冲突返回的新 revision 有界重试,单次资源签名最多追加 `2` 次,持续跨窗口写入时不得无限自旋。 - 缺少 Tauri bridge 的浏览器开发态可以保留当前会话内布局用于界面测试,但不得宣称已经持久保存。 ### 5.3 资源类型与替换兼容性(P1) diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 3fa68381e..b0d24a812 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -340,7 +340,7 @@ game-project/ - 前端从当前项目开发大组件中拆出纯布局模型与持久 Hook。默认布局、碰撞检查、资源增删协调和 section 边界由纯模型负责;读取、异步身份、CAS、错误回滚和冲突载入由 Hook 负责。Hook 以 `projectPath + projectId + mode` epoch 隔离异步结果,资源变化不取消首读或在途保存;单窗口写入经同一 FIFO 串行提交,每笔都使用最近一次成功 / 冲突响应的权威 revision。视图使用 Pointer Events 做二维拖动,保存中仍允许继续拖动并排队,普通点击、搜索、筛选和唯一资源详情浮层语义保持不变。 - 新资源只在第一次进入某个 mode 时计算默认不重叠位置;全部现存坐标保持不变。搜索、筛选、窗口 resize 和 mode 切换不得重排或回写已有坐标,窄视图通过 section 画布范围与滚动访问,不裁切持久坐标。 - type 默认布局固定按 `subtype -> mediaType -> label -> id` 排序。manifest 资产的 subtype 使用 `asset.kind`,任务产物、导入附件和 Agent 文本成果使用稳定的来源 fallback;subtype 必须进入资源协调签名,不能因 MIME 相同而退化成按名称混排。 -- 普通保存失败恢复最近可信持久布局;CAS 冲突载入对方最新布局并要求用户重新拖动,同时清除基于旧快照排队的全部手动意图,不自动重放旧坐标。即使冲突发生在允许自动重试的资源协调请求上,只要本次冲突清除了排队手动意图,重新拖动提示就必须保留且不能被后续资源协调成功静默清除。资源自动协调可基于冲突布局最多追加两次重试,持续跨窗口竞争时停止自旋并保留当前会话协调结果。损坏、未知 schema、身份冲突、超限与链接文件失败关闭,不能用空布局覆盖原文件。 +- 普通保存失败恢复最近可信持久布局;CAS 冲突载入对方最新布局并要求用户重新拖动,同时清除基于旧快照排队的全部手动意图,不自动重放旧坐标。即使冲突发生在允许自动重试的资源协调请求上,只要本次冲突清除了排队手动意图,重新拖动提示就必须绑定当前 scope 保留,不得被后续资源协调成功、失败或通用提示定时器静默清除;新的手动布局成功保存或 scope 切换后才解除。资源自动协调可基于冲突布局最多追加两次重试,持续跨窗口竞争时停止自旋并保留当前会话协调结果。损坏、未知 schema、身份冲突、超限与链接文件失败关闭,不能用空布局覆盖原文件。 - 本切片不包含资源关系线、资源替换、详情浮层位置、缩放 / 平移、搜索 / 筛选条件、当前 mode,也不修改 `api-server` 或 SpacetimeDB。关系线与其它 P1 能力必须在本切片独立验收后继续接入。 实施顺序固定为:先同步 TypeScript / Rust DTO 与序列化测试,再实现 Tauri sidecar 读写和 CAS,随后接入前端纯模型、持久 Hook 与二维拖动,最后完成 Rust 安全测试、React 交互测试、跨重启 / 双窗口验收和文档状态回写。任何一步不得用 `localStorage`、manifest 字段或只在当前 React 会话有效的状态冒充项目持久化。