feat(incident): redesign IncidentCard with FlowPipeline, migrate auth logic from DualState
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,273 +1,382 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* IncidentCard - Phase 7 事件卡片
|
||||
* ================================
|
||||
* IncidentCard - AI中心 v6 事件卡片
|
||||
* =====================================
|
||||
* Anthropic Warmth 米色系 + OpenClaw Blue
|
||||
* - 頂邊條顏色 = 嚴重度(P0=紅/P2=橙/P3=藍/OK=綠)
|
||||
* - FlowPipeline 7節點 + Q版龍蝦
|
||||
* - 授權邏輯從 DualStateIncidentCard (Phase 6.5) 移植
|
||||
* handleApprove / handleReject / CSRF / 30s 超時重試
|
||||
*
|
||||
* Nothing.tech 視覺規範:
|
||||
* - 純白底色 (bg-white)
|
||||
* - 極細淺灰邊框 (border border-gray-200)
|
||||
* - 無圓角或微圓角 (rounded-sm)
|
||||
* - 嚴禁陰影 (shadow-none)
|
||||
*
|
||||
* 統帥鐵律: 禁止假數據!所有資料必須來自真實 API
|
||||
* 統帥鐵律: 禁止假數據!無數據顯示 "--"
|
||||
* @deprecated dual-state-incident-card.tsx (Phase 6.5 舊風格,此元件取代後刪除)
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { IncidentResponse } from '@/lib/api-client'
|
||||
import { AlertTriangle, Clock, Server, ChevronRight } from 'lucide-react'
|
||||
import type { IncidentResponse, DecisionInfo } from '@/lib/api-client'
|
||||
import { apiClient } from '@/lib/api-client'
|
||||
import { useCSRF } from '@/hooks/useCSRF'
|
||||
import { FlowPipeline, type FlowStage } from './flow-pipeline'
|
||||
|
||||
// =============================================================================
|
||||
// Severity Config
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
const SEVERITY_CONFIG = {
|
||||
P0: {
|
||||
label: 'P0',
|
||||
color: 'text-red-600',
|
||||
bgColor: 'bg-red-50',
|
||||
borderColor: 'border-red-200',
|
||||
dotColor: 'bg-red-500',
|
||||
},
|
||||
P1: {
|
||||
label: 'P1',
|
||||
color: 'text-orange-600',
|
||||
bgColor: 'bg-orange-50',
|
||||
borderColor: 'border-orange-200',
|
||||
dotColor: 'bg-orange-500',
|
||||
},
|
||||
P2: {
|
||||
label: 'P2',
|
||||
color: 'text-amber-600',
|
||||
bgColor: 'bg-amber-50',
|
||||
borderColor: 'border-amber-200',
|
||||
dotColor: 'bg-amber-500',
|
||||
},
|
||||
P3: {
|
||||
label: 'P3',
|
||||
color: 'text-gray-600',
|
||||
bgColor: 'bg-gray-50',
|
||||
borderColor: 'border-gray-200',
|
||||
dotColor: 'bg-gray-400',
|
||||
},
|
||||
type ButtonState = 'idle' | 'loading' | 'approved' | 'rejected' | 'error' | 'timeout'
|
||||
|
||||
const EXECUTION_TIMEOUT_MS = 30000
|
||||
|
||||
export interface IncidentCardProps {
|
||||
incident: IncidentResponse
|
||||
decision?: DecisionInfo | null
|
||||
onApprovalChange?: (proposalId: string, newStatus: 'approved' | 'rejected') => void
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Severity / Status Config
|
||||
// =============================================================================
|
||||
|
||||
const SEV_CONFIG = {
|
||||
P0: { barColor: '#cc2200', label: 'P0', labelBg: '#ffeaea', labelColor: '#cc2200' },
|
||||
P1: { barColor: '#d97757', label: 'P1', labelBg: '#fff0ea', labelColor: '#b05030' },
|
||||
P2: { barColor: '#4A90D9', label: 'P2', labelBg: '#eaf4ff', labelColor: '#2a6cb0' },
|
||||
P3: { barColor: '#22C55E', label: 'P3', labelBg: '#eafff2', labelColor: '#166534' },
|
||||
} as const
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
investigating: {
|
||||
label: 'investigating',
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-50',
|
||||
},
|
||||
mitigating: {
|
||||
label: 'mitigating',
|
||||
color: 'text-purple-600',
|
||||
bgColor: 'bg-purple-50',
|
||||
},
|
||||
resolved: {
|
||||
label: 'resolved',
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-50',
|
||||
},
|
||||
closed: {
|
||||
label: 'closed',
|
||||
color: 'text-gray-500',
|
||||
bgColor: 'bg-gray-50',
|
||||
},
|
||||
} as const
|
||||
/** 根據 incident status 對應 FlowStage */
|
||||
function toFlowStage(status: string, severity: string): FlowStage {
|
||||
switch (status) {
|
||||
case 'new': return 'alert'
|
||||
case 'investigating': return 'detection'
|
||||
case 'analyzing': return 'analysis'
|
||||
case 'proposal_generated': return 'proposal'
|
||||
case 'waiting_approval': return 'approval'
|
||||
case 'executing': return 'execution'
|
||||
case 'resolved': return 'resolved'
|
||||
default: return severity === 'P0' ? 'alert' : 'detection'
|
||||
}
|
||||
}
|
||||
|
||||
/** 格式化持續時間 */
|
||||
function formatDuration(createdAt: string | undefined): string {
|
||||
if (!createdAt) return '--'
|
||||
try {
|
||||
const ms = Date.now() - new Date(createdAt).getTime()
|
||||
const mins = Math.floor(ms / 60000)
|
||||
if (mins < 60) return `${mins}m`
|
||||
return `${Math.floor(mins / 60)}h${mins % 60}m`
|
||||
} catch {
|
||||
return '--'
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
interface IncidentCardProps {
|
||||
incident: IncidentResponse
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
}
|
||||
export function IncidentCard({ incident, decision, onApprovalChange }: IncidentCardProps) {
|
||||
const t = useTranslations('incident.card')
|
||||
const { csrfToken } = useCSRF()
|
||||
|
||||
export function IncidentCard({
|
||||
incident,
|
||||
onClick,
|
||||
className,
|
||||
}: IncidentCardProps) {
|
||||
const t = useTranslations('incident')
|
||||
const [buttonState, setButtonState] = useState<ButtonState>('idle')
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [currentProposalId, setCurrentProposalId] = useState<string | null>(null)
|
||||
const [aiExpanded, setAiExpanded] = useState(false)
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const severityConfig = SEVERITY_CONFIG[incident.severity]
|
||||
const statusConfig = STATUS_CONFIG[incident.status]
|
||||
useEffect(() => {
|
||||
return () => { if (timeoutRef.current) clearTimeout(timeoutRef.current) }
|
||||
}, [])
|
||||
|
||||
// 格式化時間
|
||||
const createdAt = new Date(incident.created_at)
|
||||
const timeAgo = getTimeAgo(createdAt)
|
||||
const incidentStatus = incident.status as string
|
||||
const sev = incident.severity as keyof typeof SEV_CONFIG
|
||||
const sevCfg = SEV_CONFIG[sev] ?? SEV_CONFIG.P3
|
||||
const flowStage = toFlowStage(incidentStatus, incident.severity)
|
||||
const isResolved = incidentStatus === 'resolved'
|
||||
const isWaitingApproval = incidentStatus === 'waiting_approval'
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
// Nothing.tech 核心規範
|
||||
'bg-white',
|
||||
'border border-gray-200',
|
||||
'rounded-sm',
|
||||
'shadow-none',
|
||||
// 互動狀態
|
||||
'transition-colors duration-150',
|
||||
onClick && 'cursor-pointer hover:bg-gray-50 hover:border-gray-300',
|
||||
// 佈局 (p-6 靈魂注入)
|
||||
'p-6',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header: ID + Severity Badge */}
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{/* Severity Dot */}
|
||||
<span
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full flex-shrink-0',
|
||||
severityConfig.dotColor,
|
||||
(incident.severity === 'P0' || incident.severity === 'P1') &&
|
||||
'animate-pulse'
|
||||
)}
|
||||
/>
|
||||
{/* Incident ID */}
|
||||
<span className="font-mono text-sm text-ink truncate">
|
||||
{incident.incident_id}
|
||||
const serviceName = incident.affected_services?.[0] ?? '--'
|
||||
const duration = formatDuration(incident.created_at)
|
||||
const isDecisionReady = decision?.state === 'ready' || !!currentProposalId
|
||||
const isAnalyzing = decision?.state === 'analyzing'
|
||||
const decisionAction = decision?.proposal_data?.action ?? ''
|
||||
const decisionReasoning = decision?.proposal_data?.reasoning ?? ''
|
||||
|
||||
// ── handleApprove(移植自 DualStateIncidentCard Phase 6.5)──────────────
|
||||
const handleApprove = useCallback(async () => {
|
||||
if (!isDecisionReady || buttonState === 'loading') 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
|
||||
if (!approvalId && decision?.token) {
|
||||
const proposalResult = await apiClient.generateProposal(incident.incident_id)
|
||||
if (!proposalResult.success || !proposalResult.proposal) {
|
||||
throw new Error(proposalResult.message || 'Failed to generate proposal')
|
||||
}
|
||||
approvalId = proposalResult.proposal.id
|
||||
setCurrentProposalId(approvalId)
|
||||
}
|
||||
if (!approvalId) throw new Error('No approval ID available')
|
||||
const result = await apiClient.signApproval(approvalId, 'commander', 'Authorized via AI Center', csrfToken)
|
||||
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null }
|
||||
const approvalStatus = result.approval?.status?.toLowerCase()
|
||||
if (approvalStatus === 'approved') {
|
||||
setButtonState('approved')
|
||||
onApprovalChange?.(approvalId, 'approved')
|
||||
} else {
|
||||
setButtonState('idle')
|
||||
}
|
||||
} catch (error) {
|
||||
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null }
|
||||
setButtonState('error')
|
||||
setErrorMessage(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentProposalId, decision, incident.incident_id, isDecisionReady, buttonState, onApprovalChange, csrfToken])
|
||||
|
||||
// ── handleReject(移植自 DualStateIncidentCard Phase 6.5)────────────────
|
||||
const handleReject = useCallback(async () => {
|
||||
if (!isDecisionReady || buttonState === 'loading') 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
|
||||
if (!approvalId && decision?.token) {
|
||||
const proposalResult = await apiClient.generateProposal(incident.incident_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 AI Center', csrfToken)
|
||||
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null }
|
||||
setButtonState('rejected')
|
||||
onApprovalChange?.(approvalId, 'rejected')
|
||||
} catch (error) {
|
||||
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null }
|
||||
setButtonState('error')
|
||||
setErrorMessage(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentProposalId, decision, incident.incident_id, isDecisionReady, buttonState, onApprovalChange, csrfToken])
|
||||
|
||||
// ── 授權按鈕渲染 ───────────────────────────────────────────────────────────
|
||||
const renderApproveButtons = () => {
|
||||
switch (buttonState) {
|
||||
case 'loading':
|
||||
return (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, color: '#87867f' }}>
|
||||
<span style={{ width: 8, height: 8, border: '1.5px solid #e0ddd4', borderTopColor: '#d97757', borderRadius: '50%', animation: 'spin 0.8s linear infinite', display: 'inline-block' }} />
|
||||
{t('executing')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Severity Badge (Dot Matrix 靈魂注入) */}
|
||||
<span
|
||||
className={cn(
|
||||
'px-3 py-1 text-sm font-dot-matrix font-bold rounded-sm border',
|
||||
severityConfig.bgColor,
|
||||
severityConfig.color,
|
||||
severityConfig.borderColor
|
||||
)}
|
||||
>
|
||||
{severityConfig.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="mb-4">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center px-2.5 py-1 text-xs font-mono font-medium rounded-sm',
|
||||
statusConfig.bgColor,
|
||||
statusConfig.color
|
||||
)}
|
||||
>
|
||||
{t(`status.${statusConfig.label}`)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Affected Services */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-1.5 text-xs text-ink-secondary mb-2">
|
||||
<Server className="w-3 h-3" />
|
||||
<span>{t('affectedServices')}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{incident.affected_services.slice(0, 3).map((service) => (
|
||||
<span
|
||||
key={service}
|
||||
className="px-2 py-1 text-xs font-mono bg-gray-100 text-ink rounded-sm"
|
||||
)
|
||||
case 'approved':
|
||||
return <span style={{ fontSize: 9, color: '#22C55E', fontWeight: 700 }}>✓ {t('approved')}</span>
|
||||
case 'rejected':
|
||||
return <span style={{ fontSize: 9, color: '#cc2200', fontWeight: 700 }}>✗ {t('rejected')}</span>
|
||||
case 'error':
|
||||
case 'timeout':
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ fontSize: 9, color: buttonState === 'timeout' ? '#F59E0B' : '#cc2200' }}>
|
||||
{buttonState === 'timeout' ? t('timeout') : t('error')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { setButtonState('idle'); setErrorMessage(null) }}
|
||||
style={{ fontSize: 9, padding: '2px 7px', background: '#f0efe8', border: '0.5px solid #e0ddd4', borderRadius: 10, cursor: 'pointer', color: '#87867f' }}
|
||||
>
|
||||
{service}
|
||||
</span>
|
||||
))}
|
||||
{incident.affected_services.length > 3 && (
|
||||
<span className="px-2 py-1 text-xs text-ink-secondary">
|
||||
+{incident.affected_services.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{t('retry')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<button
|
||||
onClick={handleApprove}
|
||||
disabled={!isDecisionReady}
|
||||
style={{
|
||||
padding: '5px 16px',
|
||||
background: '#141413',
|
||||
color: '#f5f4ed',
|
||||
border: 'none',
|
||||
borderRadius: 20,
|
||||
fontSize: 10,
|
||||
cursor: isDecisionReady ? 'pointer' : 'not-allowed',
|
||||
opacity: isDecisionReady ? 1 : 0.4,
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
title={isAnalyzing ? t('analyzing') : decisionAction || t('authorizeExecution')}
|
||||
>
|
||||
✓ {t('authorize')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReject}
|
||||
disabled={!isDecisionReady}
|
||||
style={{
|
||||
padding: '5px 16px',
|
||||
background: 'transparent',
|
||||
color: '#87867f',
|
||||
border: '0.5px solid #e0ddd4',
|
||||
borderRadius: 20,
|
||||
fontSize: 10,
|
||||
cursor: isDecisionReady ? 'pointer' : 'not-allowed',
|
||||
opacity: isDecisionReady ? 1 : 0.4,
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
✗ {t('reject')}
|
||||
</button>
|
||||
{isAnalyzing && <span style={{ fontSize: 9, color: '#F59E0B' }}>⏳</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
{/* Footer: Stats + Time */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
|
||||
<div className="flex items-center gap-4 text-xs text-ink-secondary">
|
||||
{/* Signal Count */}
|
||||
<span className="flex items-center gap-1.5 font-mono">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
<span className="font-dot-matrix text-ink">{incident.signal_count}</span> {t('signals')}
|
||||
</span>
|
||||
{/* Proposal Count */}
|
||||
{incident.proposal_count > 0 && (
|
||||
<span className="flex items-center gap-1.5 font-mono text-purple-600">
|
||||
<span className="font-dot-matrix">{incident.proposal_count}</span> {t('proposals')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Time + Arrow */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-ink-secondary">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
<span className="font-mono">{timeAgo}</span>
|
||||
{onClick && <ChevronRight className="w-3.5 h-3.5" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Grid Container
|
||||
// =============================================================================
|
||||
|
||||
interface IncidentCardGridProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function IncidentCardGrid({ children, className }: IncidentCardGridProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-1 md:grid-cols-2 gap-3',
|
||||
className
|
||||
<div style={{
|
||||
background: '#faf9f3',
|
||||
border: '0.5px solid #e0ddd4',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
{/* 頂邊條 3px */}
|
||||
<div style={{ height: 3, background: sevCfg.barColor, borderRadius: '8px 8px 0 0' }} />
|
||||
|
||||
{/* 標頭列:嚴重度 + 服務標籤 + 時間 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 10px 4px' }}>
|
||||
<span style={{
|
||||
fontSize: 9, fontWeight: 700, padding: '2px 8px',
|
||||
background: sevCfg.labelBg, color: sevCfg.labelColor,
|
||||
borderRadius: 10, flexShrink: 0,
|
||||
}}>
|
||||
{sevCfg.label}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 9, color: '#87867f', background: '#ece9e0',
|
||||
border: '0.5px solid #dedad0', padding: '2px 7px', borderRadius: 10,
|
||||
}}>
|
||||
{serviceName}
|
||||
</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 9, color: '#b0ad9f' }}>
|
||||
{duration}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 事件標題 */}
|
||||
<div style={{ padding: '2px 10px 4px', fontSize: 11, fontWeight: 600, color: '#141413', lineHeight: 1.4 }}>
|
||||
{(incident as IncidentResponse & { description?: string }).description ?? '--'}
|
||||
</div>
|
||||
|
||||
{/* INC-ID */}
|
||||
<div style={{ padding: '0 10px 4px', fontSize: 9, color: '#b0ad9f', fontFamily: 'monospace' }}>
|
||||
{incident.incident_id}
|
||||
</div>
|
||||
|
||||
{/* 流程狀態圖 */}
|
||||
<FlowPipeline activeStage={flowStage} isResolved={isResolved} />
|
||||
|
||||
{/* Impact 指標列 */}
|
||||
<div style={{
|
||||
margin: '0 10px 6px',
|
||||
padding: '5px 8px',
|
||||
background: '#f0efe8',
|
||||
border: '0.5px solid #e0ddd4',
|
||||
borderRadius: 6,
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap' as const,
|
||||
}}>
|
||||
<span style={{ fontSize: 9, color: '#87867f' }}>
|
||||
影響服務 <strong style={{ color: '#141413' }}>{incident.affected_services?.length ?? 0}</strong>
|
||||
</span>
|
||||
<span style={{ fontSize: 9, color: '#87867f' }}>
|
||||
信號數 <strong style={{ color: '#141413' }}>{incident.signal_count ?? '--'}</strong>
|
||||
</span>
|
||||
<span style={{ fontSize: 9, color: '#87867f' }}>
|
||||
狀態 <strong style={{ color: '#141413' }}>{incident.status}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* AI 提案行(可展開)*/}
|
||||
{decisionAction && (
|
||||
<>
|
||||
<div
|
||||
onClick={() => setAiExpanded(v => !v)}
|
||||
style={{
|
||||
margin: '0 10px 6px',
|
||||
padding: '5px 8px',
|
||||
background: '#f0efe8',
|
||||
border: '0.5px solid #e0ddd4',
|
||||
borderLeft: '3px solid rgba(217,119,87,0.5)',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
fontSize: 9,
|
||||
color: '#87867f',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#d97757' }}>▶</span>
|
||||
<span>AI 提案: {decisionAction.slice(0, 50)}{decisionAction.length > 50 ? '...' : ''}</span>
|
||||
<span style={{ marginLeft: 'auto' }}>{aiExpanded ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
{aiExpanded && (
|
||||
<div style={{
|
||||
margin: '0 10px 6px',
|
||||
padding: '8px',
|
||||
background: '#f0efe8',
|
||||
border: '0.5px solid #e0ddd4',
|
||||
borderLeft: '3px solid #d97757',
|
||||
borderRadius: 6,
|
||||
fontSize: 9,
|
||||
color: '#87867f',
|
||||
}}>
|
||||
<div style={{ color: '#141413', marginBottom: 4 }}>{decisionAction}</div>
|
||||
{decisionReasoning && (
|
||||
<div style={{ color: '#87867f', fontStyle: 'italic' }}>
|
||||
💡 {decisionReasoning.slice(0, 150)}{decisionReasoning.length > 150 ? '...' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
{/* 授權按鈕(僅 waiting_approval 狀態)*/}
|
||||
{isWaitingApproval && (
|
||||
<div style={{ padding: '6px 10px 10px', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
{renderApproveButtons()}
|
||||
{errorMessage && (
|
||||
<span style={{ fontSize: 8, color: '#cc2200', marginLeft: 6, maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={errorMessage}>
|
||||
{errorMessage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* spin keyframe */}
|
||||
<style>{`@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Empty State
|
||||
// =============================================================================
|
||||
|
||||
export function IncidentEmptyState() {
|
||||
const t = useTranslations('incident')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center mb-4">
|
||||
<AlertTriangle className="w-6 h-6 text-gray-400" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 font-medium">
|
||||
{t('emptyState')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{t('emptyStateDescription')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Utilities
|
||||
// =============================================================================
|
||||
|
||||
function getTimeAgo(date: Date): string {
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
const diffDays = Math.floor(diffMs / 86400000)
|
||||
|
||||
if (diffMins < 1) return 'just now'
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
return `${diffDays}d ago`
|
||||
}
|
||||
export default IncidentCard
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Incident Components - Phase 7 + 6.5a
|
||||
*/
|
||||
|
||||
export { IncidentCard, IncidentCardGrid, IncidentEmptyState } from './incident-card'
|
||||
export { IncidentCard } from './incident-card'
|
||||
export {
|
||||
DualStateIncidentCard,
|
||||
type DualStateIncidentCardProps,
|
||||
|
||||
Reference in New Issue
Block a user