fix(ui): 架構師 Review 修復 — i18n/keyframe/型別/版面
Some checks failed
E2E Health Check / e2e-health (push) Has been cancelled
CD Pipeline / build-and-deploy (push) Has been cancelled

Critical:
- flow-pipeline.tsx: 移除 4 個重複 lobster-bob keyframe,統一在父元件注入
  修正 isResolved 路由邏輯,保留嚴重度視覺識別 (P0 resolved 仍用 StyleA)
- incident-card.tsx: 修復 4 個硬編碼中文字串 (affectedServices/signalCount/statusLabel/aiProposal)
  新增對應 i18n key 到 zh-TW.json + en.json

Warning:
- page.tsx: MetricItem type 提升至 module scope,pendingApprovals null 安全檢查
  Metrics Strip 移除固定 height:68px 改為 auto + padding:8px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-02 21:36:51 +08:00
parent 08f73dfce8
commit ba4ee46514
5 changed files with 50 additions and 42 deletions

View File

@@ -310,7 +310,11 @@
"suggestedAction": "> Suggested action:",
"authorize": "Authorize",
"reject": "Reject",
"anomaly": "anomaly"
"anomaly": "anomaly",
"affectedServices": "Affected Services",
"signalCount": "Signals",
"statusLabel": "Status",
"aiProposal": "AI Proposal"
}
},
"status": {

View File

@@ -311,7 +311,11 @@
"suggestedAction": "> 建議行動:",
"authorize": "授權",
"reject": "拒絕",
"anomaly": "異常"
"anomaly": "異常",
"affectedServices": "影響服務",
"signalCount": "信號數",
"statusLabel": "狀態",
"aiProposal": "AI 提案"
}
},
"status": {

View File

@@ -20,6 +20,19 @@ import { OpenClawPanel } from '@/components/ai/openclaw-panel'
import { HostGrid, type HostInfo } from '@/components/infra/host-grid'
import { AppLayout } from '@/components/layout'
// =============================================================================
// Types
// =============================================================================
interface MetricItem {
label: string
value: string | number
sub?: string
badge?: { text: string; color: string; bg: string }
sparkline?: { values: number[]; color: string }
valueColor?: string
}
// =============================================================================
// Mini Sparkline (SVG inline)
// =============================================================================
@@ -118,14 +131,7 @@ export default function Home({ params }: { params: { locale: string } }) {
// ── 7 Metrics Strip ─────────────────────────────────────────────────────────
type MetricItem = {
label: string
value: string | number
sub?: string
badge?: { text: string; color: string; bg: string }
sparkline?: { values: number[]; color: string }
valueColor?: string
}
const hasPendingApprovals = pendingApprovals !== null && pendingApprovals !== undefined && pendingApprovals > 0
const metrics: MetricItem[] = [
{
@@ -144,9 +150,9 @@ export default function Home({ params }: { params: { locale: string } }) {
{
label: tDashboard('pendingApprovals'),
value: pendingApprovals ?? '--',
sub: pendingApprovals > 0 ? undefined : tDashboard('stable'),
badge: pendingApprovals > 0 ? { text: tDashboard('pendingApprovals'), color: '#F59E0B', bg: 'rgba(245,158,11,0.08)' } : undefined,
valueColor: pendingApprovals > 0 ? '#F59E0B' : undefined,
sub: hasPendingApprovals ? undefined : tDashboard('stable'),
badge: hasPendingApprovals ? { text: `${pendingApprovals}`, color: '#F59E0B', bg: 'rgba(245,158,11,0.08)' } : undefined,
valueColor: hasPendingApprovals ? '#F59E0B' : undefined,
},
{
label: tDashboard('autoRemediationRate'),
@@ -184,8 +190,7 @@ export default function Home({ params }: { params: { locale: string } }) {
<div style={{
background: '#faf9f3',
borderBottom: '0.5px solid #e0ddd4',
height: 68,
padding: '0 20px',
padding: '8px 20px',
display: 'flex',
alignItems: 'stretch',
flexShrink: 0,
@@ -208,7 +213,7 @@ export default function Home({ params }: { params: { locale: string } }) {
{/* Value row */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{
fontSize: 22,
fontSize: 20,
fontWeight: 700,
color: m.valueColor ?? '#141413',
lineHeight: 1,

View File

@@ -95,7 +95,6 @@ function PipelineStyleA({ activeStage, isResolved }: { activeStage: FlowStage; i
return (
<div style={{ padding: '4px 10px 8px', overflowX: 'auto' }}>
<style>{`
@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); }
@@ -174,9 +173,6 @@ function PipelineStyleB({ activeStage, isResolved }: { activeStage: FlowStage; i
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 }}>
{/* 底部軌道 */}
@@ -248,7 +244,6 @@ function PipelineStyleC({ activeStage, isResolved }: { activeStage: FlowStage; i
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); }
@@ -316,12 +311,6 @@ function PipelineStyleD({ activeStage, isResolved }: { activeStage: FlowStage; i
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)
@@ -382,22 +371,28 @@ function PipelineStyleD({ activeStage, isResolved }: { activeStage: FlowStage; i
)
}
// ── Shared Keyframes (injected once at top level to avoid duplication) ────────
const SHARED_KEYFRAMES = `
@keyframes lobster-bob { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-4px)} }
@keyframes dash-march { to { stroke-dashoffset: -20; } }
`
// ── 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} />
// isResolved 傳入各 Style 自行處理顏色,保留嚴重度視覺識別
return (
<>
<style>{SHARED_KEYFRAMES}</style>
{severity === 'P0' && <PipelineStyleA activeStage={activeStage} isResolved={isResolved} />}
{severity === 'P1' && <PipelineStyleB activeStage={activeStage} isResolved={isResolved} />}
{severity === 'P2' && <PipelineStyleC activeStage={activeStage} isResolved={isResolved} />}
{(severity === 'P3' || !['P0','P1','P2'].includes(severity)) && <PipelineStyleD activeStage={activeStage} isResolved={isResolved} />}
</>
)
}
export default FlowPipeline

View File

@@ -306,13 +306,13 @@ export function IncidentCard({ incident, decision, onApprovalChange }: IncidentC
flexWrap: 'wrap' as const,
}}>
<span style={{ fontSize: 13, color: '#87867f' }}>
<strong style={{ color: '#141413' }}>{incident.affected_services?.length ?? 0}</strong>
{t('affectedServices')} <strong style={{ color: '#141413' }}>{incident.affected_services?.length ?? 0}</strong>
</span>
<span style={{ fontSize: 13, color: '#87867f' }}>
<strong style={{ color: '#141413' }}>{incident.signal_count ?? '--'}</strong>
{t('signalCount')} <strong style={{ color: '#141413' }}>{incident.signal_count ?? '--'}</strong>
</span>
<span style={{ fontSize: 13, color: '#87867f' }}>
<strong style={{ color: '#141413' }}>{incident.status}</strong>
{t('statusLabel')} <strong style={{ color: '#141413' }}>{incident.status}</strong>
</span>
</div>
@@ -340,7 +340,7 @@ export function IncidentCard({ incident, decision, onApprovalChange }: IncidentC
}}
>
<span style={{ color: '#d97757' }}></span>
<span>AI : {decisionAction.slice(0, 50)}{decisionAction.length > 50 ? '...' : ''}</span>
<span>{t('aiProposal')}: {decisionAction.slice(0, 50)}{decisionAction.length > 50 ? '...' : ''}</span>
<span style={{ marginLeft: 'auto' }}>{aiExpanded ? '▲' : '▼'}</span>
</button>
{aiExpanded && (