Files

253 lines
17 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>