feat(incident): add FlowPipeline 7-node pipeline with lobster animation
This commit is contained in:
196
apps/web/src/components/incident/flow-pipeline.tsx
Normal file
196
apps/web/src/components/incident/flow-pipeline.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* FlowPipeline - AI中心 v6 事件流程管線
|
||||
* ========================================
|
||||
* 7節點水平管線:告警→AI偵測→AI分析→提案生成→等待授權→執行→完成
|
||||
* Q版龍蝦 SVG:進行中=橘紅 #E85530,完成=綠色 #22C55E
|
||||
* lobster-bob CSS 動畫:上下浮動 4px,1.5s loop
|
||||
*/
|
||||
|
||||
export type FlowStage =
|
||||
| 'alert'
|
||||
| 'detection'
|
||||
| 'analysis'
|
||||
| 'proposal'
|
||||
| 'approval'
|
||||
| 'execution'
|
||||
| 'resolved'
|
||||
|
||||
export type NodeStatus = 'done' | 'active' | 'pending' | 'resolved'
|
||||
|
||||
interface FlowNode {
|
||||
id: FlowStage
|
||||
label: string
|
||||
labelKey: string
|
||||
}
|
||||
|
||||
export interface FlowPipelineProps {
|
||||
/** 目前進行到哪個 stage */
|
||||
activeStage: FlowStage
|
||||
/** 是否已全部完成(resolved 狀態,綠色龍蝦靜止)*/
|
||||
isResolved?: boolean
|
||||
/** 各節點 tooltip 資訊(可選)*/
|
||||
tooltips?: Partial<Record<FlowStage, string>>
|
||||
}
|
||||
|
||||
const FLOW_NODES: FlowNode[] = [
|
||||
{ id: 'alert', label: '告警', labelKey: 'flow.alert' },
|
||||
{ id: 'detection', label: 'AI偵測', labelKey: 'flow.detection' },
|
||||
{ id: 'analysis', label: 'AI分析', labelKey: 'flow.analysis' },
|
||||
{ id: 'proposal', label: '提案生成', labelKey: 'flow.proposal' },
|
||||
{ id: 'approval', label: '等待授權', labelKey: 'flow.approval' },
|
||||
{ id: 'execution', label: '執行', labelKey: 'flow.execution' },
|
||||
{ id: 'resolved', label: '完成', labelKey: 'flow.resolved' },
|
||||
]
|
||||
|
||||
/** 根據 activeStage 計算每個節點的狀態 */
|
||||
function getNodeStatus(nodeId: FlowStage, activeStage: FlowStage, isResolved: boolean): NodeStatus {
|
||||
if (isResolved) return 'resolved'
|
||||
const stageOrder = FLOW_NODES.map(n => n.id)
|
||||
const nodeIndex = stageOrder.indexOf(nodeId)
|
||||
const activeIndex = stageOrder.indexOf(activeStage)
|
||||
if (nodeIndex < activeIndex) return 'done'
|
||||
if (nodeIndex === activeIndex) return 'active'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
/** Q版龍蝦 SVG (18×20) */
|
||||
function LobsterSVG({ color }: { color: string }) {
|
||||
return (
|
||||
<svg width="18" height="20" viewBox="0 0 18 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* 身體 */}
|
||||
<ellipse cx="9" cy="13" rx="5.5" ry="6.5" fill={color} />
|
||||
{/* 頭部 */}
|
||||
<circle cx="9" cy="7.5" r="4.5" fill={color} />
|
||||
{/* 眼睛 */}
|
||||
<circle cx="7" cy="6.5" r="1" fill="#b03a1a" />
|
||||
<circle cx="11" cy="6.5" r="1" fill="#b03a1a" />
|
||||
{/* 左鉗 */}
|
||||
<path d="M3.5 10 Q1 9 1.5 12 Q2 14 4 13" stroke={color} strokeWidth="1.2" fill="none" strokeLinecap="round"/>
|
||||
<ellipse cx="1.5" cy="12" rx="1.2" ry="1.5" fill={color} transform="rotate(-10 1.5 12)"/>
|
||||
{/* 右鉗 */}
|
||||
<path d="M14.5 10 Q17 9 16.5 12 Q16 14 14 13" stroke={color} strokeWidth="1.2" fill="none" strokeLinecap="round"/>
|
||||
<ellipse cx="16.5" cy="12" rx="1.2" ry="1.5" fill={color} transform="rotate(10 16.5 12)"/>
|
||||
{/* 觸鬚 */}
|
||||
<path d="M7 3 Q5 1 3 2" stroke="#b03a1a" strokeWidth="0.8" fill="none" strokeLinecap="round"/>
|
||||
<path d="M11 3 Q13 1 15 2" stroke="#b03a1a" strokeWidth="0.8" fill="none" strokeLinecap="round"/>
|
||||
{/* 尾部 */}
|
||||
<path d="M6 19 Q9 21 12 19" stroke={color} strokeWidth="1.2" fill="none" strokeLinecap="round"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function FlowPipeline({ activeStage, isResolved = false, tooltips }: FlowPipelineProps) {
|
||||
const lobsterColor = isResolved ? '#22C55E' : '#E85530'
|
||||
|
||||
return (
|
||||
<div className="flow-pipeline-wrapper" style={{ padding: '4px 10px 8px', overflowX: 'auto' }}>
|
||||
{/* CSS keyframes via style tag */}
|
||||
<style>{`
|
||||
@keyframes lobster-bob {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-4px); }
|
||||
}
|
||||
.lobster-bob { animation: lobster-bob 1.5s ease-in-out infinite; }
|
||||
.flow-tooltip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #141413;
|
||||
color: #f5f4ed;
|
||||
font-size: 9px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
z-index: 10;
|
||||
}
|
||||
.flow-node-wrap:hover .flow-tooltip { opacity: 1; }
|
||||
`}</style>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 0, minWidth: 'max-content' }}>
|
||||
{FLOW_NODES.map((node, idx) => {
|
||||
const status = getNodeStatus(node.id, activeStage, isResolved)
|
||||
const isActive = status === 'active'
|
||||
const isDone = status === 'done'
|
||||
const isResNode = status === 'resolved'
|
||||
const isLast = idx === FLOW_NODES.length - 1
|
||||
|
||||
// 節點顏色
|
||||
const dotBg = isDone || isResNode
|
||||
? (isResNode ? '#22C55E' : '#E85530')
|
||||
: isActive ? '#141413' : '#f8f9fc'
|
||||
const dotBorder = isActive
|
||||
? '1.5px solid #E85530'
|
||||
: isDone || isResNode
|
||||
? 'none'
|
||||
: '1.5px solid #e0ddd4'
|
||||
const dotShadow = isActive ? '0 0 0 3px rgba(232,85,48,0.2)' : 'none'
|
||||
|
||||
// 連接線顏色
|
||||
const connColor = isDone || isResNode ? 'rgba(232,85,48,0.5)' : '#e0ddd4'
|
||||
|
||||
return (
|
||||
<div key={node.id} style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<div
|
||||
className="flow-node-wrap"
|
||||
style={{ position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}
|
||||
>
|
||||
{/* Q版龍蝦(僅 active 節點顯示)*/}
|
||||
<div style={{ height: 22, display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }}>
|
||||
{isActive && (
|
||||
<div className={isResolved ? '' : 'lobster-bob'}>
|
||||
<LobsterSVG color={lobsterColor} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 節點圓點 */}
|
||||
<div style={{
|
||||
width: 8, height: 8,
|
||||
borderRadius: '50%',
|
||||
background: dotBg,
|
||||
border: dotBorder,
|
||||
boxShadow: dotShadow,
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
|
||||
{/* 節點標籤 */}
|
||||
<span style={{
|
||||
fontSize: 8,
|
||||
color: isActive ? '#141413' : isDone || isResNode ? '#E85530' : '#b0ad9f',
|
||||
fontWeight: isActive ? 700 : 400,
|
||||
marginTop: 2,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{node.label}
|
||||
</span>
|
||||
|
||||
{/* Tooltip */}
|
||||
{tooltips?.[node.id] && (
|
||||
<div className="flow-tooltip">{tooltips[node.id]}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 連接線(最後一個節點後不需要)*/}
|
||||
{!isLast && (
|
||||
<div style={{
|
||||
width: 16, height: 1,
|
||||
background: connColor,
|
||||
flexShrink: 0,
|
||||
marginBottom: 18, // 對齊圓點位置(跳過 lobster 空間 + 標籤)
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FlowPipeline
|
||||
Reference in New Issue
Block a user