csusage 0.4.0

Coding agent CLI usage reports (ccusage fork with Claude Science support)
<!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>