// Page 1: 總覽 — KPIs, 異常橫條, 堆疊面積圖, Top brands 排行
function gotoBrand(b) {
if (b && b.id) window.location.hash = '#/brands/' + encodeURIComponent(b.id);
}
function PageOverview({ filters, density, chartStyle }) {
const { BRANDS, DAILY_AGG, TODAY, DAYS } = window.MOCK;
// 真實 KPI 直接拿後端聚合結果,不要在前端用 BRANDS.dailyCost 推算
// (cost-trend 只回 top5 + 'other',對其他品牌都是 0,會失真)
const live = (window.LIVE && window.LIVE.kpis) || {};
const todayCost = (DAILY_AGG[DAYS - 1] && DAILY_AGG[DAYS - 1].total) || 0;
const yesterdayCost = (DAILY_AGG[DAYS - 2] && DAILY_AGG[DAYS - 2].total) || 0;
const todayConvos = BRANDS.reduce((s, b) => s + (b.dailyConvos[DAYS - 1] || 0), 0);
const yesterdayConvos = BRANDS.reduce((s, b) => s + (b.dailyConvos[DAYS - 2] || 0), 0);
const periodCost = +(live.total_cost_usd || 0);
const periodConvos = +(live.total_convos || 0);
const activeBrandIds = +(live.active_brands || BRANDS.length);
const totalTokens = +(live.total_tokens || 0);
const avgCostPerConvo = +(live.avg_cost_per_convo || 0);
const avgCacheHit = +(live.cache_hit_rate || 0);
const deltaCost = +(live.delta_total_cost_usd || 0);
const deltaConvos = +(live.delta_total_convos || 0);
const deltaActive = +(live.delta_active_brands || 0);
const deltaTokens = +(live.delta_total_tokens || 0);
const deltaAvgCost = +(live.delta_avg_cost_per_convo || 0);
const totalSpark = DAILY_AGG.map(d => d.total);
const anomalous = BRANDS.filter(b => b.isAnomalous).sort((a, b) => b.anomalyRatio - a.anomalyRatio);
const topBrands = [...BRANDS].sort((a, b) => b.totalCost - a.totalCost);
const top5 = topBrands.slice(0, 5);
const otherBrands = topBrands.slice(5);
const stackSeries = top5.map((b) => ({
key: b.id,
name: b.name.split(' ')[0],
values: b.dailyCost,
})).concat([{
key: 'other', name: '其他',
values: DAILY_AGG.map((d, i) => otherBrands.reduce((s, b) => s + b.dailyCost[i], 0)),
}]);
return (
{anomalous.length > 0 &&
}
Object.values(d.perBrand).length)} sparkColor="#1D4ED8" inverse />
d.total * 220000)} sparkColor="#0891B2" inverse />
d.total / Math.max(1, BRANDS.reduce((s, b) => s + (b.dailyConvos[i] || 0), 0)))} sparkColor="#DB2777" inverseDelta />
{fmtUSD(periodCost, 0)}
>}
/>
{(w, h) => (
d.date)}
colors={['#B45309', '#1D4ED8', '#6D28D9', '#15803D', '#DB2777', '#9098A3']}
w={w} h={h}
/>
)}
0 ? (todayCost - yesterdayCost) / yesterdayCost : 0} />
0 ? (todayConvos - yesterdayConvos) / yesterdayConvos : 0} inverseDelta />
(b.dailyCost[DAYS - 1] || 0) > 0.001).length} delta={0} inverseDelta />
{ window.location.hash = '#/brands'; }} style={{ fontSize: 11, color: '#5C6470', padding: '4px 8px', border: '1px solid #E5E5E0', borderRadius: 4 }}>顯示全部 {BRANDS.length} 個 →} />
);
}
function PageHeader({ title, subtitle }) {
return (
{title}
{subtitle && {subtitle}}
);
}
function Card({ children, style }) {
return (
{children}
);
}
function CardHeader({ title, subtitle, right }) {
return (
{title}
{subtitle && {subtitle}}
{right}
);
}
function KPICard({ label, value, delta, spark, sparkColor, inverse, inverseDelta }) {
const isGood = inverseDelta ? delta < 0 : delta > 0;
const deltaColor = delta === 0 ? '#9098A3' : isGood ? '#15803D' : '#DC2626';
const arrow = delta > 0 ? '↑' : delta < 0 ? '↓' : '·';
return (
{label}
{value}
{arrow} {fmtDelta(Math.abs(delta))} vs 上期
{spark && }
);
}
function BigStat({ label, value, delta, inverseDelta }) {
const isGood = inverseDelta ? delta < 0 : delta > 0;
const deltaColor = isGood ? '#15803D' : '#DC2626';
return (
{label}
{value}
{fmtDelta(delta)}
);
}
function HourlyBars() {
const [hours, setHours] = React.useState(null); // null = loading
React.useEffect(() => {
let alive = true;
window.AdminAPI.overview.hourly()
.then((data) => { if (alive) setHours((data && data.hours) || []); })
.catch(() => { if (alive) setHours([]); });
return () => { alive = false; };
}, []);
if (hours == null) {
return 載入中…
;
}
const costs = hours.map(h => h.cost_usd || 0);
const max = Math.max(0.0001, ...costs);
const nowHour = new Date().getUTCHours(); // 後端用 UTC,前端 bar 也按 UTC 標當前小時
return (
{hours.map((h, i) => {
const ratio = h.cost_usd ? Math.max(0.05, h.cost_usd / max) : 0;
return (
0 ? `${ratio * 100}%` : '2px',
background: i === nowHour ? '#FFE600' : i < nowHour ? '#5C6470' : '#E5E5E0',
opacity: h.cost_usd === 0 ? 0.35 : 1,
borderRadius: 1,
}} title={`${String(i).padStart(2, '0')}:00 UTC · ${fmtUSD(h.cost_usd, 4)} · ${h.convos} 場`}>
);
})}
);
}
function Legend({ items }) {
return (
{items.map((it, i) => (
{it.label}
{it.value}
))}
);
}
function ScaleToggle() {
const [scale, setScale] = React.useState('linear');
const opts = [{ id: 'linear', label: '線性' }, { id: 'log', label: '對數' }];
return (
{opts.map(s => (
))}
);
}
function AnomalyBanner({ anomalous, onPick }) {
const top = anomalous[0];
return (
{anomalous.length} 個品牌今日費用超過 7 日均的 3 倍:{' '}
{anomalous.slice(0, 3).map((b, i) => (
{' '}
{fmtUSD(b.todayCost, 0)} ({b.anomalyRatio}×)
{i < Math.min(2, anomalous.length - 1) ? '、' : ''}
))}
{anomalous.length > 3 && 還有 {anomalous.length - 3} 個}
);
}
function BrandsTable({ brands, density, compact, onRowClick }) {
const rowH = density === 'comfy' ? 38 : 32;
const cellPad = density === 'comfy' ? '8px 12px' : '6px 12px';
return (
| 排名 |
品牌 |
方案 |
對話數 |
Token |
費用 |
平均/場 |
14 天趨勢 |
狀態 |
{brands.map((b, i) => (
onRowClick && onRowClick(b)}
style={{
borderBottom: '1px solid #EEEEE9',
background: i % 2 ? '#FAFAF9' : '#FFFFFF',
cursor: onRowClick ? 'pointer' : 'default',
height: rowH,
}}
onMouseEnter={(e) => e.currentTarget.style.background = '#FFFAB8'}
onMouseLeave={(e) => e.currentTarget.style.background = i % 2 ? '#FAFAF9' : '#FFFFFF'}
>
| {i + 1} |
{b.name}
{!compact && {b.id} }
|
|
{fmtNum(b.totalConvos)} |
{fmtCompact(b.tokens)} |
{fmtUSD(b.totalCost, 2)} |
{fmtUSD(b.avgCostPerConvo, 3)} |
|
|
))}
);
}
function Th({ children, style }) {
return {children} | ;
}
function BrandThumb({ id }) {
const hash = id.split('').reduce((a, c) => a + c.charCodeAt(0), 0);
const hue = (hash * 47) % 360;
return (
{id.slice(0, 2).toUpperCase()}
);
}
window.PageOverview = PageOverview;
window.PageHeader = PageHeader;
window.Card = Card;
window.CardHeader = CardHeader;
window.KPICard = KPICard;
window.BrandsTable = BrandsTable;
window.BrandThumb = BrandThumb;
window.Th = Th;