chore(incident): remove DualStateIncidentCard (Phase 6.5 old style, migrated to IncidentCard)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-01 20:10:20 +08:00
parent d14a58be0d
commit e623b1c4e9
2 changed files with 0 additions and 424 deletions

View File

@@ -1,420 +0,0 @@
'use client'
/**
* DualStateIncidentCard - Phase 6.5 雙態戰情室卡片
* ================================================
*
* Nothing.tech 視覺憲法:
* - 純白極簡 (bg-white/90)
* - 無深色模式
* - 嚴禁陰影 (shadow-none)
* - 細邊框 (border-[0.5px])
*
* 雙態設計:
* - normal: 淺灰邊框,靜態
* - alert: 紅色邊框,脈衝雷達動畫
*
* Phase 6.5: 決策令牌驅動
* - 使用 decision.proposal_data 顯示行動方案
* - decision.state === 'ready' 時解鎖按鈕
* - 點擊 Y/n 時先建立 Approval 再簽核
*
* Phase 6.5c: UX 改善 (2026-03-23)
* - 錯誤訊息明顯顯示 (不只是 hover)
* - 執行超時提示 (30秒)
* - 重試按鈕
*
* 統帥鐵律: 禁止假數據UI 永不鎖死!
*/
import React, { useState, useCallback, useEffect, useRef } from 'react'
import { useTranslations } from 'next-intl'
import type { DecisionInfo } from '@/lib/api-client';
import { apiClient } from '@/lib/api-client'
import { useCSRF } from '@/hooks/useCSRF'
type ButtonState = 'idle' | 'loading' | 'approved' | 'rejected' | 'error' | 'timeout'
const EXECUTION_TIMEOUT_MS = 30000 // 30 秒超時警告
export interface DualStateIncidentCardProps {
id: string
serviceName: string
status: 'normal' | 'alert'
tier?: 1 | 2 | 3
message: string
timestamp: string
/** @deprecated 使用 decision 取代 */
proposalId?: string
/** Phase 6.5: 決策令牌 (取代 proposalId) */
decision?: DecisionInfo | null
/** Callback when approval status changes */
onApprovalChange?: (proposalId: string, newStatus: 'approved' | 'rejected') => void
}
export const DualStateIncidentCard: React.FC<DualStateIncidentCardProps> = ({
id,
serviceName,
status,
tier,
message,
timestamp,
proposalId,
decision,
onApprovalChange,
}) => {
const t = useTranslations('incident.card')
const { csrfToken } = useCSRF()
const isAlert = status === 'alert'
const [buttonState, setButtonState] = useState<ButtonState>('idle')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [currentProposalId, setCurrentProposalId] = useState<string | null>(proposalId || null)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
// 清理 timeout
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [])
// Phase 6.5: 決策令牌驅動
// 按鈕可用條件: decision.state === 'ready' 或有 proposalId
const isDecisionReady = decision?.state === 'ready' || !!currentProposalId
const isAnalyzing = decision?.state === 'analyzing'
const decisionAction = decision?.proposal_data?.action || ''
const decisionReasoning = decision?.proposal_data?.reasoning || ''
/**
* 處理簽核 (Y 按鈕)
* Phase 6.5: 決策令牌驅動
* 1. 如果沒有 proposalId先從 incident 生成 proposal
* 2. 然後簽核該 proposal
*/
const handleApprove = useCallback(async () => {
console.log('🚀 指令授權中:', { proposalId: currentProposalId, decision: decision?.token })
// Phase 6.5: 決策必須就緒
if (!isDecisionReady || buttonState === 'loading') {
console.warn('⚠️ 授權阻斷: isDecisionReady=', isDecisionReady, 'buttonState=', buttonState)
return
}
// 關鍵: 立即進入 loading消除 300ms 感知延遲
setButtonState('loading')
setErrorMessage(null)
// Phase 6.5c: 30 秒超時警告 (如果未被清除則觸發)
if (timeoutRef.current) clearTimeout(timeoutRef.current)
timeoutRef.current = setTimeout(() => {
setButtonState('timeout')
setErrorMessage(t('timeoutMessage'))
}, EXECUTION_TIMEOUT_MS)
try {
let approvalId = currentProposalId
// Step 1: 如果沒有 proposalId先從 incident 生成
if (!approvalId && decision?.token) {
console.log('📝 生成 Proposal from incident:', id)
const proposalResult = await apiClient.generateProposal(id)
if (!proposalResult.success || !proposalResult.proposal) {
throw new Error(proposalResult.message || 'Failed to generate proposal')
}
approvalId = proposalResult.proposal.id
setCurrentProposalId(approvalId)
console.log('✅ Proposal 生成成功:', approvalId)
}
if (!approvalId) {
throw new Error('No approval ID available')
}
// Step 2: 簽核 (Phase 22 P0: 帶入 CSRF token)
const result = await apiClient.signApproval(approvalId, 'commander', 'Authorized via WarRoom', csrfToken)
console.log('✅ 簽核回應:', result)
// 清除超時計時器
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
// 🔧 Fix: 正確檢查 result.approval.status (非 result.status)
const approvalStatus = result.approval?.status?.toLowerCase()
if (approvalStatus === 'approved') {
setButtonState('approved')
console.log('🎯 授權成功,觸發 onApprovalChange')
onApprovalChange?.(approvalId, 'approved')
} else {
// Multi-sig: 還需要更多簽核
const current = result.approval?.current_signatures ?? result.current_signatures ?? 0
const required = result.approval?.required_signatures ?? result.required_signatures ?? 1
console.log('🔐 Multi-sig 等待中,目前簽核:', current, '/', required)
setButtonState('idle')
}
} catch (error) {
// 清除超時計時器
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
console.error('❌ 授權失敗:', error)
setButtonState('error')
// Phase 6.5c: 更清楚的錯誤訊息
const errMsg = error instanceof Error ? error.message : String(error)
setErrorMessage(errMsg)
// 不自動恢復,讓用戶看到錯誤並主動點擊重試
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- t is stable from next-intl
}, [currentProposalId, decision, id, isDecisionReady, buttonState, onApprovalChange, csrfToken])
/**
* 處理拒絕 (n 按鈕)
* Phase 6.5: 決策令牌驅動
*/
const handleReject = useCallback(async () => {
console.log('🛑 指令拒絕中:', { proposalId: currentProposalId, decision: decision?.token })
if (!isDecisionReady || buttonState === 'loading') {
console.warn('⚠️ 拒絕阻斷: isDecisionReady=', isDecisionReady, 'buttonState=', buttonState)
return
}
setButtonState('loading')
setErrorMessage(null)
// 超時計時器 (如果未被清除則觸發)
if (timeoutRef.current) clearTimeout(timeoutRef.current)
timeoutRef.current = setTimeout(() => {
setButtonState('timeout')
setErrorMessage(t('timeoutMessage'))
}, EXECUTION_TIMEOUT_MS)
try {
let approvalId = currentProposalId
// 如果沒有 proposalId先生成再拒絕
if (!approvalId && decision?.token) {
const proposalResult = await apiClient.generateProposal(id)
if (proposalResult.success && proposalResult.proposal) {
approvalId = proposalResult.proposal.id
setCurrentProposalId(approvalId)
}
}
if (!approvalId) {
throw new Error('No approval ID available')
}
await apiClient.rejectApproval(approvalId, 'Rejected via WarRoom', csrfToken)
// 清除超時計時器
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
console.log('✅ 拒絕成功')
setButtonState('rejected')
onApprovalChange?.(approvalId, 'rejected')
} catch (error) {
// 清除超時計時器
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
console.error('❌ 拒絕失敗:', error)
setButtonState('error')
const errMsg = error instanceof Error ? error.message : String(error)
setErrorMessage(errMsg)
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- t is stable from next-intl
}, [currentProposalId, decision, id, isDecisionReady, buttonState, onApprovalChange, csrfToken])
/**
* 渲染決策按鈕區塊
*/
const renderActionButton = () => {
switch (buttonState) {
case 'loading':
return (
<span className="px-3 py-1 bg-neutral-700 text-white text-xs flex items-center gap-2">
<span className="w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin" />
{t('executing')}
</span>
)
case 'approved':
return (
<span className="px-3 py-1 bg-green-600 text-white text-xs">
{t('approved')}
</span>
)
case 'rejected':
return (
<span className="px-3 py-1 bg-red-600 text-white text-xs">
{t('rejected')}
</span>
)
case 'error':
return (
<div className="flex flex-col gap-1 items-end">
<div className="flex items-center gap-1">
<span className="px-2 py-1 bg-red-600 text-white text-xs">
{t('error')}
</span>
<button
onClick={() => {
setButtonState('idle')
setErrorMessage(null)
}}
className="px-2 py-1 bg-neutral-700 text-white text-xs hover:bg-neutral-600 transition-colors"
>
{t('retry')}
</button>
</div>
{errorMessage && (
<span className="text-[10px] text-red-600 max-w-[200px] truncate" title={errorMessage}>
{errorMessage.length > 40 ? errorMessage.slice(0, 40) + '...' : errorMessage}
</span>
)}
</div>
)
case 'timeout':
return (
<div className="flex flex-col gap-1 items-end">
<div className="flex items-center gap-1">
<span className="px-2 py-1 bg-amber-500 text-white text-xs animate-pulse">
{t('timeout')}
</span>
<button
onClick={() => {
setButtonState('idle')
setErrorMessage(null)
}}
className="px-2 py-1 bg-neutral-700 text-white text-xs hover:bg-neutral-600 transition-colors"
>
{t('retry')}
</button>
</div>
<span className="text-[10px] text-amber-600">
{t('checkApiLogs')}
</span>
</div>
)
default:
return (
<div className="flex gap-1 items-center">
<button
onClick={handleApprove}
disabled={!isDecisionReady}
className="px-2 py-1 bg-neutral-900 text-white text-xs hover:bg-green-700 active:scale-95 active:bg-neutral-800 transition-all duration-100 cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed disabled:active:scale-100"
title={!isDecisionReady ? (isAnalyzing ? t('analyzing') : t('waitingDecision')) : (decisionAction || t('authorizeExecution'))}
>
Y
</button>
<span className="px-1 py-1 text-neutral-400 text-xs">/</span>
<button
onClick={handleReject}
disabled={!isDecisionReady}
className="px-2 py-1 bg-neutral-900 text-white text-xs hover:bg-red-700 active:scale-95 active:bg-neutral-800 transition-all duration-100 cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed disabled:active:scale-100"
title={!isDecisionReady ? (isAnalyzing ? t('analyzing') : t('waitingDecision')) : t('rejectProposal')}
>
n
</button>
{isAnalyzing && (
<span className="text-[10px] text-amber-500 ml-1 animate-pulse"></span>
)}
{decision?.proposal_data?.source && (
<span className="text-[9px] text-neutral-400 ml-1">
[{decision.proposal_data.source.includes('llm') ? 'AI' : 'EXP'}]
</span>
)}
</div>
)
}
}
return (
<div
className={`
relative p-4 w-full max-w-md font-mono text-sm transition-all duration-300
bg-white/90 backdrop-blur-md
${isAlert ? 'border border-red-500' : 'border-[0.5px] border-neutral-200'}
shadow-none
`}
>
{/* 異常脈衝雷達 (Ping Animation) */}
{isAlert && (
<span className="absolute top-4 right-4 flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-red-600"></span>
</span>
)}
{/* 標頭資訊 */}
<div className="flex justify-between items-center mb-3">
<span className="text-neutral-400 text-xs">{id}</span>
<span
className={`px-2 py-0.5 text-xs tracking-wider border-[0.5px] ${
isAlert
? 'bg-red-50 text-red-600 border-red-200'
: 'bg-neutral-50 text-neutral-500 border-neutral-200'
}`}
>
{serviceName}
</span>
</div>
{/* 核心數據與訊息 */}
<div
className={`mt-2 font-bold tracking-wide ${isAlert ? 'text-red-600' : 'text-neutral-800'}`}
>
{message}
</div>
<div className="mt-1 text-xs text-neutral-400">{timestamp}</div>
{/* 大腦決策層 - Phase 6.5 決策令牌驅動 */}
{isAlert && tier && (
<div className="mt-4 pt-3 border-t-[0.5px] border-red-200">
{/* 決策狀態 */}
<div className="flex justify-between items-center">
<span className="text-xs text-neutral-500">
{tier === 1 ? (
t('aiExecuting')
) : isAnalyzing ? (
t('brainAnalyzing')
) : isDecisionReady ? (
t('decisionReady', { tier })
) : (
t('waitingCommander', { tier })
)}
</span>
{tier > 1 && renderActionButton()}
</div>
{/* Phase 6.5: 顯示 AI 建議行動 */}
{decisionAction && tier > 1 && (
<div className="mt-2 p-2 bg-neutral-50 border-[0.5px] border-neutral-200 text-[10px] font-mono text-neutral-600">
<div className="text-neutral-400 mb-1">{t('suggestedAction')}</div>
<div className="text-neutral-800">{decisionAction}</div>
{decisionReasoning && (
<div className="mt-1 text-neutral-500 italic">
💡 {decisionReasoning.slice(0, 100)}{decisionReasoning.length > 100 ? '...' : ''}
</div>
)}
</div>
)}
</div>
)}
</div>
)
}
export default DualStateIncidentCard

View File

@@ -3,10 +3,6 @@
*/
export { IncidentCard } from './incident-card'
export {
DualStateIncidentCard,
type DualStateIncidentCardProps,
} from './dual-state-incident-card'
export {
ThinkingTerminal,
DEMO_DECISION_CHAIN,