改为全账户 9 个包:数据来自 tecdo_数据.xlsx 和 钛动预付款.xlsx,余额按到账金额

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
FANGKK
2026-09-10 17:41:53 +08:00
co-authored by Claude Opus 5
parent 2792c7fc6b
commit f07c872611
4 changed files with 1842 additions and 210 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
push.sh push.sh
fetch_midas.js build_data.py
.DS_Store .DS_Store
+1581 -34
View File
File diff suppressed because it is too large Load Diff
+239 -146
View File
@@ -10,21 +10,22 @@
color-scheme: light; color-scheme: light;
--page: #f9f9f7; --surface: #fcfcfb; --ink: #0b0b0b; --ink-2: #52514e; --muted: #898781; --page: #f9f9f7; --surface: #fcfcfb; --ink: #0b0b0b; --ink-2: #52514e; --muted: #898781;
--grid: #e1e0d9; --axis: #c3c2b7; --border: rgba(11,11,11,.10); --grid: #e1e0d9; --axis: #c3c2b7; --border: rgba(11,11,11,.10);
--s1: #2a78d6; --s2: #eb6834; --warn: #fab219; --crit: #d03b3b; --s1: #2a78d6; --s2: #eb6834; --s3: #1baf7a; --s4: #eda100; --s5: #e87ba4; --other: #a3a29b;
--warn: #fab219; --crit: #d03b3b;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root:where(:not([data-theme="light"])) { :root:where(:not([data-theme="light"])) {
color-scheme: dark; color-scheme: dark;
--page: #0d0d0d; --surface: #1a1a19; --ink: #ffffff; --ink-2: #c3c2b7; --muted: #898781; --page: #0d0d0d; --surface: #1a1a19; --ink: #ffffff; --ink-2: #c3c2b7; --muted: #898781;
--grid: #2c2c2a; --axis: #383835; --border: rgba(255,255,255,.10); --grid: #2c2c2a; --axis: #383835; --border: rgba(255,255,255,.10);
--s1: #3987e5; --s2: #d95926; --s1: #3987e5; --s2: #d95926; --s3: #199e70; --s4: #c98500; --s5: #d55181; --other: #6b6a65;
} }
} }
:root[data-theme="dark"] { :root[data-theme="dark"] {
color-scheme: dark; color-scheme: dark;
--page: #0d0d0d; --surface: #1a1a19; --ink: #ffffff; --ink-2: #c3c2b7; --muted: #898781; --page: #0d0d0d; --surface: #1a1a19; --ink: #ffffff; --ink-2: #c3c2b7; --muted: #898781;
--grid: #2c2c2a; --axis: #383835; --border: rgba(255,255,255,.10); --grid: #2c2c2a; --axis: #383835; --border: rgba(255,255,255,.10);
--s1: #3987e5; --s2: #d95926; --s1: #3987e5; --s2: #d95926; --s3: #199e70; --s4: #c98500; --s5: #d55181; --other: #6b6a65;
} }
* { box-sizing: border-box; } * { 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; } 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; }
@@ -39,7 +40,13 @@ h1 { font-size: 22px; font-weight: 600; margin: 0 0 4px; }
.tile-sub { color: var(--muted); font-size: 12px; margin-top: 4px; } .tile-sub { color: var(--muted); font-size: 12px; margin-top: 4px; }
.card { padding: 16px 18px; margin-bottom: 16px; } .card { padding: 16px 18px; margin-bottom: 16px; }
.card h2 { font-size: 15px; font-weight: 600; margin: 0; } .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; } .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; } .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; } .sw { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 6px; vertical-align: -1px; flex: none; }
.chart { position: relative; } .chart { position: relative; }
@@ -52,20 +59,19 @@ h1 { font-size: 22px; font-weight: 600; margin: 0 0 4px; }
.line { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; } .line { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
.dot { stroke: var(--surface); stroke-width: 2; } .dot { stroke: var(--surface); stroke-width: 2; }
.cross { stroke: var(--axis); stroke-width: 1; } .cross { stroke: var(--axis); stroke-width: 1; }
.tip { position: absolute; z-index: 2; pointer-events: none; min-width: 170px; 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 { 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-title { font-weight: 600; margin-bottom: 4px; }
.tip-row { display: flex; align-items: center; gap: 6px; } .tip-row { display: flex; align-items: center; gap: 6px; }
.tip-name { flex: 1; color: var(--ink-2); } .tip-name { flex: 1; color: var(--ink-2); overflow-wrap: anywhere; }
.tip-val { font-weight: 600; font-variant-numeric: tabular-nums; } .tip-val { font-weight: 600; font-variant-numeric: tabular-nums; }
.tip-sub { color: var(--muted); padding-left: 16px; margin-bottom: 2px; } .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; } .tip-total { border-top: 1px solid var(--grid); margin-top: 4px; padding-top: 4px; }
.empty { color: var(--ink-2); border: 1px dashed var(--axis); border-radius: 8px; padding: 18px; font-size: 13px; }
.table-wrap { overflow-x: auto; } .table-wrap { overflow-x: auto; }
table { border-collapse: collapse; width: 100%; font-size: 13px; white-space: nowrap; } 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); } 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); } td { padding: 7px 10px; border-bottom: 1px solid var(--grid); }
th.num, td.num { text-align: right; font-variant-numeric: tabular-nums; } 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; } tfoot td { font-weight: 600; border-bottom: none; }
.badge { display: inline-flex; align-items: center; gap: 5px; color: var(--ink); } .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-icon { display: inline-grid; place-items: center; width: 15px; height: 15px; border-radius: 50%; font-size: 10px; font-weight: 700; color: #0b0b0b; }
@@ -78,7 +84,7 @@ footer { color: var(--muted); font-size: 12px; margin-top: 8px; }
<body> <body>
<main> <main>
<header> <header>
<h1>Tecdo 客户消耗与余额</h1> <h1>Tecdo(钛动)消耗与余额</h1>
<div class="meta" id="meta">加载中…</div> <div class="meta" id="meta">加载中…</div>
</header> </header>
<div id="err" hidden></div> <div id="err" hidden></div>
@@ -86,45 +92,71 @@ footer { color: var(--muted); font-size: 12px; margin-top: 8px; }
<section class="tiles" id="tiles"></section> <section class="tiles" id="tiles"></section>
<section class="card"> <section class="card">
<h2>近 7 天每日消耗</h2> <div class="card-head">
<p class="desc">两个订单按天堆叠,悬停查看每个订单的消耗、安装和点击</p> <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="legend" id="spend-legend"></div>
<div class="chart" id="spend-chart"><div class="tip" hidden></div></div> <div class="chart" id="spend-chart"><div class="tip" hidden></div></div>
</section> </section>
<section class="card"> <section class="card">
<h2>余额走势</h2> <h2>余额走势</h2>
<p class="desc">每天日终余额 = 截至当天的累计充值 − 累计消耗</p> <p class="desc">每天日终余额 = 截至当天的累计到账 − 累计消耗,圆点是到账日</p>
<div class="empty" id="balance-empty" hidden>还没有充值记录。把充值日期和金额发给 Claude,或者编辑仓库里的 manual.json,余额和走势会自动算出来。</div>
<div class="chart" id="balance-chart"><div class="tip" hidden></div></div> <div class="chart" id="balance-chart"><div class="tip" hidden></div></div>
</section> </section>
<section class="card"> <section class="card">
<h2>每日明细</h2> <h2>分包汇总</h2>
<p class="desc">按日期倒序,每个订单一行</p> <p class="desc">全部历史,按消耗从高到低;成本 = 消耗 ÷ 首充</p>
<div class="table-wrap"> <div class="table-wrap">
<table id="daily"> <table id="pkgs">
<thead><tr><th>日期</th><th>订单</th><th>App</th><th>pkg name</th><th class="num">消耗</th><th class="num">安装</th><th class="num">点击</th><th class="num">eCPI</th></tr></thead> <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> <tbody></tbody><tfoot></tfoot>
</table> </table>
</div> </div>
</section> </section>
<section class="card"> <section class="card">
<h2>订单信息</h2> <h2>分月汇总</h2>
<p class="desc">每月先列合计,再列当月有数据的包</p>
<div class="table-wrap"> <div class="table-wrap">
<table id="camps"> <table id="months">
<thead><tr><th>订单</th><th>订单名</th><th>App</th><th>pkg name</th><th>开始日期</th><th class="num">出价</th><th class="num">日预算</th><th class="num">累计消耗</th></tr></thead> <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> <tbody></tbody>
</table> </table>
</div> </div>
</section> </section>
<section class="card" id="recharge-card" hidden> <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> <h2>充值记录</h2>
<p class="desc">余额按到账金额计算</p>
<div class="table-wrap"> <div class="table-wrap">
<table id="recharges"> <table id="recharges">
<thead><tr><th>日期</th><th class="num">金额</th><th>备注</th></tr></thead> <thead><tr><th>日期</th><th class="num">打款金额</th><th class="num">到账金额</th><th class="num">手续费</th></tr></thead>
<tbody></tbody><tfoot></tfoot> <tbody></tbody><tfoot></tfoot>
</table> </table>
</div> </div>
@@ -141,7 +173,11 @@ const h = (tag, cls, text) => { const e = document.createElement(tag); if (cls)
const r2 = v => Math.round(v * 100) / 100; 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 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 int = v => Number(v || 0).toLocaleString('en-US');
const costText = (spend, ftd) => ftd ? usd(spend / ftd) : '—';
const shortDate = dt => dt.slice(5).replace('-', '/'); 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) { function niceStep(raw) {
const p = 10 ** Math.floor(Math.log10(raw)), n = raw / p; const p = 10 ** Math.floor(Math.log10(raw)), n = raw / p;
@@ -157,50 +193,80 @@ function scaleY(min, max, count = 4) {
} }
const roundTop = (x, y, w, hgt, r) => 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`; `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) { function showTip(wrap, x, y, content) {
const t = wrap.querySelector('.tip'); const t = wrap.querySelector('.tip');
t.replaceChildren(content); t.replaceChildren(content);
t.hidden = false; t.hidden = false;
const left = Math.min(Math.max(x - t.offsetWidth / 2, 4), wrap.clientWidth - t.offsetWidth - 4); const left = Math.min(Math.max(x - t.offsetWidth / 2, 4), wrap.clientWidth - t.offsetWidth - 4);
t.style.left = left + 'px'; t.style.left = Math.max(0, left) + 'px';
t.style.top = Math.max(4, y - t.offsetHeight - 14) + 'px'; t.style.top = Math.max(4, y - t.offsetHeight - 14) + 'px';
} }
const hideTip = wrap => { wrap.querySelector('.tip').hidden = true; }; const hideTip = wrap => { wrap.querySelector('.tip').hidden = true; };
const swatch = color => { const s = h('i', 'sw'); s.style.background = color; return s; }; const swatch = color => { const s = h('i', 'sw'); s.style.background = color; return s; };
function derive(d, m) {
const pkgs = m.pkg_names || {};
const camps = d.campaigns.map((c, i) => ({ ...c, color: `var(--s${i + 1})`, pkg: pkgs[c.id] || '' }));
const dates = [...new Set(d.daily.map(r => r.dt))].sort();
const cell = {};
d.daily.forEach(r => { (cell[r.dt] ??= {})[r.campaign_id] = r; });
const get = (dt, id) => (cell[dt] || {})[id];
const dayTotal = dt => r2(camps.reduce((s, c) => s + (get(dt, c.id)?.spend || 0), 0));
const rangeSpend = r2(d.daily.reduce((s, r) => s + r.spend, 0));
const rangeInstalls = d.daily.reduce((s, r) => s + r.installs, 0);
const rangeClicks = d.daily.reduce((s, r) => s + r.clicks, 0);
const lifetime = r2(camps.reduce((s, c) => s + c.lifetime_spend, 0));
const recharges = (m.recharges || []).filter(x => x.date && Number(x.amount)).sort((a, b) => a.date.localeCompare(b.date));
const rechargeTotal = r2(recharges.reduce((s, x) => s + Number(x.amount), 0));
let cum = lifetime - rangeSpend;
const balanceSeries = dates.map(dt => {
cum += dayTotal(dt);
const rc = recharges.filter(x => x.date <= dt).reduce((s, x) => s + Number(x.amount), 0);
return { dt, v: r2(rc - cum), spend: dayTotal(dt), recharge: r2(recharges.filter(x => x.date === dt).reduce((s, x) => s + Number(x.amount), 0)) };
});
return {
camps, dates, get, dayTotal, rangeSpend, rangeInstalls, rangeClicks, lifetime, recharges, rechargeTotal,
hasRecharge: recharges.length > 0, balance: r2(rechargeTotal - lifetime), balanceSeries,
avgDaily: dates.length ? rangeSpend / dates.length : 0,
};
}
function badge(kind, icon, text) { function badge(kind, icon, text) {
const b = h('span', 'badge ' + kind); const b = h('span', 'badge ' + kind);
b.append(h('span', 'badge-icon', icon), document.createTextNode(text)); b.append(h('span', 'badge-icon', icon), document.createTextNode(text));
return b; 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) { function renderTiles(S, d) {
const box = $('#tiles'); const box = $('#tiles');
@@ -211,58 +277,77 @@ function renderTiles(S, d) {
if (sub) { const s = h('div', 'tile-sub'); s.append(sub); t.append(s); } if (sub) { const s = h('div', 'tile-sub'); s.append(sub); t.append(s); }
box.append(t); box.append(t);
}; };
const firstStart = S.camps.map(c => c.start).sort()[0]; const days = S.avg7 > 0 ? S.balance / S.avg7 : Infinity;
add('近 7 天消耗', usd(S.rangeSpend), `日均 ${usd(S.avgDaily)}`);
add('近 7 天安装', int(S.rangeInstalls), S.rangeInstalls ? `eCPI ${usd(S.rangeSpend / S.rangeInstalls)}` : '暂无安装');
add('累计消耗', usd(S.lifetime), `${firstStart} 起,截至 ${d.range.end}`);
add('累计充值', S.hasRecharge ? usd(S.rechargeTotal) : '待补', S.hasRecharge ? `共 ${S.recharges.length} 笔` : '还没有充值记录');
if (!S.hasRecharge) {
add('当前余额', '待补', '累计充值 − 累计消耗');
} else {
const days = S.avgDaily > 0 ? S.balance / S.avgDaily : Infinity;
let sub; let sub;
if (S.balance <= 0) sub = badge('crit', '✕', '余额已用完'); if (S.balance <= 0) sub = badge('crit', '✕', '余额已用完');
else if (days < 7) sub = badge('warn', '!', `按日均约可用 ${Math.floor(days)} 天`); else if (days < 7) sub = badge('warn', '!', `按近 7 天日均约可用 ${Math.floor(days)} 天`);
else sub = isFinite(days) ? `按日均约可用 ${Math.floor(days)} 天` : '近 7 天没有消耗'; else sub = isFinite(days) ? `按近 7 天日均约可用 ${Math.floor(days)} 天` : '近 7 天没有消耗';
add('当前余额', usd(S.balance), sub); 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} 笔`);
} }
function drawSpend(S) { let RANGE = '7';
function drawSpend(S, d) {
const wrap = $('#spend-chart'); const wrap = $('#spend-chart');
wrap.querySelector('svg')?.remove(); wrap.querySelector('svg')?.remove();
hideTip(wrap); hideTip(wrap);
const W = Math.max(300, wrap.clientWidth), H = 260, m = { l: 52, r: 12, t: 24, b: 30 }; 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 pw = W - m.l - m.r, ph = H - m.t - m.b;
const totals = S.dates.map(S.dayTotal);
const sc = scaleY(0, Math.max(...totals, 1)); const sc = scaleY(0, Math.max(...totals, 1));
const y = v => m.t + ph - (v - sc.lo) / (sc.hi - sc.lo) * ph; const y = v => m.t + ph - (v - sc.lo) / (sc.hi - sc.lo) * ph;
const band = pw / Math.max(1, S.dates.length), bw = Math.min(24, band * 0.55); const n = dates.length, band = pw / n, bw = Math.max(2, Math.min(24, band * 0.6));
const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, width: W, height: H, role: 'img', 'aria-label': '近 7 天每日消耗,按订单堆叠' }); 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); wrap.prepend(svg);
const hover = el('rect', { class: 'hover-band', y: m.t, height: ph, width: band, visibility: 'hidden' }, svg); const hover = el('rect', { class: 'hover-band', y: m.t, height: ph, width: band, visibility: 'hidden' }, svg);
sc.ticks.forEach(t => { 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('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); el('text', { x: m.l - 8, y: y(t) + 4, 'text-anchor': 'end', class: 'tick' }, svg).textContent = usd(t, sc.dp);
}); });
const maxI = totals.indexOf(Math.max(...totals)), lastI = totals.length - 1; const step = labelStep(n, pw);
S.dates.forEach((dt, i) => { const maxI = totals.indexOf(Math.max(...totals)), lastI = n - 1;
dates.forEach((dt, i) => {
const cx = m.l + band * (i + 0.5); const cx = m.l + band * (i + 0.5);
const segs = S.camps.map(c => ({ c, v: S.get(dt, c.id)?.spend || 0 })).filter(s => s.v > 0); const segs = stacks[i].map((v, s) => ({ v, color: S.series[s].color })).filter(s => s.v > 0);
let base = 0; let base = 0;
segs.forEach((s, j) => { segs.forEach((sg, j) => {
const y0 = y(base), y1 = y(base + s.v), gap = j > 0 ? 2 : 0; const y0 = y(base), y1 = y(base + sg.v), gap = j > 0 ? 2 : 0;
const hgt = Math.max(0, y0 - y1 - gap); const hgt = y0 - y1 - gap;
const r = j === segs.length - 1 ? Math.min(4, hgt) : 0; base += sg.v;
el('path', { d: roundTop(cx - bw / 2, y1, bw, hgt, r), fill: s.c.color }, svg); if (hgt <= 0) return;
base += s.v; 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);
}); });
el('text', { x: cx, y: H - m.b + 18, 'text-anchor': 'middle', class: 'tick' }, svg).textContent = shortDate(dt); if ((lastI - i) % step === 0) {
if ((i === maxI || i === lastI) && totals[i] > 0) { el('text', { x: clampX(cx), y: H - m.b + 18, 'text-anchor': 'middle', class: 'tick' }, svg).textContent = shortDate(dt);
el('text', { x: cx, y: y(totals[i]) - 7, 'text-anchor': 'middle', class: 'val' }, svg).textContent = usd(totals[i]); }
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]);
} }
}); });
S.dates.forEach((dt, i) => { dates.forEach((dt, i) => {
const cx = m.l + band * (i + 0.5); 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); const hit = el('rect', { x: cx - band / 2, y: m.t, width: band, height: ph, fill: 'transparent' }, svg);
hit.addEventListener('pointerenter', () => { hit.addEventListener('pointerenter', () => {
@@ -277,16 +362,18 @@ function drawSpend(S) {
function spendTip(S, dt, total) { function spendTip(S, dt, total) {
const f = document.createDocumentFragment(); const f = document.createDocumentFragment();
f.append(h('div', 'tip-title', dt)); f.append(h('div', 'tip-title', dt));
S.camps.forEach(c => { const rows = S.rowsOn(dt).filter(r => r.spend > 0 || r.ftd > 0 || r.reg > 0).sort((a, b) => b.spend - a.spend);
const r = S.get(dt, c.id); if (!rows.length) f.append(h('div', 'tip-sub', '当天没有消耗'));
rows.forEach(r => {
const row = h('div', 'tip-row'); const row = h('div', 'tip-row');
row.append(swatch(c.color), h('span', 'tip-name', c.app || c.id), h('span', 'tip-val', usd(r?.spend || 0))); row.append(swatch(S.colorOf(r.pkg)), h('span', 'tip-name', r.pkg), h('span', 'tip-val', usd(r.spend)));
f.append(row); f.append(row, h('div', 'tip-sub', `安装 ${int(r.installs)} · 注册 ${int(r.reg)} · 首充 ${int(r.ftd)}`));
if (r) f.append(h('div', 'tip-sub', `安装 ${int(r.installs)} · 点击 ${int(r.clicks)}`));
}); });
if (rows.length > 1) {
const tot = h('div', 'tip-row tip-total'); const tot = h('div', 'tip-row tip-total');
tot.append(h('span', 'tip-name', '合计'), h('span', 'tip-val', usd(total))); tot.append(h('span', 'tip-name', '合计'), h('span', 'tip-val', usd(total)));
f.append(tot); f.append(tot);
}
return f; return f;
} }
@@ -294,25 +381,29 @@ function drawBalance(S) {
const wrap = $('#balance-chart'); const wrap = $('#balance-chart');
wrap.querySelector('svg')?.remove(); wrap.querySelector('svg')?.remove();
hideTip(wrap); hideTip(wrap);
$('#balance-empty').hidden = S.hasRecharge; const pts = S.balanceSeries, n = pts.length, li = n - 1;
wrap.hidden = !S.hasRecharge; if (!n) return;
if (!S.hasRecharge) return; const W = Math.max(300, wrap.clientWidth), H = 240, m = { l: 64, r: 16, t: 28, b: 30 };
const W = Math.max(300, wrap.clientWidth), H = 220, m = { l: 64, r: 16, t: 28, b: 30 };
const pw = W - m.l - m.r, ph = H - m.t - m.b; const pw = W - m.l - m.r, ph = H - m.t - m.b;
const pts = S.balanceSeries, vals = pts.map(p => p.v), li = pts.length - 1; const vals = pts.map(p => p.v);
const sc = scaleY(Math.min(0, ...vals), Math.max(0, ...vals)); 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 y = v => m.t + ph - (v - sc.lo) / (sc.hi - sc.lo) * ph;
const stepX = pts.length > 1 ? pw / (pts.length - 1) : 0; const stepX = n > 1 ? pw / (n - 1) : 0;
const x = i => pts.length > 1 ? m.l + i * stepX : m.l + pw / 2; 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': '每日日终余额走势' }); const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, width: W, height: H, role: 'img', 'aria-label': '每日日终余额走势' });
wrap.prepend(svg); wrap.prepend(svg);
sc.ticks.forEach(t => { 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('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); el('text', { x: m.l - 8, y: y(t) + 4, 'text-anchor': 'end', class: 'tick' }, svg).textContent = usd(t, sc.dp);
}); });
pts.forEach((p, i) => { el('text', { x: x(i), y: H - m.b + 18, 'text-anchor': 'middle', class: 'tick' }, svg).textContent = shortDate(p.dt); }); 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); 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); 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('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); 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 hov = el('circle', { r: 5, class: 'dot', fill: 'var(--s1)', visibility: 'hidden' }, svg);
@@ -329,82 +420,84 @@ function drawBalance(S) {
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); }; 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.v));
row('当天消耗', usd(p.spend)); row('当天消耗', usd(p.spend));
if (p.recharge) row('当天充值', usd(p.recharge)); if (p.inflow) row('当天到账', usd(p.inflow));
showTip(wrap, x(i), y(p.v), f); showTip(wrap, x(i), y(p.v), f);
}); });
hit.addEventListener('pointerleave', () => { cross.setAttribute('visibility', 'hidden'); hov.setAttribute('visibility', 'hidden'); hideTip(wrap); }); hit.addEventListener('pointerleave', () => { cross.setAttribute('visibility', 'hidden'); hov.setAttribute('visibility', 'hidden'); hideTip(wrap); });
} }
function tr(cells) { function renderTables(S, d) {
const row = h('tr'); $('#pkgs tbody').replaceChildren(...S.pkgs.map(p => tr([{ text: p.pkg, color: p.color }, { text: `${p.first} ~ ${p.last}` }, ...metricCells(p)])));
cells.forEach(c => { $('#pkgs tfoot').replaceChildren(tr([{ text: '合计' }, { text: `${d.range.start} ~ ${d.range.end}` }, ...metricCells(S.total)]));
const td = h('td', c.cls || '');
if (c.color) td.append(swatch(c.color)); const months = {};
td.append(document.createTextNode(c.text)); d.daily.forEach(r => { ((months[r.dt.slice(0, 7)] ??= {})[r.pkg] ??= []).push(r); });
row.append(td); 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)])));
}); });
return row;
}
function renderTables(S) { const sel = $('#pkg-filter');
const pkgCell = c => c.pkg ? { text: c.pkg } : { text: '待补', cls: 'muted' }; sel.replaceChildren(new Option('全部包', ''), ...S.pkgs.map(p => new Option(p.pkg, p.pkg)));
const daily = $('#daily tbody'); sel.onchange = () => renderDaily(S, d);
daily.replaceChildren(); renderDaily(S, d);
[...S.dates].reverse().forEach(dt => S.camps.forEach(c => {
const r = S.get(dt, c.id);
if (!r) return;
daily.append(tr([
{ text: dt }, { text: c.id, color: c.color }, { text: c.app || '—' }, pkgCell(c),
{ text: usd(r.spend), cls: 'num' }, { text: int(r.installs), cls: 'num' }, { text: int(r.clicks), cls: 'num' },
{ text: r.installs ? usd(r.spend / r.installs) : '—', cls: 'num' },
]));
}));
$('#daily tfoot').replaceChildren(tr([
{ text: '合计' }, { text: '' }, { text: '' }, { text: '' },
{ text: usd(S.rangeSpend), cls: 'num' }, { text: int(S.rangeInstalls), cls: 'num' }, { text: int(S.rangeClicks), cls: 'num' },
{ text: S.rangeInstalls ? usd(S.rangeSpend / S.rangeInstalls) : '—', cls: 'num' },
]));
const camps = $('#camps tbody');
camps.replaceChildren(...S.camps.map(c => tr([
{ text: c.id, color: c.color }, { text: c.name }, { text: c.app || '—' }, pkgCell(c), { text: c.start },
{ text: usd(c.bid), cls: 'num' }, { text: usd(c.daily_budget, 0), cls: 'num' }, { text: usd(c.lifetime_spend), cls: 'num' },
])));
$('#recharge-card').hidden = !S.hasRecharge;
$('#recharges tbody').replaceChildren(...S.recharges.map(x => tr([ $('#recharges tbody').replaceChildren(...S.recharges.map(x => tr([
{ text: x.date }, { text: usd(Number(x.amount)), cls: 'num' }, { text: x.note || '' }, { 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.rechargeTotal), cls: 'num' }, { text: '' }])); $('#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' },
]));
} }
let S; function renderDaily(S, d) {
const drawCharts = () => { if (S) { drawSpend(S); drawBalance(S); } }; 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))]));
}
Promise.all([ let S, DATA;
fetch('data.json', { cache: 'no-store' }).then(r => { if (!r.ok) throw new Error('data.json ' + r.status); return r.json(); }), const drawCharts = () => { if (S) { drawSpend(S, DATA); drawBalance(S); } };
fetch('manual.json', { cache: 'no-store' }).then(r => r.ok ? r.json() : {}).catch(() => ({})),
]).then(([d, m]) => { $('#range').addEventListener('click', e => {
S = derive(d, m); 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 }); const updated = new Date(d.updated_at).toLocaleString('zh-CN', { hour12: false });
$('#meta').replaceChildren( $('#meta').replaceChildren(
h('span', '', d.account ? d.account.name : 'Tecdo'), h('span', '', `全账户 ${S.pkgs.length} 个包`),
h('span', '', `订单 ${S.camps.map(c => c.id).join('、')}`),
h('span', '', `数据区间 ${d.range.start} ~ ${d.range.end}`), h('span', '', `数据区间 ${d.range.start} ~ ${d.range.end}`),
h('span', '', `更新于 ${updated}`), h('span', '', `表格更新于 ${updated}`),
); );
const legend = $('#spend-legend');
legend.replaceChildren(...S.camps.map(c => { const s = h('span'); s.append(swatch(c.color), document.createTextNode(`${c.app || '订单'} · ${c.id}`)); return s; }));
renderTiles(S, d); renderTiles(S, d);
renderTables(S); renderTables(S, d);
drawCharts(); drawCharts();
$('#foot').textContent = `数据来源:MIDAS 综合报表(${d.source || ''})。每天 11:00 自动刷新,金额单位美元。充值记录和 pkg name 手工维护在 manual.json。`; $('#foot').textContent = `数据来源:${d.source}。消耗按表格口径,成本 = 消耗 ÷ 首充,余额按到账金额计算。每天 11:00 检查表格,有更新就自动刷新。`;
}).catch(e => { })
.catch(e => {
$('#meta').textContent = ''; $('#meta').textContent = '';
const err = $('#err'); const err = $('#err');
err.hidden = false; err.hidden = false;
err.textContent = '数据加载失败:' + e.message; err.textContent = '数据加载失败:' + e.message;
}); });
let resizeTimer, lastWidth = 0; let resizeTimer, lastWidth = 0;
function onResize() { function onResize() {
-8
View File
@@ -1,8 +0,0 @@
{
"_说明": "手工维护的数据,自动刷新不会改动这个文件。pkg_names:两个订单的包名。recharges:每笔充值一行,date 用 YYYY-MM-DD,amount 单位美元,note 可空。",
"pkg_names": {
"3202606240007": "",
"3202608110004": ""
},
"recharges": []
}