<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>csusage · Token 用量</title>
<style>
:root {
--bg: #0d1117;
--bg-card: #161b22;
--border: #30363d;
--text: #e6edf3;
--text-dim: #8b949e;
--text-faint: #6e7681;
--accent: #58a6ff;
--green: #3fb950;
--heat-0: #21262d;
--heat-1: #0e4429;
--heat-2: #006d32;
--heat-3: #26a641;
--heat-4: #39d353;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, "Segoe UI", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
font-size: 14px;
padding: 32px 40px;
max-width: 1200px;
margin: 0 auto;
}
.header { display: flex; align-items: center; gap: 16px; margin-bottom: 28px; }
.avatar {
width: 56px; height: 56px; border-radius: 50%;
background: linear-gradient(135deg, #1f6feb, #58a6ff);
display: flex; align-items: center; justify-content: center;
font-size: 24px; font-weight: 600; color: #fff; flex-shrink: 0;
}
.header h1 { font-size: 20px; font-weight: 600; }
.header .sub { color: var(--text-dim); font-size: 13px; margin-top: 4px; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 14px; margin-bottom: 28px; }
.card {
background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px;
padding: 16px 18px;
}
.card .label { color: var(--text-dim); font-size: 12px; margin-bottom: 8px; }
.card .value { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; }
.card .detail { color: var(--text-faint); font-size: 12px; margin-top: 4px; }
.section { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 20px; margin-bottom: 24px; }
.section-title { font-size: 15px; font-weight: 600; margin-bottom: 16px; display: flex; align-items: center; justify-content: space-between; }
.toggle { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
.toggle button {
background: transparent; color: var(--text-dim); border: none; padding: 5px 14px;
font-size: 12px; cursor: pointer;
}
.toggle button.active { background: #21262d; color: var(--text); }
.heatmap-wrap { overflow-x: auto; }
.heatmap { display: flex; gap: 3px; }
.heatmap .col { display: flex; flex-direction: column; gap: 3px; }
.heatmap .cell {
width: 13px; height: 13px; border-radius: 3px;
background: var(--heat-0); position: relative;
}
.month-labels { display: flex; gap: 3px; margin-bottom: 4px; height: 14px; font-size: 10px; color: var(--text-faint); }
.month-labels .m { padding-top: 2px; }
.heatmap-body { display: flex; gap: 6px; }
.day-labels { display: flex; flex-direction: column; gap: 3px; font-size: 10px; color: var(--text-faint); width: 26px; }
.day-labels span { height: 13px; line-height: 13px; }
.legend { display: flex; align-items: center; gap: 6px; margin-top: 12px; font-size: 11px; color: var(--text-dim); justify-content: flex-end; }
.legend .sw { width: 13px; height: 13px; border-radius: 3px; }
.tooltip {
position: fixed; background: #010409; border: 1px solid var(--border); border-radius: 6px;
padding: 8px 12px; font-size: 12px; pointer-events: none; z-index: 10; display: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.4); white-space: nowrap;
}
table { width: 100%; border-collapse: collapse; font-size: 13px; font-variant-numeric: tabular-nums; }
th { color: var(--text-dim); font-weight: 500; text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 12px; }
td { padding: 8px 12px; border-bottom: 1px solid #21262d; color: var(--text); }
tr:last-child td { border-bottom: none; }
td.num, th.num { text-align: right; }
.agent-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 8px; }
.loading { text-align: center; color: var(--text-dim); padding: 80px 0; font-size: 15px; }
.empty { text-align: center; color: var(--text-faint); padding: 60px 0; }
@media (prefers-color-scheme: light) {
:root { --bg:#ffffff; --bg-card:#f6f8fa; --border:#d0d7de; --text:#1f2328; --text-dim:#57606a; --text-faint:#8c959f; --heat-0:#ebedf0; }
}
</style>
</head>
<body>
<div id="app" class="loading">正在加载用量数据…</div>
<div class="tooltip" id="tooltip"></div>
<script>
const fmtInt = n => Number(n || 0).toLocaleString();
const fmtCompact = n => {
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
return String(n);
};
const fmtCost = c => c == null ? '—' : (c < 0.01 && c > 0 ? '$' + c.toFixed(6) : '$' + c.toFixed(2));
const fmtDuration = ms => {
if (!ms || ms <= 0) return '—';
const mins = Math.floor(ms / 60000);
if (mins < 1) return '<1 分钟';
if (mins < 60) return mins + ' 分钟';
const hours = Math.floor(mins / 60);
if (hours < 24) return hours + ' 小时 ' + (mins % 60) + ' 分';
return Math.floor(hours / 24) + ' 天 ' + (hours % 24) + ' 小时';
};
const AGENT_COLORS = ['58a6ff','3fb950','d29922','f778ba','a371f7','39c5cf','fb8532','8b949e','ed4d4d','7ee787','7aa2f7','ff9e64','9ece6a','bb9af7','ff757f','e0af68','73daca','c0caf5','f7768e'];
const HEAT = ['--heat-0','--heat-1','--heat-2','--heat-3','--heat-4'].map(v => getComputedStyle(document.documentElement).getPropertyValue(v).trim());
let data = null;
let mode = 'daily';
async function main() {
const res = await fetch('/api/usage');
data = await res.json();
render();
}
function render() {
const user = (data.user || 'user').toString();
const initial = user.charAt(0).toUpperCase();
const agents = data.agents || [];
const peak = data.peakDay || {};
document.getElementById('app').innerHTML = `
<div class="header">
<div class="avatar">${initial}</div>
<div>
<h1>${user}</h1>
<div class="sub">共检测到 ${agents.length} 个 agent · 数据截至 ${new Date(data.generatedAt).toLocaleString('zh-CN')}</div>
</div>
</div>
<div class="cards">
${card('累计 Token', fmtInt(data.totalTokens), `输入 ${fmtInt(data.totalInputTokens)} · 输出 ${fmtInt(data.totalOutputTokens)}`)}
${card('累计成本', fmtCost(data.totalCostUsd), `${fmtInt(data.totalSessions)} 个会话`)}
${card('峰值 Token(单日)', fmtCompact(peak.totalTokens || 0), peak.date ? new Date(peak.date).toLocaleDateString('zh-CN') : '—')}
${card('最长会话时长', fmtDuration(data.longestSessionMs), '')}
${card('当前连续天数', (data.currentStreakDays || 0) + ' 天', '')}
${card('最长连续天数', (data.longestStreakDays || 0) + ' 天', '')}
</div>
<div class="section">
<div class="section-title">
<span>用量热力图</span>
<div class="toggle">
<button id="btn-daily" onclick="setMode('daily')">每日</button>
<button id="btn-weekly" onclick="setMode('weekly')">每周</button>
<button id="btn-cumulative" onclick="setMode('cumulative')">累计</button>
</div>
</div>
<div class="heatmap-wrap">${renderHeatmap()}</div>
<div class="legend">
<span>少</span>
${HEAT.map(c => `<span class="sw" style="background:${c}"></span>`).join('')}
<span>多</span>
</div>
</div>
${agents.length ? `
<div class="section">
<div class="section-title"><span>Agent 明细</span></div>
<table>
<thead><tr><th>Agent</th><th class="num">输入</th><th class="num">输出</th><th class="num">缓存读</th><th class="num">缓存写</th><th class="num">合计</th><th class="num">成本</th></tr></thead>
<tbody>${agents.map((a, i) => `
<tr>
<td><span class="agent-dot" style="background:#${AGENT_COLORS[i % AGENT_COLORS.length]}"></span>${a.agent}</td>
<td class="num">${fmtInt(a.inputTokens)}</td>
<td class="num">${fmtInt(a.outputTokens)}</td>
<td class="num">${fmtInt(a.cacheReadTokens)}</td>
<td class="num">${fmtInt(a.cacheWriteTokens)}</td>
<td class="num">${fmtInt(a.totalTokens)}</td>
<td class="num">${fmtCost(a.costUsd)}</td>
</tr>`).join('')}
</tbody>
</table>
</div>` : ''}
${data.sessions && data.sessions.length ? `
<div class="section">
<div class="section-title"><span>最近会话(前 30)</span></div>
<table>
<thead><tr><th>Agent</th><th>会话</th><th>开始时间</th><th class="num">时长</th><th class="num">合计</th><th class="num">成本</th><th>模型</th></tr></thead>
<tbody>${data.sessions.slice(0, 30).map(s => `
<tr>
<td>${s.agent}</td>
<td title="${s.sessionId}">${s.sessionId.length > 18 ? s.sessionId.slice(0, 18) + '…' : s.sessionId}</td>
<td>${new Date(s.startMs).toLocaleString('zh-CN')}</td>
<td class="num">${fmtDuration(s.durationMs)}</td>
<td class="num">${fmtInt(s.totalTokens)}</td>
<td class="num">${fmtCost(s.costUsd)}</td>
<td>${(s.models || []).join(', ')}</td>
</tr>`).join('')}
</tbody>
</table>
</div>` : ''}
`;
setMode(mode);
}
function card(label, value, detail) {
return `<div class="card"><div class="label">${label}</div><div class="value">${value}</div>${detail ? `<div class="detail">${detail}</div>` : ''}</div>`;
}
function setMode(m) {
mode = m;
['daily', 'weekly', 'cumulative'].forEach(k => {
const btn = document.getElementById('btn-' + k);
if (btn) btn.classList.toggle('active', k === m);
});
document.querySelector('.heatmap-wrap').innerHTML = renderHeatmap();
}
function renderHeatmap() {
const days = (data.days || []).map(d => ({...d, date: new Date(d.date + 'T00:00:00Z')}));
if (!days.length) return '<div class="empty">没有用量数据</div>';
days.sort((a, b) => a.date - b.date);
let cells;
if (mode === 'daily') {
cells = buildDailyCells(days);
} else if (mode === 'weekly') {
cells = buildWeeklyCells(days);
} else {
cells = buildCumulativeCells(days);
}
const max = Math.max(...cells.map(c => c.value), 1);
const months = [];
let lastMonth = -1;
const cols = [];
let col = [];
cells.forEach((cell, i) => {
const m = cell.date.getUTCMonth();
if (m !== lastMonth) {
months.push({idx: cols.length, name: cell.date.getUTCMonth() + 1 + '月'});
lastMonth = m;
}
col.push(cell);
if (cell.date.getUTCDay() === 6 || i === cells.length - 1) {
cols.push(col);
col = [];
}
});
let html = `<div class="month-labels">${cols.map((c, i) => {
const m = months.find(mm => mm.idx === i);
return `<div class="m" style="width:16px;${m ? '' : 'visibility:hidden'}">${m ? m.name : ''}</div>`;
}).join('')}</div>`;
html += `<div class="heatmap-body"><div class="day-labels"><span></span><span>周一</span><span></span><span>周三</span><span></span><span>周五</span><span></span></div><div class="heatmap">`;
html += cols.map(c => `<div class="col">${c.map(cell => {
const t = level(cell.value, max);
return `<div class="cell" style="background:${cell.value > 0 ? HEAT[t] : getComputedStyle(document.documentElement).getPropertyValue('--heat-0').trim()}" data-tip="${cell.tip}"></div>`;
}).join('')}</div>`).join('');
html += '</div></div>';
const tooltip = document.getElementById('tooltip');
setTimeout(() => {
document.querySelectorAll('.cell').forEach(cell => {
cell.onmouseenter = e => {
tooltip.textContent = cell.dataset.tip;
tooltip.style.display = 'block';
tooltip.style.left = Math.min(e.clientX + 12, window.innerWidth - 260) + 'px';
tooltip.style.top = (e.clientY + 14) + 'px';
};
cell.onmouseleave = () => tooltip.style.display = 'none';
});
}, 0);
return html;
}
function padDays(days) {
const first = days[0].date;
const start = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth(), first.getUTCDate()));
start.setUTCDate(start.getUTCDate() - start.getUTCDay());
const last = days[days.length - 1].date;
const end = new Date(Date.UTC(last.getUTCFullYear(), last.getUTCMonth(), last.getUTCDate()));
end.setUTCDate(end.getUTCDate() + (6 - end.getUTCDay()));
const byDate = new Map(days.map(d => [d.date.toISOString().slice(0, 10), d]));
const out = [];
for (let d = new Date(start); d <= end; d.setUTCDate(d.getUTCDate() + 1)) {
const key = d.toISOString().slice(0, 10);
const day = byDate.get(key);
out.push({
date: new Date(d),
value: day ? day.totalTokens : 0,
tip: key + (day ? ` · ${fmtInt(day.totalTokens)} tokens · 输入 ${fmtInt(day.inputTokens)} 输出 ${fmtInt(day.outputTokens)} · ${fmtCost(day.costUsd)}` : ' · 无用量'),
});
}
return out;
}
function buildDailyCells(days) {
return padDays(days);
}
function buildWeeklyCells(days) {
const weeks = new Map();
for (const d of days) {
const key = d.date.toISOString().slice(0, 10);
const dt = new Date(d.date);
dt.setUTCDate(dt.getUTCDate() - dt.getUTCDay());
const wk = dt.toISOString().slice(0, 10);
if (!weeks.has(wk)) weeks.set(wk, {date: new Date(dt), value: 0, tip: ''});
weeks.get(wk).value += d.totalTokens;
}
const arr = [...weeks.values()];
for (const wk of arr) {
wk.tip = `周(${wk.date.toISOString().slice(0, 10)} 起)· ${fmtInt(wk.value)} tokens`;
}
return arr;
}
function buildCumulativeCells(days) {
let total = 0;
return padDays(days).map(d => {
total += d.value;
return {date: d.date, value: d.value === 0 ? 0 : total, tip: d.tip + ` · 累计 ${fmtInt(total)} tokens`};
});
}
function level(value, max) {
if (value <= 0) return 0;
const ratio = value / max;
if (ratio <= 0.25) return 1;
if (ratio <= 0.5) return 2;
if (ratio <= 0.75) return 3;
return 4;
}
main();
</script>
</body>
</html>