Update report UI and month-to-date metrics
This commit is contained in:
+247
-27
@@ -1,30 +1,250 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Ozon 国家投放表现</title>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Ozon 投放表现</title>
|
||||
<style>
|
||||
:root{--bg:#f4f7fb;--card:#fff;--text:#14213d;--muted:#6b7280;--line:#e6eaf0;--blue:#5577ee;--shadow:0 8px 30px rgba(24,39,75,.06)}
|
||||
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:Inter,"Microsoft YaHei",system-ui,sans-serif}.shell{max-width:1380px;margin:auto;padding:30px}.hero{background:linear-gradient(125deg,#111827,#26355e);color:white;border-radius:22px;padding:28px 30px;display:flex;justify-content:space-between;gap:20px;align-items:end;box-shadow:0 16px 40px rgba(17,24,39,.15)}h1{margin:0 0 8px;font-size:29px}.hero p{margin:0;color:#cbd5e1;font-size:13px}.updated{text-align:right;font-size:12px;color:#cbd5e1;line-height:1.6}.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:18px 0}.card,.panel{background:var(--card);border:1px solid var(--line);border-radius:16px;box-shadow:var(--shadow)}.card{padding:18px}.label{font-size:12px;color:var(--muted);margin-bottom:8px}.value{font-size:26px;font-weight:800}.hint{font-size:11px;color:#94a3b8;margin-top:6px}.panel{padding:20px;margin-top:15px}.panel-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:16px}.panel-title{font-size:17px;font-weight:800}.controls{display:flex;gap:9px;flex-wrap:wrap}button,select{border:1px solid var(--line);background:white;color:var(--text);padding:9px 12px;border-radius:9px;font:inherit;cursor:pointer}button.active{background:var(--blue);border-color:var(--blue);color:white}.chart-wrap{width:100%;overflow:auto}.chart{width:100%;min-width:760px;height:auto;display:block}.legend{display:flex;justify-content:center;gap:18px;flex-wrap:wrap;margin-top:8px;font-size:12px}.dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:6px}.tables{display:grid;grid-template-columns:.8fr 1.2fr;gap:15px}.table-wrap{overflow:auto;max-height:560px;border:1px solid var(--line);border-radius:11px}table{border-collapse:separate;border-spacing:0;width:100%;font-size:12px}th,td{padding:10px 12px;border-bottom:1px solid #edf0f5;text-align:right;white-space:nowrap}th{position:sticky;top:0;background:#f8fafc;color:#64748b;z-index:1}th:first-child,td:first-child{text-align:left}.country{font-weight:800}.error{padding:50px;text-align:center;color:#b42318}.footer{text-align:center;color:#94a3b8;font-size:11px;margin:20px}.skeleton{animation:pulse 1.2s infinite;background:#e9edf5;color:transparent;border-radius:5px}@keyframes pulse{50%{opacity:.55}}@media(max-width:950px){.grid{grid-template-columns:repeat(2,1fr)}.tables{grid-template-columns:1fr}}@media(max-width:620px){.shell{padding:14px}.hero{display:block}.updated{text-align:left;margin-top:14px}.grid{grid-template-columns:1fr 1fr}.value{font-size:21px}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<header class="hero"><div><h1>Ozon · 国家投放表现</h1><p id="period">正在加载过去 14 天数据…</p></div><div class="updated" id="updated">数据加载中</div></header>
|
||||
<div class="grid" id="kpis"><div class="card"><div class="label">总安装</div><div class="value skeleton">00000</div></div></div>
|
||||
<section class="panel"><div class="panel-head"><div class="panel-title">14 天趋势</div><div class="controls" id="metricButtons"><button data-metric="installs" class="active">安装数</button><button data-metric="spend">消耗</button><button data-metric="cac">CAC</button></div></div><div class="chart-wrap"><svg class="chart" id="chart" viewBox="0 0 1100 390" role="img" aria-label="国家趋势图"></svg></div><div class="legend" id="legend"></div></section>
|
||||
<div class="tables"><section class="panel"><div class="panel-head"><div class="panel-title">国家汇总</div></div><div class="table-wrap"><table><thead><tr><th>国家</th><th>安装数</th><th>消耗</th><th>日均 CAC</th></tr></thead><tbody id="summaryRows"></tbody></table></div></section><section class="panel"><div class="panel-head"><div class="panel-title">每日明细</div><select id="countryFilter"><option value="">全部国家</option></select></div><div class="table-wrap"><table><thead><tr><th>日期</th><th>国家</th><th>安装数</th><th>消耗</th><th>CAC</th></tr></thead><tbody id="detailRows"></tbody></table></div></section></div>
|
||||
<div class="footer">数据来源:Google Sheets · Ozon_IronSpace · 每 24 小时自动更新</div>
|
||||
</div>
|
||||
:root{--bg:#f4f7fb;--card:#fff;--text:#14213d;--muted:#6b7280;--line:#e5eaf1;--blue:#5577ee;--green:#23a673;--pink:#e4568b}
|
||||
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:Inter,"Microsoft YaHei",system-ui,sans-serif}
|
||||
.shell{max-width:1380px;margin:auto;padding:26px}
|
||||
.hero{display:flex;justify-content:space-between;gap:18px;align-items:end;padding:24px 27px;border-radius:20px;color:#fff;background:linear-gradient(125deg,#111827,#26355e)}
|
||||
h1{margin:0 0 6px;font-size:27px}.hero p,.updated{margin:0;color:#cbd5e1;font-size:12px;line-height:1.7}.updated{text-align:right}
|
||||
.scope{display:flex;align-items:center;gap:10px;margin:16px 0 0;font-size:13px;color:var(--muted)}
|
||||
select{border:1px solid var(--line);background:#fff;padding:8px 11px;border-radius:9px;color:var(--text);font:inherit}
|
||||
.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:13px;margin:13px 0}
|
||||
.card,.panel{background:var(--card);border:1px solid var(--line);border-radius:15px;box-shadow:0 8px 28px rgba(24,39,75,.05)}
|
||||
.card{padding:17px}.label{font-size:12px;color:var(--muted)}.value{font-size:26px;font-weight:800;margin-top:6px}.hint{font-size:11px;color:#94a3b8;margin-top:5px}
|
||||
.panel{padding:18px;margin-top:14px}.panel-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:10px}.panel-title{font-size:17px;font-weight:800}
|
||||
.chart-wrap{position:relative;overflow:auto}.chart{display:block;width:100%;min-width:760px;height:auto}
|
||||
.tip{position:absolute;pointer-events:none;opacity:0;transform:translate(-50%,-100%);background:rgba(17,24,39,.94);color:#fff;font-size:12px;line-height:1.6;padding:8px 11px;border-radius:9px;white-space:nowrap;z-index:5;transition:opacity .12s}
|
||||
.legend{text-align:center;color:var(--muted);font-size:12px;margin-top:7px}.legend span{margin:0 9px;white-space:nowrap}
|
||||
.dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:5px}
|
||||
.tables{display:grid;grid-template-columns:1fr 1.25fr;gap:14px}
|
||||
.table-wrap{overflow:auto;max-height:520px;border:1px solid var(--line);border-radius:10px}
|
||||
table{width:100%;border-collapse:separate;border-spacing:0;font-size:12px}
|
||||
th,td{padding:9px 11px;text-align:right;white-space:nowrap;border-bottom:1px solid #edf0f5}
|
||||
th{position:sticky;top:0;background:#f8fafc;color:#64748b;z-index:1}th:first-child,td:first-child{text-align:left}
|
||||
.country{font-weight:800}.total-row{background:#f8fafc;font-weight:800}
|
||||
.error{margin:40px auto;max-width:700px;text-align:center;padding:44px;color:#b42318}
|
||||
.footer{text-align:center;color:#94a3b8;font-size:11px;margin:18px}
|
||||
@media(max-width:900px){.grid{grid-template-columns:repeat(2,1fr)}.tables{grid-template-columns:1fr}}
|
||||
@media(max-width:560px){.shell{padding:12px}.hero{display:block}.updated{text-align:left;margin-top:12px}.grid{gap:9px}.card{padding:13px}.value{font-size:20px}.panel{padding:13px}.panel-head{flex-direction:column;align-items:flex-start}}
|
||||
</style></head><body><main class="shell">
|
||||
<header class="hero"><div><h1>Ozon · 投放表现</h1><p id="period">正在加载数据…</p></div><div class="updated" id="updated">数据加载中</div></header>
|
||||
|
||||
<section class="panel"><div class="panel-title" style="margin-bottom:11px">国家汇总</div><div class="table-wrap"><table><thead><tr><th>国家</th><th>Install</th><th>CPI</th><th>Revenue</th><th>Budget</th><th>Left Budget</th><th>FFR</th><th>L3D avr Install</th><th>Rec. Daily Install</th></tr></thead><tbody id="summaryRows"></tbody></table></div></section>
|
||||
|
||||
<div class="scope"><label>统计范围 <select id="scope"></select></label><span id="scopeHint"></span></div>
|
||||
<div class="grid" id="kpis"></div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head"><div class="panel-title">Install · Revenue · CAC</div></div>
|
||||
<div class="chart-wrap"><svg id="mainChart" class="chart" viewBox="0 0 1100 380" role="img" aria-label="Install、Revenue 与 CAC 趋势图"></svg><div class="tip" id="mainTip"></div></div>
|
||||
<div class="legend"><span><i class="dot" style="background:var(--blue)"></i>Install(左轴,实线)</span><span><i class="dot" style="background:var(--green)"></i>Revenue(右轴 USD,虚线)</span><span><i class="dot" style="background:var(--pink)"></i>CAC(最右轴,源表原始值)</span></div>
|
||||
</section>
|
||||
|
||||
<section class="panel"><div class="panel-head"><div class="panel-title">每日明细</div><label>国家 <select id="detailScope"></select></label></div><div class="table-wrap"><table><thead><tr><th>日期</th><th>国家</th><th>Install</th><th>Revenue</th><th>CAC</th></tr></thead><tbody id="detailRows"></tbody></table></div></section>
|
||||
<div class="footer">数据来源:Google Sheets · Ozon_IronSpace · 每 24 小时自动更新</div></main>
|
||||
<script>
|
||||
const colors={KZ:'#5577ee',UZ:'#32b67a',KG:'#f59e0b',AM:'#e4568b'};let report=null;let metric='installs';
|
||||
const $=id=>document.getElementById(id);const number=(v,d=0)=>Number(v||0).toLocaleString('en-US',{minimumFractionDigits:d,maximumFractionDigits:d});const money=v=>v==null?'—':'$'+number(v,2);const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
function render(){const totalI=report.summary.reduce((a,x)=>a+x.installs,0),totalS=report.summary.reduce((a,x)=>a+x.spend,0),cac=report.summary.map(x=>x.avg_cac).filter(x=>x!=null);$('period').textContent=`${report.period.start} 至 ${report.period.end} · ${report.period.days} 个完整自然日`;$('updated').innerHTML=`最近更新:${report.generated_at.replace('T',' ')}<br>时区:${report.timezone}`;$('kpis').innerHTML=[['总安装',number(totalI),'四国合计'],['总消耗',money(totalS),'USD'],['平均 CAC',cac.length?number(cac.reduce((a,b)=>a+b,0)/cac.length,2):'—','来源表原始单位 · 各国家日均值'],['覆盖国家',report.countries.length,'KZ · UZ · KG · AM']].map(x=>`<div class="card"><div class="label">${x[0]}</div><div class="value">${x[1]}</div><div class="hint">${x[2]}</div></div>`).join('');$('summaryRows').innerHTML=report.summary.map(x=>`<tr><td class="country" style="color:${colors[x.country]}">${x.country}</td><td>${number(x.installs)}</td><td>${money(x.spend)}</td><td>${number(x.avg_cac,2)}</td></tr>`).join('');$('countryFilter').innerHTML='<option value="">全部国家</option>'+report.countries.map(x=>`<option>${x}</option>`).join('');renderDetails();drawChart()}
|
||||
function renderDetails(){const country=$('countryFilter').value,rows=report.records.filter(x=>!country||x.country===country).sort((a,b)=>b.date.localeCompare(a.date)||a.country.localeCompare(b.country));$('detailRows').innerHTML=rows.map(x=>`<tr><td>${x.date}</td><td class="country" style="color:${colors[x.country]}">${x.country}</td><td>${number(x.installs)}</td><td>${money(x.spend)}</td><td>${x.cac==null?'—':number(x.cac,2)}</td></tr>`).join('')}
|
||||
function drawChart(){const svg=$('chart'),W=1100,H=390,m={l:70,r:25,t:30,b:58},iw=W-m.l-m.r,ih=H-m.t-m.b,dates=[...new Set(report.records.map(x=>x.date))].sort(),values=report.records.map(x=>x[metric]).filter(x=>x!=null),max=Math.max(...values,1)*1.12,x=i=>m.l+(dates.length===1?iw/2:i*iw/(dates.length-1)),y=v=>m.t+ih-(v/max)*ih,fmt=metric==='installs'?v=>number(v):(metric==='spend'?v=>money(v):v=>number(v,2));let h='';for(let i=0;i<=5;i++){const yy=m.t+ih*i/5,val=max*(1-i/5);h+=`<line x1="${m.l}" y1="${yy}" x2="${W-m.r}" y2="${yy}" stroke="#e8edf5"/><text x="${m.l-12}" y="${yy+4}" text-anchor="end" fill="#8190a5" font-size="11">${esc(fmt(val))}</text>`}dates.forEach((d,i)=>{if(i%2===0||dates.length<9)h+=`<text x="${x(i)}" y="${H-27}" text-anchor="middle" fill="#8190a5" font-size="11">${d.slice(5)}</text>`});for(const country of report.countries){const rows=dates.map(d=>report.records.find(r=>r.country===country&&r.date===d)),points=rows.map((r,i)=>r&&r[metric]!=null?`${x(i)},${y(r[metric])}`:null);let segment=[];const flush=()=>{if(segment.length>1)h+=`<polyline points="${segment.join(' ')}" fill="none" stroke="${colors[country]}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>`;segment=[]};points.forEach(p=>p?segment.push(p):flush());flush();rows.forEach((r,i)=>{if(r&&r[metric]!=null)h+=`<circle cx="${x(i)}" cy="${y(r[metric])}" r="4" fill="${colors[country]}" stroke="white" stroke-width="2"><title>${country} · ${r.date} · ${fmt(r[metric])}</title></circle>`})}svg.innerHTML=h;$('legend').innerHTML=report.countries.map(x=>`<span><i class="dot" style="background:${colors[x]}"></i>${x}</span>`).join('')}
|
||||
document.querySelectorAll('#metricButtons button').forEach(btn=>btn.onclick=()=>{metric=btn.dataset.metric;document.querySelectorAll('#metricButtons button').forEach(x=>x.classList.toggle('active',x===btn));drawChart()});$('countryFilter').onchange=renderDetails;
|
||||
fetch('data.json',{cache:'no-store'}).then(r=>{if(!r.ok)throw new Error(`HTTP ${r.status}`);return r.json()}).then(data=>{report=data;render()}).catch(error=>{document.querySelector('.shell').innerHTML=`<div class="panel error"><h2>数据加载失败</h2><p>${esc(error.message)}</p><p>请稍后刷新页面。</p></div>`});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
'use strict';
|
||||
const $=id=>document.getElementById(id),COUNTRIES=['KZ','UZ','KG','AM'],
|
||||
colors={KZ:'#5577ee',UZ:'#23a673',KG:'#f59e0b',AM:'#e4568b'},
|
||||
esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])),
|
||||
ok=v=>v!==null&&v!==undefined&&v!==''&&Number.isFinite(Number(v)),
|
||||
n=v=>ok(v)?Number(v):0,
|
||||
num=(v,d=0)=>n(v).toLocaleString('en-US',{minimumFractionDigits:d,maximumFractionDigits:d}),
|
||||
usd=v=>ok(v)?'$'+num(v,2):'—',
|
||||
pct=v=>ok(v)?num(v,2)+'%':'—';
|
||||
let report=null,countries=COUNTRIES.slice();
|
||||
|
||||
function records(){return Array.isArray(report.records)?report.records:[]}
|
||||
function summary(){return Array.isArray(report.summary)?report.summary:[]}
|
||||
function hasRevenue(){return records().some(r=>ok(r.revenue)||ok(r.spend))}
|
||||
function scopeRows(scope){const rows=records();return scope==='TOTAL'?rows:rows.filter(r=>r.country===scope)}
|
||||
function dates(){return [...new Set(records().filter(r=>r.date).map(r=>r.date))].sort()}
|
||||
|
||||
/* 按日期聚合所选范围:Install/Revenue/Spend 求和;TOTAL 下 CAC 不合并 */
|
||||
function series(scope){
|
||||
return dates().map(date=>{
|
||||
const rows=scopeRows(scope).filter(r=>r.date===date);
|
||||
const cacRows=rows.filter(r=>ok(r.cac));
|
||||
return {date,
|
||||
installs:rows.reduce((s,r)=>s+n(r.installs),0),
|
||||
revenue:rows.some(r=>ok(r.revenue)||ok(r.spend))?rows.reduce((s,r)=>s+n(ok(r.revenue)?r.revenue:r.spend),0):null,
|
||||
cac:scope==='TOTAL'?null:(cacRows.length?Number(cacRows[0].cac):null)};
|
||||
});
|
||||
}
|
||||
|
||||
function kpi(scope){
|
||||
const stored=(report.kpis&&report.kpis[scope])||null,rows=scopeRows(scope),
|
||||
sums=field=>{const pick=r=>field==='revenue'&&!ok(r.revenue)?r.spend:r[field];
|
||||
return rows.some(r=>ok(pick(r)))?rows.reduce((s,r)=>s+n(pick(r)),0):null},
|
||||
fromSummary=field=>{const list=scope==='TOTAL'?summary():summary().filter(r=>r.country===scope),
|
||||
vals=list.map(r=>r[field]).filter(ok);return vals.length?vals.reduce((a,b)=>a+Number(b),0):null};
|
||||
const legacy=report.kpis||{};
|
||||
return {
|
||||
installs:stored&&ok(stored.installs)?Number(stored.installs):(scope==='TOTAL'&&ok(legacy.total_installs)?Number(legacy.total_installs):sums('installs')),
|
||||
revenue:stored&&ok(stored.revenue)?Number(stored.revenue):(sums('revenue')!==null?sums('revenue'):(scope==='TOTAL'&&ok(legacy.spend_usd)?Number(legacy.spend_usd):null)),
|
||||
budget:stored&&ok(stored.budget_usd)?Number(stored.budget_usd):(scope==='TOTAL'&&ok(legacy.budget_usd)?Number(legacy.budget_usd):fromSummary('budget_usd')),
|
||||
ffr:stored&&ok(stored.ffr_percent)?Number(stored.ffr_percent):(scope==='TOTAL'&&ok(legacy.ffr_percent)?Number(legacy.ffr_percent):(scope==='TOTAL'?null:fromSummary('ffr_percent')))
|
||||
};
|
||||
}
|
||||
|
||||
/* 月度天数进度:已统计天数 / 当月总天数 */
|
||||
function monthProgress(period){
|
||||
const end=String(period.end||''),match=end.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if(!match||!ok(period.days))return '';
|
||||
const year=Number(match[1]),month=Number(match[2]),
|
||||
totalDays=new Date(year,month,0).getDate(),
|
||||
passed=Math.min(Number(period.days),totalDays);
|
||||
return ` · 月度进度 ${num(passed/totalDays*100,1)}%(${passed}/${totalDays} 天)`;
|
||||
}
|
||||
|
||||
function fillSelect(select,withTotal,totalLabel){
|
||||
select.innerHTML=(withTotal?`<option value="TOTAL">${totalLabel}</option>`:'')+countries.map(c=>`<option value="${c}">${c}</option>`).join('');
|
||||
}
|
||||
|
||||
function px(i,count,m,iw){return count>1?m.l+i*iw/(count-1):m.l+iw/2}
|
||||
function empty(svg,message){svg.innerHTML=`<text x="550" y="160" text-anchor="middle" fill="#8190a5" font-size="14">${esc(message)}</text>`}
|
||||
|
||||
function xLabels(rows,m,W,H,iw){
|
||||
return rows.map((row,i)=>(i%2===0||i===rows.length-1)
|
||||
?`<text x="${px(i,rows.length,m,iw)}" y="${H-18}" text-anchor="middle" fill="#8190a5" font-size="11">${esc(String(row.date).slice(5))}</text>`:'').join('');
|
||||
}
|
||||
|
||||
function drawMain(){
|
||||
const scope=$('scope').value,svg=$('mainChart'),rows=series(scope).filter(r=>r);
|
||||
if(!rows.length){empty(svg,'暂无趋势数据');return}
|
||||
const W=1100,H=380,m={l:66,r:132,t:22,b:50},iw=W-m.l-m.r,ih=H-m.t-m.b,
|
||||
maxI=Math.max(1,...rows.map(r=>n(r.installs)))*1.12,
|
||||
revVals=rows.filter(r=>ok(r.revenue)).map(r=>Number(r.revenue)),
|
||||
maxR=revVals.length?Math.max(1,...revVals)*1.12:null,
|
||||
cacVals=rows.filter(r=>ok(r.cac)).map(r=>Number(r.cac)),
|
||||
maxC=cacVals.length?Math.max(1,...cacVals)*1.12:null,
|
||||
x=i=>px(i,rows.length,m,iw),
|
||||
yI=v=>m.t+ih-n(v)/maxI*ih,
|
||||
yR=v=>m.t+ih-Number(v)/maxR*ih,
|
||||
yC=v=>m.t+ih-Number(v)/maxC*ih;
|
||||
let h='';
|
||||
for(let i=0;i<=5;i++){
|
||||
const y=m.t+ih*i/5,ratio=1-i/5;
|
||||
h+=`<line x1="${m.l}" y1="${y}" x2="${W-m.r}" y2="${y}" stroke="#e8edf5"/>`;
|
||||
h+=`<text x="${m.l-9}" y="${y+4}" text-anchor="end" fill="#8190a5" font-size="11">${num(maxI*ratio)}</text>`;
|
||||
if(maxR!=null)h+=`<text x="${W-m.r+8}" y="${y+4}" fill="#23a673" font-size="11">${num(maxR*ratio)}</text>`;
|
||||
if(maxC!=null)h+=`<text x="${W-m.r+72}" y="${y+4}" fill="#e4568b" font-size="11">${num(maxC*ratio)}</text>`;
|
||||
}
|
||||
h+=xLabels(rows,m,W,H,iw);
|
||||
h+=`<polyline points="${rows.map((r,i)=>`${x(i)},${yI(r.installs)}`).join(' ')}" fill="none" stroke="#5577ee" stroke-width="3"/>`;
|
||||
const line=(getY,valid,color,dash)=>{let out='',seg=[];const flush=()=>{if(seg.length>1)out+=`<polyline points="${seg.join(' ')}" fill="none" stroke="${color}" stroke-width="2.5"${dash?` stroke-dasharray="${dash}"`:''}/>`;seg=[]};
|
||||
rows.forEach((r,i)=>{valid(r)?seg.push(`${x(i)},${getY(r)}`):flush()});flush();return out};
|
||||
/* Revenue 常与 Install 成固定比例、走势重合,用虚线让下方的 Install 实线透出 */
|
||||
if(maxR!=null)h+=line(r=>yR(r.revenue),r=>ok(r.revenue),'#23a673','8 5');
|
||||
if(maxC!=null)h+=line(r=>yC(r.cac),r=>ok(r.cac),'#e4568b','3 4');
|
||||
rows.forEach((r,i)=>{
|
||||
if(maxR!=null&&ok(r.revenue))h+=`<circle cx="${x(i)}" cy="${yR(r.revenue)}" r="4.6" fill="#fff" stroke="#23a673" stroke-width="2"/>`;
|
||||
if(maxC!=null&&ok(r.cac))h+=`<circle cx="${x(i)}" cy="${yC(r.cac)}" r="3.4" fill="#e4568b"/>`;
|
||||
h+=`<circle cx="${x(i)}" cy="${yI(r.installs)}" r="3.2" fill="#5577ee"/>`;
|
||||
});
|
||||
h+=`<line id="mainCursor" x1="0" y1="${m.t}" x2="0" y2="${m.t+ih}" stroke="#94a3b8" stroke-dasharray="4 4" opacity="0"/>`;
|
||||
h+=`<rect id="mainHit" x="${m.l}" y="${m.t}" width="${iw}" height="${ih}" fill="transparent"/>`;
|
||||
svg.innerHTML=h;
|
||||
hover(svg,$('mainTip'),rows,m,iw,'mainCursor',(row)=>
|
||||
`${esc(row.date)} · ${scope==='TOTAL'?'四国合计':scope}<br>Install:${num(row.installs)}<br>Revenue:${usd(row.revenue)}<br>CAC:${scope==='TOTAL'?'按国家查看':(ok(row.cac)?num(row.cac,2):'—')}`);
|
||||
}
|
||||
|
||||
/* 鼠标移动时定位最近日期,并在光标旁显示当天数值 */
|
||||
function hover(svg,tip,rows,m,iw,cursorId,html){
|
||||
const move=event=>{
|
||||
const box=svg.getBoundingClientRect(),vb=svg.viewBox.baseVal,
|
||||
vx=(event.clientX-box.left)/box.width*vb.width;
|
||||
let index=0,best=Infinity;
|
||||
rows.forEach((row,i)=>{const d=Math.abs(px(i,rows.length,m,iw)-vx);if(d<best){best=d;index=i}});
|
||||
const row=rows[index],cx=px(index,rows.length,m,iw),cursor=svg.querySelector('#'+cursorId);
|
||||
if(cursor){cursor.setAttribute('x1',cx);cursor.setAttribute('x2',cx);cursor.setAttribute('opacity','1')}
|
||||
tip.innerHTML=html(row);
|
||||
tip.style.left=(cx/vb.width*box.width)+'px';
|
||||
tip.style.top=Math.max(38,event.clientY-box.top-14)+'px';
|
||||
tip.style.opacity='1';
|
||||
};
|
||||
svg.onmousemove=move;
|
||||
svg.onmouseleave=()=>{tip.style.opacity='0';const cursor=svg.querySelector('#'+cursorId);if(cursor)cursor.setAttribute('opacity','0')};
|
||||
}
|
||||
|
||||
function renderKpis(){
|
||||
const scope=$('scope').value,k=kpi(scope),label=scope==='TOTAL'?'四国合计':scope,
|
||||
period=report.period||{},range=`${period.start||'—'} 至 ${period.end||'—'}`;
|
||||
$('scopeHint').textContent=`${label} · ${range}`;
|
||||
$('kpis').innerHTML=[
|
||||
['Install',ok(k.installs)?num(k.installs):'—',label],
|
||||
['Revenue',usd(k.revenue),hasRevenue()?label+' · USD':'源表 Revenue 待刷新'],
|
||||
['Budget',usd(k.budget),ok(k.budget)?'Current Month Budget':'数据源待刷新'],
|
||||
['FFR',pct(k.ffr),ok(k.ffr)?'源表 FFR':'数据源待刷新']
|
||||
].map(item=>`<div class="card"><div class="label">${item[0]}</div><div class="value">${item[1]}</div><div class="hint">${esc(item[2])}</div></div>`).join('');
|
||||
}
|
||||
|
||||
/* 当月剩余天数 = 当月总天数 - 已统计天数 */
|
||||
function remainingDays(){
|
||||
const period=report.period||{},match=String(period.end||'').match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if(!match||!ok(period.days))return null;
|
||||
const totalDays=new Date(Number(match[1]),Number(match[2]),0).getDate();
|
||||
return Math.max(0,totalDays-Math.min(Number(period.days),totalDays));
|
||||
}
|
||||
|
||||
/* 最近 3 个自然日的平均 Install */
|
||||
function last3AvgInstall(scope){
|
||||
const list=dates().slice(-3);
|
||||
if(!list.length)return null;
|
||||
const rows=scopeRows(scope).filter(r=>list.includes(r.date));
|
||||
if(!rows.length)return null;
|
||||
return rows.reduce((s,r)=>s+n(r.installs),0)/list.length;
|
||||
}
|
||||
|
||||
/* 建议日均 Install = 剩余预算 / CPI / 剩余天数 */
|
||||
function recDailyInstall(leftBudget,cpi){
|
||||
const left=remainingDays();
|
||||
if(!ok(leftBudget)||!ok(cpi)||cpi<=0||!ok(left)||left<=0)return null;
|
||||
return leftBudget/cpi/left;
|
||||
}
|
||||
|
||||
function summaryCells(name,installs,revenue,budget,ffr,scope){
|
||||
const cpi=ok(installs)&&installs>0&&ok(revenue)?revenue/installs:null,
|
||||
left=ok(budget)&&ok(revenue)?budget-revenue:null,
|
||||
l3d=last3AvgInstall(scope),
|
||||
rec=recDailyInstall(left,cpi);
|
||||
return `<td>${num(installs)}</td><td>${ok(cpi)?'$'+num(cpi,2):'—'}</td><td>${usd(revenue)}</td>`
|
||||
+`<td>${usd(budget)}</td><td>${usd(left)}</td><td>${pct(ffr)}</td>`
|
||||
+`<td>${ok(l3d)?num(l3d):'—'}</td><td>${ok(rec)?num(rec):'—'}</td>`;
|
||||
}
|
||||
|
||||
function renderSummary(){
|
||||
const rows=summary(),total=kpi('TOTAL');
|
||||
$('summaryRows').innerHTML=rows.map(row=>{
|
||||
const revenue=ok(row.revenue)?Number(row.revenue):n(row.spend);
|
||||
return `<tr><td class="country" style="color:${colors[row.country]||'inherit'}">${esc(row.country)}</td>`
|
||||
+summaryCells(row.country,n(row.installs),revenue,ok(row.budget_usd)?Number(row.budget_usd):null,row.ffr_percent,row.country)+'</tr>';
|
||||
}).join('')
|
||||
+`<tr class="total-row"><td>Total</td>${summaryCells('Total',total.installs,total.revenue,total.budget,total.ffr,'TOTAL')}</tr>`;
|
||||
}
|
||||
|
||||
function renderDetails(){
|
||||
const scope=$('detailScope').value,
|
||||
rows=scopeRows(scope).slice().sort((a,b)=>String(b.date).localeCompare(String(a.date))||String(a.country).localeCompare(String(b.country)));
|
||||
$('detailRows').innerHTML=rows.map(row=>`<tr><td>${esc(row.date)}</td><td class="country" style="color:${colors[row.country]||'inherit'}">${esc(row.country)}</td><td>${num(row.installs)}</td><td>${usd(ok(row.revenue)?row.revenue:row.spend)}</td><td>${ok(row.cac)?num(row.cac,2):'—'}</td></tr>`).join('');
|
||||
}
|
||||
|
||||
function render(){
|
||||
if(Array.isArray(report.countries)&&report.countries.length)countries=report.countries;
|
||||
const period=report.period||{};
|
||||
$('period').textContent=`${period.start||'—'} 至 ${period.end||'—'}${ok(period.days)?` · ${num(period.days)} 天`:''}${monthProgress(period)}`;
|
||||
$('updated').innerHTML=`最近更新:${esc(String(report.generated_at||'—').replace('T',' '))}<br>时区:${esc(report.timezone||'—')}`;
|
||||
fillSelect($('scope'),true,'Total(四国合计)');
|
||||
fillSelect($('detailScope'),true,'全部国家');
|
||||
renderKpis();renderSummary();renderDetails();drawMain();
|
||||
}
|
||||
|
||||
$('scope').onchange=()=>{renderKpis();drawMain()};
|
||||
$('detailScope').onchange=renderDetails;
|
||||
window.addEventListener('resize',()=>{if(report)drawMain()});
|
||||
|
||||
fetch('data.json',{cache:'no-store'})
|
||||
.then(response=>{if(!response.ok)throw new Error(`HTTP ${response.status}`);return response.json()})
|
||||
.then(data=>{report=data;render()})
|
||||
.catch(error=>{document.querySelector('.shell').innerHTML=`<section class="panel error"><h2>数据加载失败</h2><p>${esc(error.message)}</p><p>请稍后刷新,页面不会使用旧值冒充最新数据。</p></section>`});
|
||||
</script></body></html>
|
||||
|
||||
Reference in New Issue
Block a user