补强资源冲突提示生命周期
Project CI / Frontend tests (pull_request) Successful in 2m56s
Project CI / Backend tests (pull_request) Successful in 3m13s
Project CI / Native shell tests (pull_request) Successful in 11m2s
Project CI / Repository checks (pull_request) Successful in 54s

按当前 scope 保留已丢弃手动意图的重新拖动提示
阻止资源重试结果与通用定时器静默清除冲突提示
在新手动布局使用最新 revision 保存成功后解除提示
同步收紧资源画布产品与技术合同
This commit is contained in:
2026-07-31 11:31:08 +08:00
parent beec99a3b7
commit 39f7b06297
4 changed files with 52 additions and 12 deletions
@@ -123,6 +123,7 @@ export function useProjectResourceCanvasLayout({
});
const writeQueueRef = useRef<LayoutWriteIntent[]>([]);
const activeWriteIntentRef = useRef<LayoutWriteIntent | null>(null);
const redragRequiredScopeEpochRef = useRef<number | null>(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]);
@@ -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 () => {
@@ -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)
@@ -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 会话有效的状态冒充项目持久化。