Tecdo 消耗与余额报告:网页 + 首份数据
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
push.sh
|
||||
fetch_midas.js
|
||||
.DS_Store
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"updated_at": "2026-09-10T14:40:00+08:00",
|
||||
"source": "data_report/list,按日期+订单",
|
||||
"account": { "id": 11535, "name": "C00000196_TECDO HONG KONG LIMITED" },
|
||||
"range": { "start": "2026-09-03", "end": "2026-09-09" },
|
||||
"campaigns": [
|
||||
{
|
||||
"id": "3202606240007",
|
||||
"name": "PendingC00000196_Tecdo_Agc_ArionPlay_APK_PH_CPI_MIX_20260624",
|
||||
"app": "ArionPlay",
|
||||
"start": "2026-06-24",
|
||||
"bid": 0.65,
|
||||
"daily_budget": 100,
|
||||
"lifetime_spend": 3770
|
||||
},
|
||||
{
|
||||
"id": "3202608110004",
|
||||
"name": "C00000196_Tecdo_Agc_PTGaming_APK_PH_CPI_MIX_20260811",
|
||||
"app": "PTGaming",
|
||||
"start": "2026-08-11",
|
||||
"bid": 0.65,
|
||||
"daily_budget": 50,
|
||||
"lifetime_spend": 5.2
|
||||
}
|
||||
],
|
||||
"daily": [
|
||||
{ "dt": "2026-09-03", "campaign_id": "3202606240007", "spend": 40.95, "installs": 63, "clicks": 28436, "ecpi": 0.65 },
|
||||
{ "dt": "2026-09-03", "campaign_id": "3202608110004", "spend": 0, "installs": 0, "clicks": 6123, "ecpi": 0 },
|
||||
{ "dt": "2026-09-04", "campaign_id": "3202606240007", "spend": 46.8, "installs": 72, "clicks": 30193, "ecpi": 0.65 },
|
||||
{ "dt": "2026-09-04", "campaign_id": "3202608110004", "spend": 0, "installs": 0, "clicks": 8263, "ecpi": 0 },
|
||||
{ "dt": "2026-09-05", "campaign_id": "3202606240007", "spend": 45.5, "installs": 70, "clicks": 29587, "ecpi": 0.65 },
|
||||
{ "dt": "2026-09-05", "campaign_id": "3202608110004", "spend": 0, "installs": 0, "clicks": 6176, "ecpi": 0 },
|
||||
{ "dt": "2026-09-06", "campaign_id": "3202606240007", "spend": 46.8, "installs": 72, "clicks": 30822, "ecpi": 0.65 },
|
||||
{ "dt": "2026-09-06", "campaign_id": "3202608110004", "spend": 0, "installs": 0, "clicks": 7574, "ecpi": 0 },
|
||||
{ "dt": "2026-09-07", "campaign_id": "3202606240007", "spend": 42.25, "installs": 65, "clicks": 31475, "ecpi": 0.65 },
|
||||
{ "dt": "2026-09-07", "campaign_id": "3202608110004", "spend": 0, "installs": 0, "clicks": 8234, "ecpi": 0 },
|
||||
{ "dt": "2026-09-08", "campaign_id": "3202606240007", "spend": 40.3, "installs": 62, "clicks": 28810, "ecpi": 0.65 },
|
||||
{ "dt": "2026-09-08", "campaign_id": "3202608110004", "spend": 0, "installs": 0, "clicks": 3481, "ecpi": 0 },
|
||||
{ "dt": "2026-09-09", "campaign_id": "3202606240007", "spend": 42.25, "installs": 65, "clicks": 27030, "ecpi": 0.65 },
|
||||
{ "dt": "2026-09-09", "campaign_id": "3202608110004", "spend": 0, "installs": 0, "clicks": 4208, "ecpi": 0 }
|
||||
]
|
||||
}
|
||||
+412
@@ -0,0 +1,412 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Tecdo 消耗与余额</title>
|
||||
<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; --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;
|
||||
}
|
||||
}
|
||||
: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;
|
||||
}
|
||||
* { 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; }
|
||||
.desc { color: var(--ink-2); font-size: 12px; margin: 2px 0 12px; }
|
||||
.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: 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-title { font-weight: 600; margin-bottom: 4px; }
|
||||
.tip-row { display: flex; align-items: center; gap: 6px; }
|
||||
.tip-name { flex: 1; color: var(--ink-2); }
|
||||
.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; }
|
||||
.empty { color: var(--ink-2); border: 1px dashed var(--axis); border-radius: 8px; padding: 18px; font-size: 13px; }
|
||||
.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); }
|
||||
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">
|
||||
<h2>近 7 天每日消耗</h2>
|
||||
<p class="desc">两个订单按天堆叠,悬停查看每个订单的消耗、安装和点击</p>
|
||||
<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="empty" id="balance-empty" hidden>还没有充值记录。把充值日期和金额发给 Claude,或者编辑仓库里的 manual.json,余额和走势会自动算出来。</div>
|
||||
<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="daily">
|
||||
<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>
|
||||
<tbody></tbody><tfoot></tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>订单信息</h2>
|
||||
<div class="table-wrap">
|
||||
<table id="camps">
|
||||
<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>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="recharge-card" hidden>
|
||||
<h2>充值记录</h2>
|
||||
<div class="table-wrap">
|
||||
<table id="recharges">
|
||||
<thead><tr><th>日期</th><th class="num">金额</th><th>备注</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('-', '/');
|
||||
|
||||
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`;
|
||||
|
||||
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 = 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 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) {
|
||||
const b = h('span', 'badge ' + kind);
|
||||
b.append(h('span', 'badge-icon', icon), document.createTextNode(text));
|
||||
return b;
|
||||
}
|
||||
|
||||
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 firstStart = S.camps.map(c => c.start).sort()[0];
|
||||
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;
|
||||
if (S.balance <= 0) sub = badge('crit', '✕', '余额已用完');
|
||||
else if (days < 7) sub = badge('warn', '!', `按日均约可用 ${Math.floor(days)} 天`);
|
||||
else sub = isFinite(days) ? `按日均约可用 ${Math.floor(days)} 天` : '近 7 天没有消耗';
|
||||
add('当前余额', usd(S.balance), sub);
|
||||
}
|
||||
}
|
||||
|
||||
function drawSpend(S) {
|
||||
const wrap = $('#spend-chart');
|
||||
wrap.querySelector('svg')?.remove();
|
||||
hideTip(wrap);
|
||||
const W = Math.max(300, wrap.clientWidth), H = 260, m = { l: 52, r: 12, t: 24, b: 30 };
|
||||
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 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 svg = el('svg', { viewBox: `0 0 ${W} ${H}`, width: W, height: H, role: 'img', 'aria-label': '近 7 天每日消耗,按订单堆叠' });
|
||||
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 maxI = totals.indexOf(Math.max(...totals)), lastI = totals.length - 1;
|
||||
S.dates.forEach((dt, i) => {
|
||||
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);
|
||||
let base = 0;
|
||||
segs.forEach((s, j) => {
|
||||
const y0 = y(base), y1 = y(base + s.v), gap = j > 0 ? 2 : 0;
|
||||
const hgt = Math.max(0, y0 - y1 - gap);
|
||||
const r = j === segs.length - 1 ? Math.min(4, hgt) : 0;
|
||||
el('path', { d: roundTop(cx - bw / 2, y1, bw, hgt, r), fill: s.c.color }, svg);
|
||||
base += s.v;
|
||||
});
|
||||
el('text', { x: cx, y: H - m.b + 18, 'text-anchor': 'middle', class: 'tick' }, svg).textContent = shortDate(dt);
|
||||
if ((i === maxI || i === lastI) && totals[i] > 0) {
|
||||
el('text', { x: cx, y: y(totals[i]) - 7, 'text-anchor': 'middle', class: 'val' }, svg).textContent = usd(totals[i]);
|
||||
}
|
||||
});
|
||||
S.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));
|
||||
S.camps.forEach(c => {
|
||||
const r = S.get(dt, c.id);
|
||||
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)));
|
||||
f.append(row);
|
||||
if (r) f.append(h('div', 'tip-sub', `安装 ${int(r.installs)} · 点击 ${int(r.clicks)}`));
|
||||
});
|
||||
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);
|
||||
$('#balance-empty').hidden = S.hasRecharge;
|
||||
wrap.hidden = !S.hasRecharge;
|
||||
if (!S.hasRecharge) return;
|
||||
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 pts = S.balanceSeries, vals = pts.map(p => p.v), li = pts.length - 1;
|
||||
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 = pts.length > 1 ? pw / (pts.length - 1) : 0;
|
||||
const x = i => pts.length > 1 ? m.l + i * stepX : m.l + pw / 2;
|
||||
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);
|
||||
});
|
||||
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 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('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.recharge) row('当天充值', usd(p.recharge));
|
||||
showTip(wrap, x(i), y(p.v), f);
|
||||
});
|
||||
hit.addEventListener('pointerleave', () => { cross.setAttribute('visibility', 'hidden'); hov.setAttribute('visibility', 'hidden'); hideTip(wrap); });
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function renderTables(S) {
|
||||
const pkgCell = c => c.pkg ? { text: c.pkg } : { text: '待补', cls: 'muted' };
|
||||
const daily = $('#daily tbody');
|
||||
daily.replaceChildren();
|
||||
[...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([
|
||||
{ text: x.date }, { text: usd(Number(x.amount)), cls: 'num' }, { text: x.note || '' },
|
||||
])));
|
||||
$('#recharges tfoot').replaceChildren(tr([{ text: '合计' }, { text: usd(S.rechargeTotal), cls: 'num' }, { text: '' }]));
|
||||
}
|
||||
|
||||
let S;
|
||||
const drawCharts = () => { if (S) { drawSpend(S); drawBalance(S); } };
|
||||
|
||||
Promise.all([
|
||||
fetch('data.json', { cache: 'no-store' }).then(r => { if (!r.ok) throw new Error('data.json ' + r.status); return r.json(); }),
|
||||
fetch('manual.json', { cache: 'no-store' }).then(r => r.ok ? r.json() : {}).catch(() => ({})),
|
||||
]).then(([d, m]) => {
|
||||
S = derive(d, m);
|
||||
const updated = new Date(d.updated_at).toLocaleString('zh-CN', { hour12: false });
|
||||
$('#meta').replaceChildren(
|
||||
h('span', '', d.account ? d.account.name : 'Tecdo'),
|
||||
h('span', '', `订单 ${S.camps.map(c => c.id).join('、')}`),
|
||||
h('span', '', `数据区间 ${d.range.start} ~ ${d.range.end}`),
|
||||
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);
|
||||
renderTables(S);
|
||||
drawCharts();
|
||||
$('#foot').textContent = `数据来源:MIDAS 综合报表(${d.source || ''})。每天 11:00 自动刷新,金额单位美元。充值记录和 pkg name 手工维护在 manual.json。`;
|
||||
}).catch(e => {
|
||||
$('#meta').textContent = '';
|
||||
const err = $('#err');
|
||||
err.hidden = false;
|
||||
err.textContent = '数据加载失败:' + e.message;
|
||||
});
|
||||
|
||||
let resizeTimer;
|
||||
addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(drawCharts, 120); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"_说明": "手工维护的数据,自动刷新不会改动这个文件。pkg_names:两个订单的包名。recharges:每笔充值一行,date 用 YYYY-MM-DD,amount 单位美元,note 可空。",
|
||||
"pkg_names": {
|
||||
"3202606240007": "",
|
||||
"3202608110004": ""
|
||||
},
|
||||
"recharges": []
|
||||
}
|
||||
Reference in New Issue
Block a user