253 lines
17 KiB
HTML
253 lines
17 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate"><meta http-equiv="Pragma" content="no-cache"><meta http-equiv="Expires" content="0">
|
||
<title>Ozon 投放表现</title>
|
||
<style>
|
||
: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>
|
||
'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>
|