feat(ui): 四種嚴重度管線動畫 + WoooClaw 命名更新
Some checks failed
E2E Health Check / e2e-health (push) Has been cancelled
CD Pipeline / build-and-deploy (push) Has been cancelled

- flow-pipeline.tsx: 新增 severity prop,四種管線樣式
  - P0 → Style A: 脈衝光波 + 流動光效 (#cc2200)
  - P1 → Style B: 進度條,龍蝦站在進度端點 (#F59E0B)
  - P2 → Style C: 卡片步驟,龍蝦浮在 active 卡片上方 (#4A90D9)
  - P3 → Style D: 時間軸,虛線流動動畫 (#22C55E)
- incident-card.tsx: FlowPipeline 傳入 severity={sev}
- openclaw-panel.tsx: NemoClaw→WoooClaw, OpenClaw Pipeline→WoooClaw Pipeline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-02 21:18:22 +08:00
parent 04978995c1
commit 93e3aa6811
3 changed files with 305 additions and 98 deletions

View File

@@ -229,7 +229,7 @@ export function OpenClawPanel({
{/* 右側文字 */}
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: '#141413', marginBottom: 2 }}>
NemoClaw
WoooClaw
</div>
<div style={{ fontSize: 12, color: '#87867f', marginBottom: 6 }}>
{tPanel(status)}
@@ -241,7 +241,7 @@ export function OpenClawPanel({
borderRadius: 10,
color: '#4A90D9',
}}>
OpenClaw Pipeline
WoooClaw Pipeline
</span>
</div>
</div>

View File

@@ -4,8 +4,12 @@
* FlowPipeline - AI中心 v6 事件流程管線
* ========================================
* 7節點水平管線告警→AI偵測→AI分析→提案生成→等待授權→執行→完成
* Q版龍蝦 SVG進行中=橘紅 #E85530完成=綠色 #22C55E
* lobster-bob CSS 動畫:上下浮動 4px1.5s loop
* 4種嚴重度對應不同管線樣式
* P0 → Style A: 脈衝光波 (紅 #cc2200)
* P1 → Style B: 進度條 (橘 #F59E0B)
* P2 → Style C: 卡片步驟 (藍 #4A90D9)
* P3 → Style D: 時間軸 (綠 #22C55E)
* 2026-04-02 Claude Code: 新增 severity prop + 四種管線渲染
*/
export type FlowStage =
@@ -22,167 +26,278 @@ 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 資訊(可選)*/
severity?: 'P0' | 'P1' | 'P2' | 'P3'
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' },
{ id: 'alert', label: '告警' },
{ id: 'detection', label: 'AI偵測' },
{ id: 'analysis', label: 'AI分析' },
{ id: 'proposal', label: '提案生成' },
{ id: 'approval', label: '等待授權' },
{ id: 'execution', label: '執行' },
{ id: 'resolved', label: '完成' },
]
/** 根據 activeStage 計算每個節點的狀態 */
const SEV_COLOR: Record<string, string> = {
P0: '#cc2200',
P1: '#F59E0B',
P2: '#4A90D9',
P3: '#22C55E',
}
function getActiveIndex(activeStage: FlowStage): number {
return FLOW_NODES.findIndex(n => n.id === 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)
const nodeIndex = FLOW_NODES.findIndex(n => n.id === nodeId)
const activeIndex = getActiveIndex(activeStage)
if (nodeIndex < activeIndex) return 'done'
if (nodeIndex === activeIndex) return 'active'
return 'pending'
}
/** Q版龍蝦 SVG (18×20) */
function LobsterSVG({ color }: { color: string }) {
// ── Q版龍蝦 SVG ──────────────────────────────────────────────────────────────
function LobsterSVG({ color, size = 18 }: { color: string; size?: number }) {
const h = Math.round(size * 20 / 18)
return (
<svg width="18" height="20" viewBox="0 0 18 20" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* 身體 */}
<svg width={size} height={h} 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'
// ── Style A: P0 脈衝光波 ──────────────────────────────────────────────────────
function PipelineStyleA({ activeStage, isResolved }: { activeStage: FlowStage; isResolved: boolean }) {
const color = isResolved ? '#22C55E' : SEV_COLOR.P0
const activeIdx = getActiveIndex(activeStage)
return (
<div className="flow-pipeline-wrapper" style={{ padding: '4px 10px 8px', overflowX: 'auto' }}>
{/* CSS keyframes via style tag */}
<div style={{ padding: '4px 10px 8px', overflowX: 'auto' }}>
<style>{`
@keyframes lobster-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
@keyframes lobster-bob { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-4px)} }
@keyframes pulse-wave {
0% { box-shadow: 0 0 0 0 rgba(204,34,0,0.6); }
70% { box-shadow: 0 0 0 8px rgba(204,34,0,0); }
100% { box-shadow: 0 0 0 0 rgba(204,34,0,0); }
}
.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: 11px;
padding: 3px 7px;
border-radius: 4px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s;
z-index: 10;
@keyframes flow-light {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.pipe-a-conn-done {
background: linear-gradient(90deg, transparent 0%, ${color} 50%, transparent 100%);
background-size: 200% 100%;
animation: flow-light 1.4s linear infinite;
}
.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 isDone = status === 'done' || 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={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<div style={{ height: 22, display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }}>
{isActive && (
<div className={isResolved ? '' : 'lobster-bob'}>
<LobsterSVG color={lobsterColor} />
<div style={{ animation: 'lobster-bob 1.5s ease-in-out infinite' }}>
<LobsterSVG color={color} />
</div>
)}
</div>
{/* 節點圓點 */}
<div style={{
width: 8, height: 8,
width: 10, height: 10,
borderRadius: '50%',
background: dotBg,
border: dotBorder,
boxShadow: dotShadow,
background: isDone ? color : isActive ? '#fff' : '#f8f9fc',
border: isActive ? `2px solid ${color}` : isDone ? 'none' : '1.5px solid #e0ddd4',
animation: isActive ? 'pulse-wave 1.2s ease-out infinite' : 'none',
flexShrink: 0,
}} />
{/* 節點標籤 */}
<span style={{
fontSize: 11,
color: isActive ? '#141413' : isDone || isResNode ? '#E85530' : '#b0ad9f',
color: isActive ? '#141413' : isDone ? color : '#b0ad9f',
fontWeight: isActive ? 700 : 400,
marginTop: 2,
whiteSpace: 'nowrap',
marginTop: 2, whiteSpace: 'nowrap',
}}>
{node.label}
</span>
{/* Tooltip */}
{tooltips?.[node.id] && (
<div className="flow-tooltip">{tooltips[node.id]}</div>
)}
</div>
{!isLast && (
<div
className={idx < activeIdx ? 'pipe-a-conn-done' : ''}
style={{
width: 16, height: 2,
background: idx < activeIdx ? undefined : '#e0ddd4',
flexShrink: 0,
marginBottom: 18,
}}
/>
)}
</div>
)
})}
</div>
</div>
)
}
{/* 連接線(最後一個節點後不需要)*/}
// ── Style B: P1 進度條 ────────────────────────────────────────────────────────
function PipelineStyleB({ activeStage, isResolved }: { activeStage: FlowStage; isResolved: boolean }) {
const color = isResolved ? '#22C55E' : SEV_COLOR.P1
const activeIdx = getActiveIndex(activeStage)
const totalNodes = FLOW_NODES.length
const progressPct = isResolved ? 100 : (activeIdx / (totalNodes - 1)) * 100
return (
<div style={{ padding: '4px 10px 12px', overflowX: 'auto' }}>
<style>{`
@keyframes lobster-bob { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-4px)} }
`}</style>
{/* 進度條軌道 */}
<div style={{ position: 'relative', height: 54 }}>
{/* 底部軌道 */}
<div style={{
position: 'absolute', bottom: 16, left: 0, right: 0,
height: 4, background: '#e8e5dc', borderRadius: 2,
}} />
{/* 已完成進度 */}
<div style={{
position: 'absolute', bottom: 16, left: 0,
height: 4, background: color, borderRadius: 2,
width: `${progressPct}%`,
transition: 'width 0.4s ease',
}} />
{/* 節點圓點 */}
{FLOW_NODES.map((node, idx) => {
const pct = (idx / (totalNodes - 1)) * 100
const status = getNodeStatus(node.id, activeStage, isResolved)
const isActive = status === 'active'
const isDone = status === 'done' || status === 'resolved'
return (
<div
key={node.id}
style={{
position: 'absolute',
bottom: 0,
left: `${pct}%`,
transform: 'translateX(-50%)',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2,
}}
>
{/* 龍蝦 (active 節點,站在進度條終點上方) */}
{isActive && (
<div style={{ animation: 'lobster-bob 1.5s ease-in-out infinite', marginBottom: 2 }}>
<LobsterSVG color={color} size={16} />
</div>
)}
{!isActive && <div style={{ height: 20 }} />}
{/* 圓點 */}
<div style={{
width: 8, height: 8, borderRadius: '50%',
background: isDone ? color : isActive ? '#fff' : '#f8f9fc',
border: isActive ? `2px solid ${color}` : isDone ? 'none' : '1.5px solid #e0ddd4',
flexShrink: 0,
}} />
{/* 標籤 */}
<span style={{
fontSize: 10, color: isActive ? '#141413' : isDone ? color : '#b0ad9f',
fontWeight: isActive ? 700 : 400,
whiteSpace: 'nowrap',
}}>
{node.label}
</span>
</div>
)
})}
</div>
</div>
)
}
// ── Style C: P2 卡片步驟 ──────────────────────────────────────────────────────
function PipelineStyleC({ activeStage, isResolved }: { activeStage: FlowStage; isResolved: boolean }) {
const color = isResolved ? '#22C55E' : SEV_COLOR.P2
const activeIdx = getActiveIndex(activeStage)
return (
<div style={{ padding: '4px 10px 8px', overflowX: 'auto' }}>
<style>{`
@keyframes lobster-bob { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-4px)} }
@keyframes card-glow {
0%,100% { box-shadow: 0 0 0 0 rgba(74,144,217,0.3); }
50% { box-shadow: 0 0 6px 2px rgba(74,144,217,0.3); }
}
`}</style>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 3, minWidth: 'max-content' }}>
{FLOW_NODES.map((node, idx) => {
const status = getNodeStatus(node.id, activeStage, isResolved)
const isActive = status === 'active'
const isDone = status === 'done' || status === 'resolved'
const isLast = idx === FLOW_NODES.length - 1
return (
<div key={node.id} style={{ display: 'flex', alignItems: 'flex-end' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
{/* 龍蝦 */}
<div style={{ height: 22, display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }}>
{isActive && (
<div style={{ animation: 'lobster-bob 1.5s ease-in-out infinite' }}>
<LobsterSVG color={color} size={16} />
</div>
)}
</div>
{/* 卡片 */}
<div style={{
padding: '3px 6px',
background: isDone ? color : isActive ? '#fff' : '#f8f9fc',
border: isActive ? `1.5px solid ${color}` : isDone ? 'none' : '1px solid #e0ddd4',
borderRadius: 5,
animation: isActive ? 'card-glow 1.5s ease-in-out infinite' : 'none',
minWidth: 36,
textAlign: 'center' as const,
}}>
<span style={{
fontSize: 10,
color: isDone ? '#fff' : isActive ? color : '#b0ad9f',
fontWeight: isActive || isDone ? 700 : 400,
whiteSpace: 'nowrap',
}}>
{node.label}
</span>
</div>
</div>
{!isLast && (
<div style={{
width: 16, height: 1,
background: connColor,
width: 8, height: 1.5,
background: idx < activeIdx ? color : '#e0ddd4',
flexShrink: 0,
marginBottom: 18, // 對齊圓點位置(跳過 lobster 空間 + 標籤)
marginBottom: 11,
}} />
)}
</div>
@@ -193,4 +308,96 @@ export function FlowPipeline({ activeStage, isResolved = false, tooltips }: Flow
)
}
// ── Style D: P3 時間軸 ────────────────────────────────────────────────────────
function PipelineStyleD({ activeStage, isResolved }: { activeStage: FlowStage; isResolved: boolean }) {
const color = isResolved ? '#22C55E' : SEV_COLOR.P3
const activeIdx = getActiveIndex(activeStage)
return (
<div style={{ padding: '4px 10px 8px', overflowX: 'auto' }}>
<style>{`
@keyframes lobster-bob { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-4px)} }
@keyframes dash-march {
to { stroke-dashoffset: -20; }
}
`}</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' || status === 'resolved'
const isLast = idx === FLOW_NODES.length - 1
const isNextActive = idx === activeIdx - 1
return (
<div key={node.id} style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<div style={{ height: 22, display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }}>
{isActive && (
<div style={{ animation: 'lobster-bob 1.5s ease-in-out infinite' }}>
<LobsterSVG color={color} />
</div>
)}
</div>
<div style={{
width: 8, height: 8, borderRadius: '50%',
background: isDone ? color : isActive ? '#fff' : '#f8f9fc',
border: isActive ? `2px solid ${color}` : isDone ? 'none' : '1.5px solid #e0ddd4',
flexShrink: 0,
}} />
<span style={{
fontSize: 11,
color: isActive ? '#141413' : isDone ? color : '#b0ad9f',
fontWeight: isActive ? 700 : 400,
marginTop: 2, whiteSpace: 'nowrap',
}}>
{node.label}
</span>
</div>
{!isLast && (
<div style={{ marginBottom: 18, flexShrink: 0 }}>
{/* Active 前的虛線用 SVG 做流動動畫 */}
{isNextActive ? (
<svg width="16" height="2" style={{ display: 'block' }}>
<line x1="0" y1="1" x2="16" y2="1"
stroke={color} strokeWidth="1.5"
strokeDasharray="4 3"
style={{ animation: 'dash-march 0.6s linear infinite', strokeDashoffset: 0 }}
/>
</svg>
) : (
<div style={{
width: 16, height: 1.5,
background: isDone ? color : '#e0ddd4',
}} />
)}
</div>
)}
</div>
)
})}
</div>
</div>
)
}
// ── Main Component ────────────────────────────────────────────────────────────
export function FlowPipeline({ activeStage, isResolved = false, severity = 'P3', tooltips: _tooltips }: FlowPipelineProps) {
// 2026-04-02 Claude Code: severity → pipeline style mapping
// P0=StyleA(脈衝光波) P1=StyleB(進度條) P2=StyleC(卡片步驟) P3=StyleD(時間軸)
if (isResolved || severity === 'P3') {
return <PipelineStyleD activeStage={activeStage} isResolved={isResolved} />
}
if (severity === 'P0') {
return <PipelineStyleA activeStage={activeStage} isResolved={false} />
}
if (severity === 'P1') {
return <PipelineStyleB activeStage={activeStage} isResolved={false} />
}
// P2
return <PipelineStyleC activeStage={activeStage} isResolved={false} />
}
export default FlowPipeline

View File

@@ -292,7 +292,7 @@ export function IncidentCard({ incident, decision, onApprovalChange }: IncidentC
</div>
{/* 流程狀態圖 */}
<FlowPipeline activeStage={flowStage} isResolved={isResolved} />
<FlowPipeline activeStage={flowStage} isResolved={isResolved} severity={sev} />
{/* Impact 指標列 */}
<div style={{