Files
tecdo-spend-amount/index.html
T

515 lines
25 KiB
HTML
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.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tecdo 消耗与余额</title>
<link rel="icon" href="data:,">
<style>
:root {
color-scheme: light;
--page: #f9f9f7; --surface: #fcfcfb; --ink: #0b0b0b; --ink-2: #52514e; --muted: #898781;
--grid: #e1e0d9; --axis: #c3c2b7; --border: rgba(11,11,11,.10);
--s1: #2a78d6; --s2: #eb6834; --s3: #1baf7a; --s4: #eda100; --s5: #e87ba4; --other: #a3a29b;
--warn: #fab219; --crit: #d03b3b;
}
@media (prefers-color-scheme: dark) {
:root:where(:not([data-theme="light"])) {
color-scheme: dark;
--page: #0d0d0d; --surface: #1a1a19; --ink: #ffffff; --ink-2: #c3c2b7; --muted: #898781;
--grid: #2c2c2a; --axis: #383835; --border: rgba(255,255,255,.10);
--s1: #3987e5; --s2: #d95926; --s3: #199e70; --s4: #c98500; --s5: #d55181; --other: #6b6a65;
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--page: #0d0d0d; --surface: #1a1a19; --ink: #ffffff; --ink-2: #c3c2b7; --muted: #898781;
--grid: #2c2c2a; --axis: #383835; --border: rgba(255,255,255,.10);
--s1: #3987e5; --s2: #d95926; --s3: #199e70; --s4: #c98500; --s5: #d55181; --other: #6b6a65;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--page); color: var(--ink); font: 14px/1.5 system-ui, -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
main { max-width: 1080px; margin: 0 auto; padding: 28px 20px 48px; }
h1 { font-size: 22px; font-weight: 600; margin: 0 0 4px; }
.meta { color: var(--ink-2); font-size: 13px; display: flex; flex-wrap: wrap; gap: 2px 14px; }
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; margin: 20px 0 16px; }
.tile, .card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; }
.tile { padding: 14px 16px; }
.tile-label { color: var(--ink-2); font-size: 13px; }
.tile-value { font-size: 26px; font-weight: 600; margin-top: 2px; letter-spacing: -.01em; }
.tile-sub { color: var(--muted); font-size: 12px; margin-top: 4px; }
.card { padding: 16px 18px; margin-bottom: 16px; }
.card h2 { font-size: 15px; font-weight: 600; margin: 0; }
.card-head { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 8px 16px; }
.desc { color: var(--ink-2); font-size: 12px; margin: 2px 0 12px; }
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.seg button { font: inherit; font-size: 12px; border: 0; background: transparent; color: var(--ink-2); padding: 4px 10px; cursor: pointer; }
.seg button + button { border-left: 1px solid var(--border); }
.seg button[aria-pressed="true"] { background: var(--ink); color: var(--surface); }
select { font: inherit; font-size: 12px; color: var(--ink); background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 4px 8px; max-width: 100%; }
.legend { display: flex; flex-wrap: wrap; gap: 4px 18px; font-size: 12px; color: var(--ink-2); margin-bottom: 4px; }
.sw { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 6px; vertical-align: -1px; flex: none; }
.chart { position: relative; }
.chart svg { display: block; max-width: 100%; }
.grid { stroke: var(--grid); stroke-width: 1; }
.axis { stroke: var(--axis); stroke-width: 1; }
.tick { fill: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; }
.val { fill: var(--ink); font-size: 12px; font-weight: 600; }
.hover-band { fill: var(--ink); opacity: .05; }
.line { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
.dot { stroke: var(--surface); stroke-width: 2; }
.cross { stroke: var(--axis); stroke-width: 1; }
.tip { position: absolute; z-index: 2; pointer-events: none; min-width: 180px; max-width: 340px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 6px 20px rgba(0,0,0,.14); padding: 8px 10px; font-size: 12px; }
.tip-title { font-weight: 600; margin-bottom: 4px; }
.tip-row { display: flex; align-items: center; gap: 6px; }
.tip-name { flex: 1; color: var(--ink-2); overflow-wrap: anywhere; }
.tip-val { font-weight: 600; font-variant-numeric: tabular-nums; }
.tip-sub { color: var(--muted); padding-left: 16px; margin-bottom: 2px; }
.tip-total { border-top: 1px solid var(--grid); margin-top: 4px; padding-top: 4px; }
.table-wrap { overflow-x: auto; }
table { border-collapse: collapse; width: 100%; font-size: 13px; white-space: nowrap; }
th { text-align: left; color: var(--muted); font-weight: 500; font-size: 12px; padding: 6px 10px; border-bottom: 1px solid var(--axis); }
td { padding: 7px 10px; border-bottom: 1px solid var(--grid); }
th.num, td.num { text-align: right; font-variant-numeric: tabular-nums; }
tr.sub td { font-weight: 600; border-top: 1px solid var(--axis); }
tfoot td { font-weight: 600; border-bottom: none; }
.badge { display: inline-flex; align-items: center; gap: 5px; color: var(--ink); }
.badge-icon { display: inline-grid; place-items: center; width: 15px; height: 15px; border-radius: 50%; font-size: 10px; font-weight: 700; color: #0b0b0b; }
.badge.warn .badge-icon { background: var(--warn); }
.badge.crit .badge-icon { background: var(--crit); color: #fff; }
footer { color: var(--muted); font-size: 12px; margin-top: 8px; }
#err { background: var(--surface); border: 1px solid var(--crit); border-radius: 8px; padding: 14px; margin-top: 20px; }
</style>
</head>
<body>
<main>
<header>
<h1>Tecdo(钛动)消耗与余额</h1>
<div class="meta" id="meta">加载中…</div>
</header>
<div id="err" hidden></div>
<section class="tiles" id="tiles"></section>
<section class="card">
<div class="card-head">
<div>
<h2>每日消耗</h2>
<p class="desc">按包堆叠,悬停查看当天每个包的消耗、注册和首充</p>
</div>
<div class="seg" id="range" role="group" aria-label="时间范围">
<button type="button" data-range="7" aria-pressed="true">近 7 天</button>
<button type="button" data-range="30" aria-pressed="false">近 30 天</button>
<button type="button" data-range="all" aria-pressed="false">全部</button>
</div>
</div>
<div class="legend" id="spend-legend"></div>
<div class="chart" id="spend-chart"><div class="tip" hidden></div></div>
</section>
<section class="card">
<h2>余额走势</h2>
<p class="desc">每天日终余额 = 截至当天的累计到账 − 累计消耗,圆点是到账日</p>
<div class="chart" id="balance-chart"><div class="tip" hidden></div></div>
</section>
<section class="card">
<h2>分包汇总</h2>
<p class="desc">全部历史,按消耗从高到低;成本 = 消耗 ÷ 首充</p>
<div class="table-wrap">
<table id="pkgs">
<thead><tr><th>产品名</th><th>投放日期</th><th class="num">安装</th><th class="num">注册</th><th class="num">首充</th><th class="num">消耗</th><th class="num">成本</th></tr></thead>
<tbody></tbody><tfoot></tfoot>
</table>
</div>
</section>
<section class="card">
<h2>分月汇总</h2>
<p class="desc">每月先列合计,再列当月有数据的包</p>
<div class="table-wrap">
<table id="months">
<thead><tr><th>月份</th><th>产品名</th><th class="num">安装</th><th class="num">注册</th><th class="num">首充</th><th class="num">消耗</th><th class="num">成本</th></tr></thead>
<tbody></tbody>
</table>
</div>
</section>
<section class="card">
<div class="card-head">
<div>
<h2>分天明细</h2>
<p class="desc">按日期倒序</p>
</div>
<select id="pkg-filter" aria-label="按包筛选"></select>
</div>
<div class="table-wrap">
<table id="daily">
<thead><tr><th>日期</th><th>产品名</th><th class="num">安装</th><th class="num">注册</th><th class="num">首充</th><th class="num">消耗</th><th class="num">成本</th></tr></thead>
<tbody></tbody><tfoot></tfoot>
</table>
</div>
</section>
<section class="card">
<h2>充值记录</h2>
<p class="desc">余额按到账金额计算</p>
<div class="table-wrap">
<table id="recharges">
<thead><tr><th>日期</th><th class="num">打款金额</th><th class="num">到账金额</th><th class="num">手续费</th></tr></thead>
<tbody></tbody><tfoot></tfoot>
</table>
</div>
</section>
<footer id="foot"></footer>
</main>
<script>
const $ = s => document.querySelector(s);
const NS = 'http://www.w3.org/2000/svg';
const el = (tag, attrs, parent) => { const e = document.createElementNS(NS, tag); for (const k in attrs) e.setAttribute(k, attrs[k]); if (parent) parent.appendChild(e); return e; };
const h = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
const r2 = v => Math.round(v * 100) / 100;
const usd = (v, dp = 2) => (v < 0 ? '−$' : '$') + Math.abs(v).toLocaleString('en-US', { minimumFractionDigits: dp, maximumFractionDigits: dp });
const int = v => Number(v || 0).toLocaleString('en-US');
const costText = (spend, ftd) => ftd ? usd(spend / ftd) : '—';
const shortDate = dt => dt.slice(5).replace('-', '/');
const addDays = (s, n) => { const t = new Date(s + 'T00:00:00Z'); t.setUTCDate(t.getUTCDate() + n); return t.toISOString().slice(0, 10); };
const dayRange = (a, b) => { const out = []; for (let s = a; s <= b; s = addDays(s, 1)) out.push(s); return out; };
const SLOTS = 5; // 颜色槽位数,排不进的包合并成「其他」
function niceStep(raw) {
const p = 10 ** Math.floor(Math.log10(raw)), n = raw / p;
return (n <= 1 ? 1 : n <= 2 ? 2 : n <= 2.5 ? 2.5 : n <= 5 ? 5 : 10) * p;
}
function scaleY(min, max, count = 4) {
if (max === min) max = min + 1;
const step = niceStep((max - min) / count);
const lo = Math.floor(min / step) * step, hi = Math.ceil(max / step) * step;
const ticks = [];
for (let v = lo; v <= hi + step / 2; v += step) ticks.push(r2(v));
return { lo, hi, ticks, dp: step % 1 ? 1 : 0 };
}
const roundTop = (x, y, w, hgt, r) =>
`M${x},${y + hgt}V${y + r}Q${x},${y} ${x + r},${y}H${x + w - r}Q${x + w},${y} ${x + w},${y + r}V${y + hgt}Z`;
const labelStep = (n, pw) => Math.max(1, Math.ceil(n / Math.max(1, Math.floor(pw / 52))));
function showTip(wrap, x, y, content) {
const t = wrap.querySelector('.tip');
t.replaceChildren(content);
t.hidden = false;
const left = Math.min(Math.max(x - t.offsetWidth / 2, 4), wrap.clientWidth - t.offsetWidth - 4);
t.style.left = Math.max(0, left) + 'px';
t.style.top = Math.max(4, y - t.offsetHeight - 14) + 'px';
}
const hideTip = wrap => { wrap.querySelector('.tip').hidden = true; };
const swatch = color => { const s = h('i', 'sw'); s.style.background = color; return s; };
function badge(kind, icon, text) {
const b = h('span', 'badge ' + kind);
b.append(h('span', 'badge-icon', icon), document.createTextNode(text));
return b;
}
function tr(cells) {
const row = h('tr');
cells.forEach(c => {
const td = h('td', c.cls || '');
if (c.color) td.append(swatch(c.color));
td.append(document.createTextNode(c.text));
row.append(td);
});
return row;
}
const sumRows = rows => rows.reduce((a, r) => ({ installs: a.installs + r.installs, reg: a.reg + r.reg, ftd: a.ftd + r.ftd, spend: r2(a.spend + r.spend) }), { installs: 0, reg: 0, ftd: 0, spend: 0 });
const metricCells = t => [
{ text: int(t.installs), cls: 'num' }, { text: int(t.reg), cls: 'num' }, { text: int(t.ftd), cls: 'num' },
{ text: usd(t.spend), cls: 'num' }, { text: costText(t.spend, t.ftd), cls: 'num' },
];
function derive(d) {
const end = d.range.end;
const groups = {};
d.daily.forEach(r => (groups[r.pkg] ??= []).push(r));
const pkgs = Object.entries(groups).map(([pkg, rows]) => ({
pkg,
first: rows.reduce((m, r) => r.dt < m ? r.dt : m, rows[0].dt),
last: rows.reduce((m, r) => r.dt > m ? r.dt : m, rows[0].dt),
...sumRows(rows),
})).sort((a, b) => b.spend - a.spend || a.pkg.localeCompare(b.pkg));
// 颜色跟着包走:按全部历史消耗排名分配,切换时间范围不重新上色
const series = pkgs.slice(0, SLOTS).map((p, i) => ({ label: p.pkg, color: `var(--s${i + 1})`, members: [p.pkg] }));
if (pkgs.length > SLOTS) series.push({ label: `其他 ${pkgs.length - SLOTS} 个包`, color: 'var(--other)', members: pkgs.slice(SLOTS).map(p => p.pkg) });
const seriesOf = {};
series.forEach((s, i) => s.members.forEach(p => { seriesOf[p] = i; }));
const colorOf = pkg => series[seriesOf[pkg]].color;
pkgs.forEach(p => { p.color = colorOf(p.pkg); });
const byDate = {};
d.daily.forEach(r => (byDate[r.dt] ??= []).push(r));
const rowsOn = dt => byDate[dt] || [];
const total = sumRows(d.daily);
const recharges = [...d.recharges].sort((a, b) => a.date.localeCompare(b.date));
const received = r2(recharges.reduce((s, x) => s + x.amount, 0));
const paid = r2(recharges.reduce((s, x) => s + x.paid, 0));
const w7 = sumRows(dayRange(addDays(end, -6), end).flatMap(rowsOn));
const bStart = [d.range.start, recharges[0] && recharges[0].date].filter(Boolean).sort()[0];
let cumSpend = 0, cumIn = 0;
const balanceSeries = dayRange(bStart, end).map(dt => {
const spend = r2(rowsOn(dt).reduce((s, r) => s + r.spend, 0));
const inflow = r2(recharges.filter(x => x.date === dt).reduce((s, x) => s + x.amount, 0));
cumSpend += spend;
cumIn += inflow;
return { dt, v: r2(cumIn - cumSpend), spend, inflow };
});
return { end, pkgs, series, seriesOf, colorOf, rowsOn, total, recharges, received, paid, balance: r2(received - total.spend), w7, avg7: w7.spend / 7, balanceSeries };
}
function renderTiles(S, d) {
const box = $('#tiles');
box.replaceChildren();
const add = (label, value, sub) => {
const t = h('div', 'tile');
t.append(h('div', 'tile-label', label), h('div', 'tile-value', value));
if (sub) { const s = h('div', 'tile-sub'); s.append(sub); t.append(s); }
box.append(t);
};
const days = S.avg7 > 0 ? S.balance / S.avg7 : Infinity;
let sub;
if (S.balance <= 0) sub = badge('crit', '✕', '余额已用完');
else if (days < 7) sub = badge('warn', '!', `按近 7 天日均约可用 ${Math.floor(days)} 天`);
else sub = isFinite(days) ? `按近 7 天日均约可用 ${Math.floor(days)} 天` : '近 7 天没有消耗';
add('当前余额', usd(S.balance), sub);
add('近 7 天消耗', usd(S.w7.spend), `${shortDate(addDays(S.end, -6))}–${shortDate(S.end)},日均 ${usd(S.avg7)}`);
add('近 7 天首充', int(S.w7.ftd), `成本 ${costText(S.w7.spend, S.w7.ftd)} · 注册 ${int(S.w7.reg)}`);
add('累计消耗', usd(S.total.spend), `${d.range.start} 起 · 成本 ${costText(S.total.spend, S.total.ftd)}`);
add('累计到账', usd(S.received), `打款 ${usd(S.paid, 0)} · 共 ${S.recharges.length} 笔`);
}
let RANGE = '7';
function drawSpend(S, d) {
const wrap = $('#spend-chart');
wrap.querySelector('svg')?.remove();
hideTip(wrap);
const start = RANGE === 'all' ? d.range.start : addDays(S.end, -(Number(RANGE) - 1));
const dates = dayRange(start < d.range.start ? d.range.start : start, S.end);
const stacks = dates.map(dt => {
const acc = S.series.map(() => 0);
S.rowsOn(dt).forEach(r => { acc[S.seriesOf[r.pkg]] += r.spend; });
return acc.map(r2);
});
const totals = stacks.map(s => r2(s.reduce((a, b) => a + b, 0)));
$('#spend-legend').replaceChildren(...S.series
.filter((s, i) => stacks.some(st => st[i] > 0))
.map(s => {
const e = h('span');
e.append(swatch(s.color), document.createTextNode(s.label));
if (s.members.length > 1) e.title = s.members.join('\n');
return e;
}));
const W = Math.max(300, wrap.clientWidth), H = 260, m = { l: 56, r: 14, t: 24, b: 30 };
const pw = W - m.l - m.r, ph = H - m.t - m.b;
const sc = scaleY(0, Math.max(...totals, 1));
const y = v => m.t + ph - (v - sc.lo) / (sc.hi - sc.lo) * ph;
const n = dates.length, band = pw / n, bw = Math.max(2, Math.min(24, band * 0.6));
const clampX = x => Math.min(Math.max(x, m.l + 18), W - m.r - 18);
const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, width: W, height: H, role: 'img', 'aria-label': '每日消耗,按包堆叠' });
wrap.prepend(svg);
const hover = el('rect', { class: 'hover-band', y: m.t, height: ph, width: band, visibility: 'hidden' }, svg);
sc.ticks.forEach(t => {
el('line', { x1: m.l, x2: W - m.r, y1: y(t), y2: y(t), class: t === 0 ? 'axis' : 'grid' }, svg);
el('text', { x: m.l - 8, y: y(t) + 4, 'text-anchor': 'end', class: 'tick' }, svg).textContent = usd(t, sc.dp);
});
const step = labelStep(n, pw);
const maxI = totals.indexOf(Math.max(...totals)), lastI = n - 1;
dates.forEach((dt, i) => {
const cx = m.l + band * (i + 0.5);
const segs = stacks[i].map((v, s) => ({ v, color: S.series[s].color })).filter(s => s.v > 0);
let base = 0;
segs.forEach((sg, j) => {
const y0 = y(base), y1 = y(base + sg.v), gap = j > 0 ? 2 : 0;
const hgt = y0 - y1 - gap;
base += sg.v;
if (hgt <= 0) return;
const rr = j === segs.length - 1 ? Math.min(4, hgt, bw / 2) : 0;
el('path', { d: roundTop(cx - bw / 2, y1, bw, hgt, rr), fill: sg.color }, svg);
});
if ((lastI - i) % step === 0) {
el('text', { x: clampX(cx), y: H - m.b + 18, 'text-anchor': 'middle', class: 'tick' }, svg).textContent = shortDate(dt);
}
if (totals[i] > 0 && (i === lastI || (i === maxI && lastI - maxI >= 3 * step))) {
el('text', { x: clampX(cx), y: y(totals[i]) - 7, 'text-anchor': 'middle', class: 'val' }, svg).textContent = usd(totals[i]);
}
});
dates.forEach((dt, i) => {
const cx = m.l + band * (i + 0.5);
const hit = el('rect', { x: cx - band / 2, y: m.t, width: band, height: ph, fill: 'transparent' }, svg);
hit.addEventListener('pointerenter', () => {
hover.setAttribute('x', cx - band / 2);
hover.setAttribute('visibility', 'visible');
showTip(wrap, cx, y(totals[i]), spendTip(S, dt, totals[i]));
});
hit.addEventListener('pointerleave', () => { hover.setAttribute('visibility', 'hidden'); hideTip(wrap); });
});
}
function spendTip(S, dt, total) {
const f = document.createDocumentFragment();
f.append(h('div', 'tip-title', dt));
const rows = S.rowsOn(dt).filter(r => r.spend > 0 || r.ftd > 0 || r.reg > 0).sort((a, b) => b.spend - a.spend);
if (!rows.length) f.append(h('div', 'tip-sub', '当天没有消耗'));
rows.forEach(r => {
const row = h('div', 'tip-row');
row.append(swatch(S.colorOf(r.pkg)), h('span', 'tip-name', r.pkg), h('span', 'tip-val', usd(r.spend)));
f.append(row, h('div', 'tip-sub', `安装 ${int(r.installs)} · 注册 ${int(r.reg)} · 首充 ${int(r.ftd)}`));
});
if (rows.length > 1) {
const tot = h('div', 'tip-row tip-total');
tot.append(h('span', 'tip-name', '合计'), h('span', 'tip-val', usd(total)));
f.append(tot);
}
return f;
}
function drawBalance(S) {
const wrap = $('#balance-chart');
wrap.querySelector('svg')?.remove();
hideTip(wrap);
const pts = S.balanceSeries, n = pts.length, li = n - 1;
if (!n) return;
const W = Math.max(300, wrap.clientWidth), H = 240, m = { l: 64, r: 16, t: 28, b: 30 };
const pw = W - m.l - m.r, ph = H - m.t - m.b;
const vals = pts.map(p => p.v);
const sc = scaleY(Math.min(0, ...vals), Math.max(0, ...vals));
const y = v => m.t + ph - (v - sc.lo) / (sc.hi - sc.lo) * ph;
const stepX = n > 1 ? pw / (n - 1) : 0;
const x = i => n > 1 ? m.l + i * stepX : m.l + pw / 2;
const clampX = v => Math.min(Math.max(v, m.l + 18), W - m.r - 18);
const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, width: W, height: H, role: 'img', 'aria-label': '每日日终余额走势' });
wrap.prepend(svg);
sc.ticks.forEach(t => {
el('line', { x1: m.l, x2: W - m.r, y1: y(t), y2: y(t), class: t === 0 ? 'axis' : 'grid' }, svg);
el('text', { x: m.l - 8, y: y(t) + 4, 'text-anchor': 'end', class: 'tick' }, svg).textContent = usd(t, sc.dp);
});
const step = labelStep(n, pw);
pts.forEach((p, i) => {
if ((li - i) % step === 0) el('text', { x: clampX(x(i)), y: H - m.b + 18, 'text-anchor': 'middle', class: 'tick' }, svg).textContent = shortDate(p.dt);
});
const cross = el('line', { y1: m.t, y2: m.t + ph, class: 'cross', visibility: 'hidden' }, svg);
el('path', { d: 'M' + pts.map((p, i) => `${x(i)},${y(p.v)}`).join('L'), class: 'line', stroke: 'var(--s1)' }, svg);
pts.forEach((p, i) => { if (p.inflow) el('circle', { cx: x(i), cy: y(p.v), r: 4, class: 'dot', fill: 'var(--s1)' }, svg); });
el('circle', { cx: x(li), cy: y(pts[li].v), r: 4, class: 'dot', fill: 'var(--s1)' }, svg);
el('text', { x: x(li), y: y(pts[li].v) - 12, 'text-anchor': 'end', class: 'val' }, svg).textContent = usd(pts[li].v);
const hov = el('circle', { r: 5, class: 'dot', fill: 'var(--s1)', visibility: 'hidden' }, svg);
const hit = el('rect', { x: m.l - 12, y: m.t, width: pw + 24, height: ph, fill: 'transparent' }, svg);
hit.addEventListener('pointermove', e => {
const b = svg.getBoundingClientRect();
const px = (e.clientX - b.left) * (W / b.width);
const i = stepX ? Math.max(0, Math.min(li, Math.round((px - m.l) / stepX))) : 0;
const p = pts[i];
cross.setAttribute('x1', x(i)); cross.setAttribute('x2', x(i)); cross.setAttribute('visibility', 'visible');
hov.setAttribute('cx', x(i)); hov.setAttribute('cy', y(p.v)); hov.setAttribute('visibility', 'visible');
const f = document.createDocumentFragment();
f.append(h('div', 'tip-title', p.dt));
const row = (name, val) => { const r = h('div', 'tip-row'); r.append(h('span', 'tip-name', name), h('span', 'tip-val', val)); f.append(r); };
row('日终余额', usd(p.v));
row('当天消耗', usd(p.spend));
if (p.inflow) row('当天到账', usd(p.inflow));
showTip(wrap, x(i), y(p.v), f);
});
hit.addEventListener('pointerleave', () => { cross.setAttribute('visibility', 'hidden'); hov.setAttribute('visibility', 'hidden'); hideTip(wrap); });
}
function renderTables(S, d) {
$('#pkgs tbody').replaceChildren(...S.pkgs.map(p => tr([{ text: p.pkg, color: p.color }, { text: `${p.first} ~ ${p.last}` }, ...metricCells(p)])));
$('#pkgs tfoot').replaceChildren(tr([{ text: '合计' }, { text: `${d.range.start} ~ ${d.range.end}` }, ...metricCells(S.total)]));
const months = {};
d.daily.forEach(r => { ((months[r.dt.slice(0, 7)] ??= {})[r.pkg] ??= []).push(r); });
const mbody = $('#months tbody');
mbody.replaceChildren();
Object.keys(months).sort().reverse().forEach(mo => {
const sub = tr([{ text: mo }, { text: '当月合计' }, ...metricCells(sumRows(Object.values(months[mo]).flat()))]);
sub.className = 'sub';
mbody.append(sub);
Object.entries(months[mo])
.map(([pkg, rows]) => ({ pkg, ...sumRows(rows) }))
.filter(e => e.installs || e.reg || e.ftd || e.spend)
.sort((a, b) => b.spend - a.spend)
.forEach(e => mbody.append(tr([{ text: '' }, { text: e.pkg, color: S.colorOf(e.pkg) }, ...metricCells(e)])));
});
const sel = $('#pkg-filter');
sel.replaceChildren(new Option('全部包', ''), ...S.pkgs.map(p => new Option(p.pkg, p.pkg)));
sel.onchange = () => renderDaily(S, d);
renderDaily(S, d);
$('#recharges tbody').replaceChildren(...S.recharges.map(x => tr([
{ text: x.date }, { text: usd(x.paid), cls: 'num' }, { text: usd(x.amount), cls: 'num' }, { text: usd(r2(x.paid - x.amount)), cls: 'num' },
])));
$('#recharges tfoot').replaceChildren(tr([
{ text: '合计' }, { text: usd(S.paid), cls: 'num' }, { text: usd(S.received), cls: 'num' }, { text: usd(r2(S.paid - S.received)), cls: 'num' },
]));
}
function renderDaily(S, d) {
const f = $('#pkg-filter').value;
const rows = d.daily.filter(r => !f || r.pkg === f).sort((a, b) => b.dt.localeCompare(a.dt) || b.spend - a.spend);
$('#daily tbody').replaceChildren(...rows.map(r => tr([{ text: r.dt }, { text: r.pkg, color: S.colorOf(r.pkg) }, ...metricCells(r)])));
$('#daily tfoot').replaceChildren(tr([{ text: '合计' }, { text: f || '全部包' }, ...metricCells(sumRows(rows))]));
}
let S, DATA;
const drawCharts = () => { if (S) { drawSpend(S, DATA); drawBalance(S); } };
$('#range').addEventListener('click', e => {
const b = e.target.closest('button');
if (!b) return;
RANGE = b.dataset.range;
[...$('#range').children].forEach(x => x.setAttribute('aria-pressed', String(x === b)));
if (S) drawSpend(S, DATA);
});
fetch('data.json', { cache: 'no-store' })
.then(r => { if (!r.ok) throw new Error('data.json ' + r.status); return r.json(); })
.then(d => {
DATA = d;
S = derive(d);
const updated = new Date(d.updated_at).toLocaleString('zh-CN', { hour12: false });
$('#meta').replaceChildren(
h('span', '', `全账户 ${S.pkgs.length} 个包`),
h('span', '', `数据区间 ${d.range.start} ~ ${d.range.end}`),
h('span', '', `表格更新于 ${updated}`),
);
renderTiles(S, d);
renderTables(S, d);
drawCharts();
$('#foot').textContent = `数据来源:${d.source}。消耗按表格口径,成本 = 消耗 ÷ 首充,余额按到账金额计算。每天 11:00 检查表格,有更新就自动刷新。`;
})
.catch(e => {
$('#meta').textContent = '';
const err = $('#err');
err.hidden = false;
err.textContent = '数据加载失败:' + e.message;
});
let resizeTimer, lastWidth = 0;
function onResize() {
const w = document.querySelector('main').clientWidth;
if (w === lastWidth) return;
lastWidth = w;
clearTimeout(resizeTimer);
resizeTimer = setTimeout(drawCharts, 120);
}
new ResizeObserver(onResize).observe(document.querySelector('main'));
addEventListener('resize', onResize);
</script>
</body>
</html>