Some checks failed
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Failing after 1m4s
CD Pipeline / build-and-deploy (push) Has been skipped
CD Pipeline / post-deploy-checks (push) Has been skipped
AWOOOI Harbor 110 Local Repair / workflow-shape (push) Successful in 1s
AWOOOI Harbor 110 Local Repair / harbor-110-local-repair (push) Successful in 30s
## 改善範圍 ### Panels (components/panels/) - CostPanel: inline style → Tailwind + Stat Cards + 效能分佈長條圖 + Loading skeleton + Refresh - ApmPanel: Tailwind + 彩色狀態徽章 + ring 發光 + Sparkline 趨勢圖 - AppsPanel: Tailwind + 卡片 Grid + 狀態 / 延遲指示器 + Summary Pills - ServicesPanel: Tailwind + 3 欄 Summary Cards + 資源 CPU/RAM Progress Bar - BillingPanel: Tailwind + Stat Cards + 成功/失敗進度條 + 操作類型分類圖 - DeploymentsPanel: Tailwind + CI/CD 時間軸事件 + 主機健康卡片帶服務 Grid - SecurityPanel: Tailwind + 彩色 Stat Cards + 問題列表帶 Sentry 等級徽章 - CompliancePanel: Tailwind + 嚴重性分佈視覺化 + Auto-Repair 三欄指標面板 - ErrorsPanel: 頭部 Tailwind 化 + 統一 bg-[#f5f4ed] 背景 ### Pages (app/[locale]/) - operations: 5 個彩色功能模組卡片(帶描述、hover 上浮) - automation: 3 個功能模組卡片 + 系統狀態橫幅 - help: Hero + 4 能力卡片 + 版本資訊表 + Docs 快捷連結 - notifications: Summary Stats + 頻道卡片(帶 Telegram/Slack 圖示) - settings: 加上 bg-[#f5f4ed] 背景 + max-width wrapper - topology: 標題列 Tailwind 化 + Network icon + Live 綠點 ## 技術規範 - 零 inline style(除必要 dynamic 值) - 全 Tailwind CSS - TypeScript tsc --noEmit 通過 - 保留所有 API 邏輯不變,僅優化 UI 呈現層
209 lines
8.0 KiB
TypeScript
209 lines
8.0 KiB
TypeScript
'use client'
|
||
|
||
/**
|
||
* CostPanel — AI 執行效能統計面板 (不含 AppLayout)
|
||
* ==================================================
|
||
* Sprint 5: 從 /cost/page.tsx 抽取
|
||
* 供原始頁面和整合頁面 (/operations) 共用
|
||
*
|
||
* 建立時間: 2026-04-09 (台北時區)
|
||
* 更新時間: 2026-07-03 — UI/UX 全面升級,Tailwind + Stat Cards + 視覺化分佈圖
|
||
*/
|
||
|
||
import { useState, useEffect } from 'react'
|
||
import { useTranslations } from 'next-intl'
|
||
import { TrendingUp, Zap, CheckCircle2, Star, AlertCircle, RefreshCw } from 'lucide-react'
|
||
import { cn } from '@/lib/utils'
|
||
|
||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
|
||
|
||
interface AIPerformance {
|
||
total_proposals: number
|
||
executed_count: number
|
||
execution_rate: number
|
||
success_count: number
|
||
success_rate: number
|
||
avg_effectiveness: number | null
|
||
effectiveness_distribution: Record<string, number>
|
||
}
|
||
|
||
const SCORE_CONFIG = [
|
||
{ score: 1, color: 'text-[#cc2200]', bg: 'bg-[#cc2200]', label: '極差' },
|
||
{ score: 2, color: 'text-[#F59E0B]', bg: 'bg-[#F59E0B]', label: '差' },
|
||
{ score: 3, color: 'text-[#87867f]', bg: 'bg-[#87867f]', label: '普通' },
|
||
{ score: 4, color: 'text-[#4A90D9]', bg: 'bg-[#4A90D9]', label: '好' },
|
||
{ score: 5, color: 'text-[#22C55E]', bg: 'bg-[#22C55E]', label: '極優' },
|
||
]
|
||
|
||
export function CostPanel() {
|
||
const t = useTranslations('cost')
|
||
const [data, setData] = useState<AIPerformance | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
const fetchData = () => {
|
||
setLoading(true)
|
||
setError(null)
|
||
fetch(`${API_BASE}/api/v1/stats/ai-performance?days=30`)
|
||
.then(r => r.json())
|
||
.then((d: AIPerformance) => { setData(d); setLoading(false) })
|
||
.catch(err => { setError(String(err)); setLoading(false) })
|
||
}
|
||
|
||
useEffect(() => { fetchData() }, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
const statCards = data ? [
|
||
{
|
||
label: t('totalProposals'),
|
||
value: data.total_proposals.toLocaleString(),
|
||
icon: TrendingUp,
|
||
accent: 'text-[#4A90D9]',
|
||
bg: 'bg-[#f0f6ff]',
|
||
border: 'border-[#c7dcf7]',
|
||
},
|
||
{
|
||
label: t('executionRate'),
|
||
value: `${data.execution_rate.toFixed(1)}%`,
|
||
icon: Zap,
|
||
accent: 'text-[#d97757]',
|
||
bg: 'bg-[#fff7f3]',
|
||
border: 'border-[#f5cdb8]',
|
||
},
|
||
{
|
||
label: t('successRate'),
|
||
value: `${data.success_rate.toFixed(1)}%`,
|
||
icon: CheckCircle2,
|
||
accent: 'text-[#17602a]',
|
||
bg: 'bg-[#f0faf2]',
|
||
border: 'border-[#8fc29a]',
|
||
},
|
||
{
|
||
label: t('avgEffectiveness'),
|
||
value: data.avg_effectiveness != null ? data.avg_effectiveness.toFixed(2) : '—',
|
||
icon: Star,
|
||
accent: 'text-[#8a5a08]',
|
||
bg: 'bg-[#fff7e8]',
|
||
border: 'border-[#d9b36f]',
|
||
},
|
||
] : []
|
||
|
||
const maxDistCount = data
|
||
? Math.max(...[1,2,3,4,5].map(s => data.effectiveness_distribution[String(s)] ?? 0), 1)
|
||
: 1
|
||
|
||
return (
|
||
<div className="min-h-full bg-[#f5f4ed] p-5 lg:p-6">
|
||
{/* Header */}
|
||
<div className="mb-6 flex min-w-0 items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<p className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">
|
||
AI Performance
|
||
</p>
|
||
<h1 className="mt-1 text-2xl font-bold text-[#141413]">{t('title')}</h1>
|
||
<p className="mt-1 text-sm text-[#77736a]">{t('subtitle')}</p>
|
||
</div>
|
||
<button
|
||
onClick={fetchData}
|
||
disabled={loading}
|
||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-[#e0ddd4] bg-white text-[#77736a] transition hover:border-[#d97757] hover:text-[#d97757] disabled:cursor-not-allowed disabled:opacity-40"
|
||
aria-label="Refresh"
|
||
>
|
||
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Loading */}
|
||
{loading && (
|
||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||
{[0,1,2,3].map(i => (
|
||
<div key={i} className="animate-pulse rounded-xl border border-[#e0ddd4] bg-white p-5">
|
||
<div className="mb-3 h-3 w-24 rounded bg-[#e0ddd4]" />
|
||
<div className="h-8 w-16 rounded bg-[#e0ddd4]" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Error */}
|
||
{!loading && error && (
|
||
<div className="flex items-start gap-3 rounded-xl border border-[#f5cdb8] bg-[#fff7f3] p-4">
|
||
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-[#d97757]" aria-hidden="true" />
|
||
<div>
|
||
<p className="font-semibold text-[#141413]">{t('error')}</p>
|
||
<p className="mt-1 font-mono text-xs text-[#77736a]">{error}</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Data */}
|
||
{!loading && !error && data && (
|
||
<div className="grid gap-4">
|
||
{/* Stat Cards */}
|
||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||
{statCards.map(({ label, value, icon: Icon, accent, bg, border }) => (
|
||
<div
|
||
key={label}
|
||
className={cn('rounded-xl border p-5 transition hover:shadow-sm', bg, border)}
|
||
>
|
||
<div className="flex items-center justify-between gap-2">
|
||
<p className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">
|
||
{label}
|
||
</p>
|
||
<Icon className={cn('h-4 w-4 shrink-0', accent)} aria-hidden="true" />
|
||
</div>
|
||
<p className={cn('mt-3 font-mono text-3xl font-bold tabular-nums', accent)}>
|
||
{value}
|
||
</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Effectiveness Distribution */}
|
||
{Object.keys(data.effectiveness_distribution).length > 0 && (
|
||
<div className="overflow-hidden rounded-xl border border-[#e0ddd4] bg-white">
|
||
<div className="flex items-center gap-2 border-b border-[#e0ddd4] bg-[#faf9f3] px-5 py-3">
|
||
<Star className="h-4 w-4 text-[#d97757]" aria-hidden="true" />
|
||
<h2 className="font-semibold text-[#141413]">
|
||
{t('effectivenessDistribution')}
|
||
</h2>
|
||
<span className="ml-auto font-mono text-[11px] text-[#77736a]">1 – 5</span>
|
||
</div>
|
||
<div className="p-5">
|
||
<div className="grid grid-cols-5 gap-3">
|
||
{SCORE_CONFIG.map(({ score, color, bg, label }) => {
|
||
const count = data.effectiveness_distribution[String(score)] ?? 0
|
||
const pct = maxDistCount > 0 ? (count / maxDistCount) * 100 : 0
|
||
return (
|
||
<div key={score} className="flex flex-col items-center gap-2">
|
||
<span className={cn('text-[11px] font-bold uppercase', color)}>{label}</span>
|
||
{/* Mini bar */}
|
||
<div className="relative h-20 w-full overflow-hidden rounded bg-[#f5f4ed]">
|
||
<div
|
||
className={cn('absolute bottom-0 left-0 right-0 rounded transition-all duration-700', bg)}
|
||
style={{ height: `${pct}%` }}
|
||
/>
|
||
</div>
|
||
<span className={cn('font-mono text-xl font-bold', color)}>{count}</span>
|
||
<span className="font-mono text-[11px] text-[#77736a]">★{score}</span>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* No data */}
|
||
{!loading && !error && !data && (
|
||
<div className="flex flex-col items-center justify-center rounded-xl border border-[#e0ddd4] bg-white py-16">
|
||
<TrendingUp className="h-10 w-10 text-[#e0ddd4]" aria-hidden="true" />
|
||
<p className="mt-4 font-semibold text-[#141413]">{t('noData')}</p>
|
||
<p className="mt-1 text-sm text-[#77736a]">{t('subtitle')}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|