Wave 6 P2.5 frontend-designer 工業級視覺化(拒絕 AI slop): 新增(1824 行): - apps/web/src/app/[locale]/aiops/timeline/page.tsx - apps/web/src/components/aiops/timeline/ · AiopsTimelinePanel.tsx (413) — 主面板組件 · TimelineStage.tsx (279) — 6 階段時序卡片 · TimelineStageDetails.tsx (359) — 階段細節展開 · EvidenceViewer.tsx (144) — Evidence Snapshot 檢視 · TimelineFilter.tsx (109) — incident_id / severity / 時段 過濾器 · types.ts (118) — TS 型別定義 · mock-data.ts (357) — 開發 mock fallback · index.ts (7) — barrel export - i18n: messages/en.json + messages/zh-TW.json — Timeline 翻譯 設計原則: - 拒絕 AI slop(無泛用 emoji/漸層,採工業 dashboard 風格) - 後端 endpoint 接通 /api/v1/aiops/timeline(critic B4 修復) - mock 模式 fallback 防 endpoint 暫時不可達 對應後端: a3b4595e(aiops_timeline.py + aiops_timeline_service.py) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-Authored-By: frontend-designer agent (Wave 6) <noreply@anthropic.com>
145 lines
5.7 KiB
TypeScript
145 lines
5.7 KiB
TypeScript
'use client'
|
||
|
||
// 2026-04-26 P2.5 by Claude — AIOps Timeline
|
||
// ============================================================
|
||
// EvidenceViewer — 8D evidence 視覺化
|
||
// Tab 切換不同 dimension,每個 dimension 顯示狀態 + 詳情
|
||
// ============================================================
|
||
|
||
import { useState } from 'react'
|
||
import { useTranslations } from 'next-intl'
|
||
import { CheckCircle2, AlertTriangle, HelpCircle } from 'lucide-react'
|
||
import { cn } from '@/lib/utils'
|
||
import type { EvidenceDimension } from './types'
|
||
|
||
interface EvidenceViewerProps {
|
||
dimensions: EvidenceDimension[]
|
||
}
|
||
|
||
const DIMENSION_STATUS_CONFIG = {
|
||
ok: { icon: CheckCircle2, color: 'text-status-healthy', bg: 'bg-status-healthy/10', label: 'OK' },
|
||
anomaly: { icon: AlertTriangle, color: 'text-status-critical', bg: 'bg-status-critical/10', label: 'ANOMALY' },
|
||
unknown: { icon: HelpCircle, color: 'text-nothing-gray-400', bg: 'bg-nothing-gray-100', label: 'UNKNOWN' },
|
||
}
|
||
|
||
export function EvidenceViewer({ dimensions }: EvidenceViewerProps) {
|
||
const t = useTranslations('aiopsTimeline')
|
||
const [activeTab, setActiveTab] = useState(0)
|
||
const active = dimensions[activeTab]
|
||
|
||
return (
|
||
<div className="mt-3">
|
||
{/* Tab nav — 8 個 dimension 可滾動 */}
|
||
<div
|
||
className="flex gap-1 overflow-x-auto pb-1 scrollbar-none"
|
||
role="tablist"
|
||
aria-label={t('evidence.dimensions')}
|
||
>
|
||
{dimensions.map((dim, i) => {
|
||
const cfg = DIMENSION_STATUS_CONFIG[dim.status]
|
||
return (
|
||
<button
|
||
key={dim.key}
|
||
role="tab"
|
||
aria-selected={i === activeTab}
|
||
aria-controls={`evidence-panel-${dim.key}`}
|
||
id={`evidence-tab-${dim.key}`}
|
||
onClick={() => setActiveTab(i)}
|
||
className={cn(
|
||
'flex-shrink-0 flex items-center gap-1.5 px-2.5 py-1.5 rounded text-xs font-body transition-all',
|
||
i === activeTab
|
||
? cn('border font-medium', cfg.bg,
|
||
dim.status === 'anomaly' ? 'border-status-critical/30 text-status-critical'
|
||
: dim.status === 'ok' ? 'border-status-healthy/30 text-status-healthy'
|
||
: 'border-nothing-gray-300 text-nothing-gray-600')
|
||
: 'text-nothing-gray-500 hover:text-nothing-gray-700 hover:bg-nothing-gray-100'
|
||
)}
|
||
>
|
||
<cfg.icon className={cn('w-3 h-3', i === activeTab ? cfg.color : 'text-nothing-gray-400')} />
|
||
<span className="truncate max-w-[80px]">{dim.name}</span>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* Active dimension detail */}
|
||
{active && (
|
||
<div
|
||
id={`evidence-panel-${active.key}`}
|
||
role="tabpanel"
|
||
aria-labelledby={`evidence-tab-${active.key}`}
|
||
className={cn(
|
||
'mt-2 p-3 rounded-lg border',
|
||
active.status === 'anomaly'
|
||
? 'bg-status-critical/5 border-status-critical/20'
|
||
: active.status === 'ok'
|
||
? 'bg-status-healthy/5 border-status-healthy/20'
|
||
: 'bg-nothing-gray-50 border-nothing-gray-200'
|
||
)}
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
{(() => {
|
||
const cfg = DIMENSION_STATUS_CONFIG[active.status]
|
||
return (
|
||
<>
|
||
<cfg.icon className={cn('w-3.5 h-3.5 flex-shrink-0', cfg.color)} />
|
||
<span className={cn('text-xs font-body font-bold uppercase tracking-wider', cfg.color)}>
|
||
{cfg.label}
|
||
</span>
|
||
</>
|
||
)
|
||
})()}
|
||
</div>
|
||
<p className="font-body text-sm font-medium text-nothing-black">
|
||
{active.name}
|
||
</p>
|
||
{active.detail && (
|
||
<p className="font-body text-xs text-nothing-gray-500 mt-0.5">
|
||
{active.detail}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<div className="flex-shrink-0 text-right">
|
||
<span className="font-body text-sm font-bold text-nothing-black tabular-nums">
|
||
{active.value !== null && active.value !== undefined ? String(active.value) : t('evidence.noData')}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Summary row: anomaly count */}
|
||
<div className="flex items-center gap-3 mt-2">
|
||
<span className="text-[11px] font-body text-nothing-gray-400">
|
||
{t('evidence.anomalyCount', {
|
||
count: dimensions.filter(d => d.status === 'anomaly').length,
|
||
total: dimensions.length,
|
||
})}
|
||
</span>
|
||
{/* Mini status dots */}
|
||
<div className="flex gap-1 items-center">
|
||
{dimensions.map((d, i) => (
|
||
<div
|
||
key={d.key}
|
||
title={d.name}
|
||
className={cn(
|
||
'w-2 h-2 rounded-full transition-all',
|
||
i === activeTab && 'ring-2 ring-offset-1',
|
||
d.status === 'anomaly' ? 'bg-status-critical'
|
||
: d.status === 'ok' ? 'bg-status-healthy'
|
||
: 'bg-nothing-gray-300',
|
||
i === activeTab && d.status === 'anomaly' ? 'ring-status-critical/40'
|
||
: i === activeTab && d.status === 'ok' ? 'ring-status-healthy/40'
|
||
: i === activeTab ? 'ring-nothing-gray-400/40'
|
||
: ''
|
||
)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|