Files
awoooi/apps/web/src/components/panels/CostPanel.tsx
Your Name 0c740f70e3
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
feat(ui): 全站 UI/UX 專業化重構 — Panel + Page Tailwind 化
## 改善範圍

### 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 呈現層
2026-07-03 21:21:21 +08:00

209 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
)
}