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