From 75880e463e28ae464bdab7578052d09bff039d6e Mon Sep 17 00:00:00 2001 From: IGNGserver <176017966+IGNGserver@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:52:47 +0800 Subject: [PATCH] fix(web): prevent tree item version conflicts and enhance recovery Ensure directory operations (reordering, single/batch deletion, and status changes) always consume the latest entity version and status from optimistic state projections. Gracefully handle version conflicts by triggering background synchronization and clear local optimistic overrides only when server responses reflect up-to-date revisions. Assisted-by: opencode/gemini-3.8-flash --- apps/web/src/TreePage.tsx | 106 ++++++++++++++++++++++++++++---------- 1 file changed, 80 insertions(+), 26 deletions(-) diff --git a/apps/web/src/TreePage.tsx b/apps/web/src/TreePage.tsx index 63d0839..c268b6c 100644 --- a/apps/web/src/TreePage.tsx +++ b/apps/web/src/TreePage.tsx @@ -376,9 +376,31 @@ export function TreePage() { setNextCursor(children.nextCursor ?? null); setCachedPage(children.cached === true); pagesLoaded.current = count; - // A fresh read is authoritative for the circle. - setStatusOverrides({}); - setFolderStatusOverrides({}); + // A fresh read clears overrides only when the server catch up to or exceeds local versions. + setStatusOverrides((current) => { + const next: Record = {}; + for (const row of rows) { + if (row.kind === 'TASK') { + const override = current[row.task.id]; + if (override && override.version > row.task.version) { + next[row.task.id] = override; + } + } + } + return next; + }); + setFolderStatusOverrides((current) => { + const next: Record = {}; + for (const row of rows) { + if (row.kind === 'FOLDER') { + const override = current[row.folder.id]; + if (override && override.version > row.folder.version) { + next[row.folder.id] = override; + } + } + } + return next; + }); if (keepPins) { // Drop rows that disappeared; anything still on screen keeps its place. const alive = rows.map(treeItemId); @@ -526,6 +548,39 @@ export function TreePage() { [folderStatusOverrides], ); + const latestItem = useCallback( + (item: TreeItemDto): TreeItemDto => + item.kind === 'TASK' + ? { ...item, task: displayTask(item.task) } + : { + ...item, + folder: displayFolder(item.folder), + aggregate: { + ...item.aggregate, + status: displayFolder(item.folder).status ?? item.aggregate.status, + }, + }, + [displayTask, displayFolder], + ); + + const handleTreeMutationError = useCallback( + (cause: unknown, fallback: string) => { + const isConflict = + (cause instanceof ApiError && cause.code === 'VERSION_CONFLICT') || + (cause instanceof Error && + (cause.message.includes('版本已变化') || + cause.message.includes('状态已变化') || + cause.message.includes('锚点已变化'))); + if (isConflict) { + setError('数据版本已被更新,已自动同步最新状态,请重试'); + void load(); + return; + } + setError(errorMessage(cause, fallback)); + }, + [load], + ); + /** * The group a row belongs to. A row the user just re-statused keeps the status * it was loaded with, so it only changes group on the next visit. @@ -673,15 +728,16 @@ export function TreePage() { } }; const deleteTask = (item: Extract) => { + const latestTask = displayTask(item.task); setConfirmation({ title: '永久删除任务', - description: `确定要永久删除“${item.task.title}”吗?它的备注、步骤和所有安排都会一并删除,此操作不可恢复。`, + description: `确定要永久删除“${latestTask.title}”吗?它的备注、步骤和所有安排都会一并删除,此操作不可恢复。`, confirmLabel: '永久删除', danger: true, onConfirm: async () => { try { - await mutationV2('DELETE', `/tasks/${item.task.id}`, { - baseVersion: item.task.version, + await mutationV2('DELETE', `/tasks/${latestTask.id}`, { + baseVersion: latestTask.version, }); await load(); notifyDataChanged(load); @@ -727,7 +783,7 @@ export function TreePage() { setGroupPins((pins) => (task.id in pins ? pins : { ...pins, [task.id]: task.status })); window.dispatchEvent(new Event('devtodo:data-changed')); } catch (cause) { - setError(cause instanceof Error ? cause.message : '更新任务状态失败,请重试'); + handleTreeMutationError(cause, '更新任务状态失败,请重试'); } }; const changeFolderStatus = async ( @@ -768,7 +824,7 @@ export function TreePage() { ); window.dispatchEvent(new Event('devtodo:data-changed')); } catch (cause) { - setError(cause instanceof Error ? cause.message : '更新文件夹状态失败,请重试'); + handleTreeMutationError(cause, '更新文件夹状态失败,请重试'); } }; const deleteFolder = async (item: Extract) => { @@ -830,44 +886,41 @@ export function TreePage() { } }; const moveWithinStatus = async (item: TreeItemDto, direction: 'up' | 'down') => { - const status = item.kind === 'FOLDER' ? item.aggregate.status : item.task.status; - const group = items.filter( - (candidate) => - (candidate.kind === 'FOLDER' ? candidate.aggregate.status : candidate.task.status) === - status, - ); + const latest = latestItem(item); + const status = itemStatus(latest); + const latestItems = items.map(latestItem); + const group = latestItems.filter((candidate) => itemStatus(candidate) === status); const index = group.findIndex( (candidate) => - candidate.kind === item.kind && - (candidate.kind === 'FOLDER' ? candidate.folder.id : candidate.task.id) === - (item.kind === 'FOLDER' ? item.folder.id : item.task.id), + candidate.kind === latest.kind && + treeItemId(candidate) === treeItemId(latest), ); const targetIndex = direction === 'up' ? index - 1 : index + 1; if (index < 0 || targetIndex < 0 || targetIndex >= group.length) return; const target = group[targetIndex]!; try { await mutationV2('POST', '/tree/items/move', { - item: { kind: item.kind, id: item.kind === 'FOLDER' ? item.folder.id : item.task.id }, + item: { kind: latest.kind, id: treeItemId(latest) }, parentFolderId: folderId, expectedStatus: status, - baseVersion: item.kind === 'FOLDER' ? item.folder.version : item.task.version, + baseVersion: itemRecord(latest).version, ...(direction === 'up' ? { before: { kind: target.kind, - id: target.kind === 'FOLDER' ? target.folder.id : target.task.id, + id: treeItemId(target), }, } : { after: { kind: target.kind, - id: target.kind === 'FOLDER' ? target.folder.id : target.task.id, + id: treeItemId(target), }, }), }); await load(); } catch (cause) { - setError(cause instanceof Error ? cause.message : '调整顺序失败,请重试'); + handleTreeMutationError(cause, '调整顺序失败,请重试'); } }; const dropItemBefore = async (target: TreeItemDto) => { @@ -1043,11 +1096,12 @@ export function TreePage() { const messages: string[] = []; for (const item of remaining) { try { - if (item.kind === 'TASK') - await mutationV2('DELETE', `/tasks/${item.task.id}`, { - baseVersion: item.task.version, + if (item.kind === 'TASK') { + const latestTask = displayTask(item.task); + await mutationV2('DELETE', `/tasks/${latestTask.id}`, { + baseVersion: latestTask.version, }); - else + } else await mutationV2('DELETE', `/folders/${item.folder.id}/tree`, { confirmationToken: previews.get(item.folder.id)!.confirmationToken, });