feat(web): #15 SSE + Optimistic Updates (樂觀更新)

Phase 15: 解決 Zustand Polling 與授權 API Race Condition

樂觀更新 (Optimistic UI):
- signApproval: 點擊瞬間更新簽章數和狀態
- rejectApproval: 點擊瞬間標記為 rejected
- 失敗自動回滾到原始狀態 (Rollback)

SSE 增量更新:
- 'created': 直接加入列表 (無需 re-fetch)
- 'signed': 增量更新簽章數
- 'rejected/expired/executed': 延遲移除

預期效益:
- 即時 UI 響應 (0ms 延遲)
- 減少 API 請求 (增量取代全量)
- Race Condition 消除

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-03-31 11:13:12 +08:00
parent 0b8701854d
commit 8c8664c75a
2 changed files with 102 additions and 9 deletions

View File

@@ -194,7 +194,7 @@ export const useApprovalStore = create<ApprovalState>()(
},
// ==========================================================================
// Sign Approval
// Sign Approval (Phase 15: Optimistic Updates)
// ==========================================================================
signApproval: async (id, signerId, signerName, comment, csrfToken) => {
// 🔧 Race Condition 修復: 簽核期間暫停 Polling
@@ -205,7 +205,36 @@ export const useApprovalStore = create<ApprovalState>()(
console.log('[Approval] Polling paused during sign')
}
set({ signingId: id, error: null })
// 🎯 Phase 15: 樂觀更新 - 立即更新 UI (Optimistic Update)
const state = get()
const originalApprovals = [...state.pendingApprovals]
const targetApproval = originalApprovals.find((a) => a.id === id)
if (targetApproval) {
// 樂觀更新: 假設簽核成功,立即增加簽章數
const optimisticApproval = {
...targetApproval,
current_signatures: targetApproval.current_signatures + 1,
// 如果達到要求簽章數,預設為 approved
status: (targetApproval.current_signatures + 1 >= targetApproval.required_signatures
? 'approved' : targetApproval.status) as ApprovalStatus,
}
set({
pendingApprovals: state.pendingApprovals.map((a) =>
a.id === id ? optimisticApproval : a
),
signingId: id,
error: null,
})
console.log('[Approval] Optimistic update applied:', id, {
signatures: `${optimisticApproval.current_signatures}/${optimisticApproval.required_signatures}`,
status: optimisticApproval.status,
})
} else {
set({ signingId: id, error: null })
}
try {
// Phase 20: CSRF Protection
@@ -276,10 +305,14 @@ export const useApprovalStore = create<ApprovalState>()(
return result
} catch (err) {
console.error('[Approval] Sign failed:', err)
// 🎯 Phase 15: 樂觀更新失敗 - 回滾到原始狀態 (Rollback)
set({
pendingApprovals: originalApprovals,
signingId: null,
error: `Sign failed: ${err}`,
})
console.log('[Approval] Rollback applied due to error')
// 🔧 失敗也要恢復 Polling
if (wasPolling) {
@@ -292,7 +325,7 @@ export const useApprovalStore = create<ApprovalState>()(
},
// ==========================================================================
// Reject Approval
// Reject Approval (Phase 15: Optimistic Updates)
// ==========================================================================
rejectApproval: async (id, rejectorId, rejectorName, reason, csrfToken) => {
// 🔧 Race Condition 修復: 拒絕期間暫停 Polling
@@ -303,7 +336,20 @@ export const useApprovalStore = create<ApprovalState>()(
console.log('[Approval] Polling paused during reject')
}
set({ rejectingId: id, error: null })
// 🎯 Phase 15: 樂觀更新 - 立即更新 UI (Optimistic Update)
const state = get()
const originalApprovals = [...state.pendingApprovals]
// 樂觀更新: 假設拒絕成功,立即更新狀態
set({
pendingApprovals: state.pendingApprovals.map((a) =>
a.id === id ? { ...a, status: 'rejected' as ApprovalStatus } : a
),
rejectingId: id,
error: null,
})
console.log('[Approval] Optimistic reject applied:', id)
try {
// Phase 20: CSRF Protection
@@ -367,10 +413,14 @@ export const useApprovalStore = create<ApprovalState>()(
return true
} catch (err) {
console.error('[Approval] Reject failed:', err)
// 🎯 Phase 15: 樂觀更新失敗 - 回滾到原始狀態 (Rollback)
set({
pendingApprovals: originalApprovals,
rejectingId: null,
error: `Reject failed: ${err}`,
})
console.log('[Approval] Rollback applied due to reject error')
// 🔧 失敗也要恢復 Polling
if (wasPolling) {
@@ -424,15 +474,58 @@ export const useApprovalStore = create<ApprovalState>()(
})
}
// Handle approval events
// Handle approval events (Phase 15: Incremental Updates)
eventSource.addEventListener('approval', (e: MessageEvent) => {
try {
const data = JSON.parse(e.data)
const data = JSON.parse(e.data) as {
action: 'created' | 'signed' | 'rejected' | 'expired' | 'executed'
approval_id: string
approval?: ApprovalRequest
}
console.log('[Approval SSE] Event:', data.action, data.approval_id)
// Re-fetch pending approvals on any change
get().fetchPending()
// 🎯 Phase 15: 增量更新取代全量 re-fetch
switch (data.action) {
case 'created':
// 新增: 如果有完整資料,直接加入列表
if (data.approval) {
set((state) => ({
pendingApprovals: [data.approval!, ...state.pendingApprovals],
}))
} else {
// 無完整資料時才 re-fetch
get().fetchPending()
}
break
case 'signed':
// 簽核: 更新或確認樂觀更新
if (data.approval) {
set((state) => ({
pendingApprovals: state.pendingApprovals.map((a) =>
a.id === data.approval_id ? data.approval! : a
),
}))
}
break
case 'rejected':
case 'expired':
case 'executed':
// 移除: 直接從列表移除 (延遲 2 秒讓 UI 顯示最終狀態)
setTimeout(() => {
set((state) => ({
pendingApprovals: state.pendingApprovals.filter(
(a) => a.id !== data.approval_id
),
}))
}, 2000)
break
default:
// 未知事件類型: re-fetch 作為 fallback
get().fetchPending()
}
} catch (err) {
console.error('[Approval SSE] Failed to parse event:', err)
}

File diff suppressed because one or more lines are too long