补强资源冲突提示生命周期
按当前 scope 保留已丢弃手动意图的重新拖动提示 阻止资源重试结果与通用定时器静默清除冲突提示 在新手动布局使用最新 revision 保存成功后解除提示 同步收紧资源画布产品与技术合同
This commit is contained in:
+32
-10
@@ -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 会话有效的状态冒充项目持久化。
|
||||
|
||||
Reference in New Issue
Block a user