fix(web): 簽核後保留內容顯示 5 秒 (2026-03-26)

問題: 簽核後卡片立即消失,用戶無法確認已批准內容

修復:
- approval.store.ts: 簽核/拒絕後延遲 5 秒才移除
- live-approval-panel.tsx: 已解決項目顯示狀態橫幅

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-03-26 18:40:19 +08:00
parent 539f14bcd5
commit 7847e00b1b
2 changed files with 71 additions and 9 deletions

View File

@@ -266,20 +266,39 @@ export function LiveApprovalPanel({
{/* Approval Cards */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{approvals.map((approval) => (
{approvals.map((approval) => {
// 🔴 2026-03-26 修復: 檢查原始後端狀態 (透過 pendingApprovals 取得)
const backendApproval = pendingApprovals.find(a => a.id === approval.id)
const isResolved = backendApproval?.status === 'approved' || backendApproval?.status === 'rejected'
const resolvedStatus = backendApproval?.status
return (
<div
key={approval.id}
className={cn(
'transition-all duration-500 relative',
signingStates[approval.id] === 'success' && 'scale-95 opacity-50'
signingStates[approval.id] === 'success' && 'scale-95 opacity-70',
isResolved && 'opacity-80'
)}
>
{/* 🔴 已解決狀態橫幅 */}
{isResolved && (
<div className={cn(
'absolute -top-3 left-1/2 -translate-x-1/2 z-10 px-4 py-1 rounded-full font-mono text-xs font-bold uppercase tracking-wider shadow-lg',
resolvedStatus === 'approved'
? 'bg-status-healthy text-white'
: 'bg-status-critical text-white'
)}>
{resolvedStatus === 'approved' ? '✓ 已批准' : '✗ 已拒絕'}
</div>
)}
<ApprovalCard
request={approval}
onApprove={() => handleSign(approval.id, approval.riskLevel)}
onReject={() => handleReject(approval.id)}
holdDuration={2000}
isLoading={signingStates[approval.id] === 'signing'}
disabled={isResolved}
/>
{/* Permission Warning Badge (Phase 3) */}
@@ -302,7 +321,8 @@ export function LiveApprovalPanel({
</div>
)}
</div>
))}
)})}
</div>
{/* Reject Modal */}

View File

@@ -226,26 +226,36 @@ export const useApprovalStore = create<ApprovalState>()(
console.log('[Approval] Signed:', id, result.message)
// Update local state
// 🔴 2026-03-26 修復: 簽核後保留內容顯示,不立即移除
set((state) => {
const updatedApprovals = state.pendingApprovals.map((a) =>
a.id === id ? result.approval : a
)
// If approved, mark for removal animation
// If approved, mark for removal animation (但不立即移除)
const newRecentlyApproved = new Set(state.recentlyApproved)
if (result.approval.status === 'approved') {
newRecentlyApproved.add(id)
}
return {
pendingApprovals: result.approval.status === 'approved'
? updatedApprovals.filter((a) => a.id !== id)
: updatedApprovals,
// 🔴 修復: 保留在列表中,讓 UI 顯示已簽核狀態
pendingApprovals: updatedApprovals,
signingId: null,
recentlyApproved: newRecentlyApproved,
}
})
// 🔴 延遲 5 秒後才從列表移除 (讓用戶看到完整簽核結果)
if (result.approval.status === 'approved') {
setTimeout(() => {
set((state) => ({
pendingApprovals: state.pendingApprovals.filter((a) => a.id !== id),
}))
console.log('[Approval] Removed approved item after delay:', id)
}, 5000)
}
// 🔧 Race Condition 修復: 延遲 1 秒後恢復 Polling讓後端有時間更新
if (wasPolling) {
setTimeout(() => {
@@ -305,17 +315,31 @@ export const useApprovalStore = create<ApprovalState>()(
console.log('[Approval] Rejected:', id)
// Update local state
// 🔴 2026-03-26 修復: 拒絕後保留內容顯示,不立即移除
set((state) => {
const newRecentlyRejected = new Set(state.recentlyRejected)
newRecentlyRejected.add(id)
// 更新狀態但保留在列表中
const updatedApprovals = state.pendingApprovals.map((a) =>
a.id === id ? { ...a, status: 'rejected' as const } : a
)
return {
pendingApprovals: state.pendingApprovals.filter((a) => a.id !== id),
pendingApprovals: updatedApprovals,
rejectingId: null,
recentlyRejected: newRecentlyRejected,
}
})
// 🔴 延遲 5 秒後才從列表移除
setTimeout(() => {
set((state) => ({
pendingApprovals: state.pendingApprovals.filter((a) => a.id !== id),
}))
console.log('[Approval] Removed rejected item after delay:', id)
}, 5000)
// 🔧 Race Condition 修復: 延遲 1 秒後恢復 Polling
if (wasPolling) {
setTimeout(() => {
@@ -541,7 +565,25 @@ export const useApprovalSSEConnected = () =>
// Type Converters (Backend → Frontend)
// =============================================================================
// P1 修復: 使用 mapping object 取代 toUpperCase() as Type (2026-03-26)
const DATA_IMPACT_MAP: Record<string, 'NONE' | 'READ_ONLY' | 'WRITE' | 'DESTRUCTIVE'> = {
'none': 'NONE',
'read_only': 'READ_ONLY',
'read-only': 'READ_ONLY',
'readonly': 'READ_ONLY',
'write': 'WRITE',
'destructive': 'DESTRUCTIVE',
// 大寫版本 (防禦性)
'NONE': 'NONE',
'READ_ONLY': 'READ_ONLY',
'WRITE': 'WRITE',
'DESTRUCTIVE': 'DESTRUCTIVE',
}
export function toFrontendApproval(backend: ApprovalRequest): import('@/components/approval').ApprovalRequest {
const rawDataImpact = backend.blast_radius.data_impact || 'none'
const dataImpact = DATA_IMPACT_MAP[rawDataImpact] || DATA_IMPACT_MAP[rawDataImpact.toLowerCase()] || 'NONE'
return {
id: backend.id,
action: backend.action,
@@ -552,7 +594,7 @@ export function toFrontendApproval(backend: ApprovalRequest): import('@/componen
affectedPods: backend.blast_radius.affected_pods,
estimatedDowntime: backend.blast_radius.estimated_downtime,
relatedServices: backend.blast_radius.related_services,
dataImpact: backend.blast_radius.data_impact.toUpperCase() as 'NONE' | 'READ_ONLY' | 'WRITE' | 'DESTRUCTIVE',
dataImpact,
},
dryRunChecks: backend.dry_run_checks.map((c) => ({
name: c.name,