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:
@@ -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
Reference in New Issue
Block a user