// Page 4: 費用分析 — per-model donut, cache trend, token mix, forecast
function PageCost({ filters, density, chartStyle }) {
const { BRANDS, DAILY_AGG, TOKEN_MIX, TODAY, DAYS } = window.MOCK;
// 真實 per-model 統計從 cost.byModel endpoint 來;不再用 MODELS array + share 寫死
const liveModels = (window.LIVE && window.LIVE.byModel && window.LIVE.byModel.models) || [];
const byModel = liveModels.map((m) => ({
id: m.model,
provider: m.provider || (m.model && m.model.toLowerCase().includes('claude') ? 'anthropic' :
m.model && m.model.toLowerCase().includes('gpt') ? 'openai' : 'other'),
cost: m.cost_usd || 0,
requests: m.requests || 0,
tokens: m.tokens || 0,
// 後端尚未回 per-model price / latency / 14d trend,以下三項為 placeholder
priceIn: null,
priceOut: null,
latency: null,
trend: null,
})).sort((a, b) => b.cost - a.cost);
const totalCost = byModel.reduce((s, m) => s + m.cost, 0);
const projectedMonth = DAYS > 0 ? totalCost * (30 / DAYS) : 0;
const monthBudget = 18500; // TODO: 接預算管理表
const cacheTrend = TOKEN_MIX.map(d => d.cacheHit);
const tokenMix = TOKEN_MIX.map(d => ({
date: d.date,
input: d.fresh,
output: d.output,
cache_read: d.cacheRead,
cache_creation: d.cacheCreate,
}));
return (
({ value: m.cost, color: TOKENS.chart[i % TOKENS.chart.length] }))}
size={130} thickness={20}
centerLabel="總計" centerValue={fmtUSD(totalCost, 0)}
/>
{byModel.map((m, i) => (
{m.id}
{fmtUSD(m.cost, 0)}
{fmtPct(m.cost / totalCost, 0)}
))}
= 0.7 ? '#15803D' : '#B45309', fontWeight: 600 }}>
現在 {fmtPct(cacheTrend[cacheTrend.length - 1])}
} />
d.date)} />
↑ Cache 建立 4/29 飆高} />
);
}
function ForecastBar({ projected, budget, actual, daysIn }) {
const projectedPct = (projected / budget) * 100;
const actualPct = (actual / budget) * 100;
const overBudget = projected > budget;
return (
本月實際 ({daysIn}/30 天)
{fmtUSD(actual, 0)}
月底預測
{fmtUSD(projected, 0)}
使用率
80 ? '#B45309' : '#15803D' }}>{projectedPct.toFixed(0)}%
已實際支出
線性預測
{overBudget && ⚠ 預期超出預算 {fmtUSD(projected - budget, 0)}}
);
}
function ModelComparisonTable({ models, totalCost }) {
return (
| 模型 |
輸入 $/M |
輸出 $/M |
平均延遲 |
使用占比 |
費用 |
請求數 |
{models.map((m, i) => (
|
{m.id}
|
{m.priceIn != null ? '$' + m.priceIn.toFixed(2) : '—'} |
{m.priceOut != null ? '$' + m.priceOut.toFixed(2) : '—'} |
{m.latency != null ? m.latency + 'ms' : '—'} |
{totalCost > 0 ? fmtPct(m.cost / totalCost, 1) : '—'} |
{fmtUSD(m.cost, m.cost < 10 ? 4 : 0)} |
{fmtNum(m.requests || 0)} |
))}
);
}
function CacheTrendChart({ data, dates }) {
const w = 440, h = 180;
const padL = 32, padR = 12, padT = 14, padB = 24;
const innerW = w - padL - padR;
const innerH = h - padT - padB;
const target = 0.7;
const max = 1;
const stepX = innerW / (data.length - 1);
const path = data.map((v, i) => {
const x = padL + i * stepX;
const y = padT + innerH - (v / max) * innerH;
return (i === 0 ? 'M' : 'L') + x.toFixed(1) + ',' + y.toFixed(1);
}).join(' ');
const targetY = padT + innerH - (target / max) * innerH;
return (
);
}
function TokenMixChart({ data }) {
const w = 440, h = 180;
const padL = 40, padR = 12, padT = 14, padB = 24;
const innerW = w - padL - padR;
const innerH = h - padT - padB;
const totals = data.map(d => d.input + d.output + d.cache_read + d.cache_creation);
const max = Math.max(...totals) * 1.05;
const barW = innerW / data.length * 0.8;
const stepX = innerW / data.length;
const colors = { input: '#1D4ED8', output: '#15803D', cache_read: '#6D28D9', cache_creation: '#B45309' };
const labels = { input: '輸入', output: '輸出', cache_read: 'Cache 讀', cache_creation: 'Cache 建' };
return (
{Object.entries(labels).map(([k, l]) => (
{l}
))}
);
}
function CacheBrandTable({ brands }) {
const sorted = [...brands].sort((a, b) => a.cacheHit - b.cacheHit).slice(0, 10);
return (
| # |
品牌 |
對話數 |
Cache 命中 |
潛在節省 |
vs 目標 70% |
{sorted.map((b, i) => {
const gap = 0.7 - b.cacheHit;
const savings = gap > 0 ? b.totalCost * gap * 0.85 : 0;
return (
| {i + 1} |
{b.name}
|
{fmtNum(b.totalConvos)} |
{fmtPct(b.cacheHit)} |
~{fmtUSD(savings, 0)}/月 |
= 0.7 ? '#15803D' : b.cacheHit >= 0.5 ? '#B45309' : '#DC2626',
}}>
|
);
})}
);
}
window.PageCost = PageCost;