Files
tecdo-spend-amount/index.html
T
2026-09-15 11:30:20 +08:00

609 lines
30 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;
--good: #006300; --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;
--good: #0ca30c;
}
}
: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;
--good: #0ca30c;
}
* { 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; }
td.muted { color: var(--muted); }
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; }
.badge.good .badge-icon { background: var(--good); 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" id="spend-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="legend" id="installs-legend"></div>
<div class="chart" id="installs-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>MIDAS campaign</h2>
<p class="desc" id="camps-desc">账户 C00000196_TECDO HONG KONG LIMITED,DSP投放页状态为「投放中」的 campaign</p>
<div class="table-wrap">
<table id="camps">
<thead><tr><th>广告活动</th><th>ID</th><th>推广包</th><th>状态</th><th class="num">安装</th><th class="num">消耗</th></tr></thead>
<tbody></tbody>
</table>
</div>
</section>
<section class="card">
<h2>分包汇总</h2>
<p class="desc">全部历史,按消耗从高到低;成本 = 消耗 ÷ 首充。MIDAS 没有注册和首充,带 * 的数只统计表格部分</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">按日期倒序;MIDAS 的天没有注册和首充</p>
</div>
<select id="pkg-filter" aria-label="按包筛选"></select>
</div>
<div class="table-wrap">
<table id="daily">
<thead><tr><th>日期</th><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 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));
if (c.node) td.append(c.node); else td.append(document.createTextNode(c.text));
row.append(td);
});
return row;
}
// 注册、首充只有表格部分有(MIDAS 为 null):成本只用有首充数据的那部分消耗来算
const sumRows = rows => rows.reduce((a, r) => {
a.installs += r.installs;
a.spend = r2(a.spend + r.spend);
if (r.ftd == null) a.unknown++;
else { a.known++; a.reg += r.reg || 0; a.ftd += r.ftd; a.costSpend = r2(a.costSpend + r.spend); }
return a;
}, { installs: 0, reg: 0, ftd: 0, spend: 0, costSpend: 0, known: 0, unknown: 0 });
const metricCells = t => {
const star = t.known && t.unknown ? '*' : '';
return [
{ text: int(t.installs), cls: 'num' },
{ text: t.known ? int(t.reg) + star : '—', cls: 'num' + (t.known ? '' : ' muted') },
{ text: t.known ? int(t.ftd) + star : '—', cls: 'num' + (t.known ? '' : ' muted') },
{ text: usd(t.spend), cls: 'num' },
{ text: t.known && t.ftd ? usd(t.costSpend / t.ftd) + star : '—', cls: 'num' + (t.known && t.ftd ? '' : ' muted') },
];
};
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]] ? series[seriesOf[pkg]].color : 'var(--other)';
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 bEnd = [end, recharges.length ? recharges[recharges.length - 1].date : end].sort().pop();
const balanceSeries = dayRange(bStart, bEnd).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.installs), S.w7.installs ? `eCPI ${usd(S.w7.spend / S.w7.installs)}` : '暂无安装');
add('累计消耗', usd(S.total.spend), `${d.range.start} 起 · 首充成本 ${S.total.ftd ? usd(S.total.costSpend / S.total.ftd) : '—'}(表格部分)`);
add('累计到账', usd(S.received), `打款 ${usd(S.paid, 0)} · 共 ${S.recharges.length} 笔`);
}
let RANGE = '7';
function drawStacked(S, d, opts) {
const wrap = $(opts.wrapSel);
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]] += opts.valueOf(r); });
return acc.map(r2);
});
const totals = stacks.map(s => r2(s.reduce((a, b) => a + b, 0)));
$(opts.legendSel).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: 28, 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': opts.ariaLabel });
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 = opts.fmt(t, sc.dp);
});
// 表格数据和 MIDAS 数据的分界线
const k = dates.indexOf(d.midas_from);
if (k > 0) {
const xk = m.l + band * k;
el('line', { x1: xk, x2: xk, y1: m.t - 6, y2: m.t + ph, class: 'axis' }, svg);
el('text', { x: xk + 4, y: m.t - 10, 'text-anchor': 'start', class: 'tick' }, svg).textContent = 'MIDAS →';
el('text', { x: xk - 4, y: m.t - 10, 'text-anchor': 'end', class: 'tick' }, svg).textContent = '← 表格';
}
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 = opts.fmt(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]), opts.tip(S, dt, totals[i]));
});
hit.addEventListener('pointerleave', () => { hover.setAttribute('visibility', 'hidden'); hideTip(wrap); });
});
}
function drawSpend(S, d) {
drawStacked(S, d, {
wrapSel: '#spend-chart', legendSel: '#spend-legend', valueOf: r => r.spend,
fmt: usd, ariaLabel: '每日消耗,按包堆叠', tip: spendTip,
});
}
function drawInstalls(S, d) {
drawStacked(S, d, {
wrapSel: '#installs-chart', legendSel: '#installs-legend', valueOf: r => r.installs,
fmt: v => int(v), ariaLabel: '每日安装,按包堆叠', tip: installsTip,
});
}
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)));
const detail = r.ftd == null ? `安装 ${int(r.installs)} · 来自 MIDAS` : `安装 ${int(r.installs)} · 注册 ${int(r.reg)} · 首充 ${int(r.ftd)}`;
f.append(row, h('div', 'tip-sub', detail));
});
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 installsTip(S, dt, total) {
const f = document.createDocumentFragment();
f.append(h('div', 'tip-title', dt));
const rows = S.rowsOn(dt).filter(r => r.installs > 0).sort((a, b) => b.installs - a.installs);
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', int(r.installs)));
f.append(row);
});
if (rows.length > 1) {
const tot = h('div', 'tip-row tip-total');
tot.append(h('span', 'tip-name', '合计'), h('span', 'tip-val', int(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) {
const camps = d.campaigns || [];
$('#camps-desc').textContent = `账户 C00000196_TECDO HONG KONG LIMITED,${shortDate(d.midas_from)} 起的数据;列出状态为「投放中」的 campaign,以及期间内有过消耗、现在已停的`;
$('#camps tbody').replaceChildren(...(camps.length ? camps.map(c => tr([
{ text: c.name }, { text: c.id }, { text: c.pkg || c.app || '—', color: c.pkg ? S.colorOf(c.pkg) : null },
{ node: c.active ? badge('good', '✓', '投放中') : document.createTextNode('已停') },
{ text: int(c.installs), cls: 'num' }, { text: usd(c.spend), cls: 'num' },
])) : [tr([{ text: `还没有 ${d.midas_from} 起的 MIDAS 数据` }, { text: '' }, { text: '' }, { text: '' }, { text: '' }, { text: '' }])]));
$('#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');
const current = sel.value;
sel.replaceChildren(new Option('全部包', ''), ...S.pkgs.map(p => new Option(p.pkg, p.pkg)));
sel.value = current;
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) }, { text: r.src === 'midas' ? 'MIDAS' : '表格' }, ...metricCells(sumRows([r])),
])));
$('#daily tfoot').replaceChildren(tr([{ text: '合计' }, { text: f || '全部包' }, { text: '' }, ...metricCells(sumRows(rows))]));
}
let S, DATA;
const drawCharts = () => { if (S) { drawSpend(S, DATA); drawInstalls(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); drawInstalls(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', '', `${shortDate(d.sheet_until)} 及以前来自表格,${shortDate(d.midas_from)} 起来自 MIDAS`),
h('span', '', `更新于 ${updated}`),
);
$('#spend-desc').textContent = `按包堆叠,悬停查看当天每个包的数据;竖线右边(${shortDate(d.midas_from)} 起)来自 MIDAS`;
renderTiles(S, d);
renderTables(S, d);
drawCharts();
$('#foot').textContent = `数据来源:${d.sheet_until} 及以前是 tecdo_数据.xlsx(计费口径);${d.midas_from} 起是 MIDAS 账户 C00000196_TECDO HONG KONG LIMITED 的 campaign 消耗(UTC+0,截至前一天),MIDAS 没有注册和首充。充值来自 钛动预付款.xlsx,余额按到账金额计算。每天 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>