Add campaign experiment and control reporting

This commit is contained in:
zouyun
2026-08-28 16:37:10 +08:00
parent 1ae965915a
commit 889e08a18b
4 changed files with 106 additions and 64 deletions
+11 -35
View File
@@ -1,36 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Manta 在投人群包表现</title>
<style>
:root{--bg:#f5f7fb;--panel:#fff;--ink:#152238;--muted:#69778c;--line:#e5eaf2;--blue:#356ae6;--cyan:#28b8c7;--green:#1b9e77;--orange:#ee8b2d;--red:#d95757;--shadow:0 10px 30px rgba(32,55,88,.08)}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif}.wrap{max-width:1500px;margin:auto;padding:30px}.top{display:flex;justify-content:space-between;gap:24px;align-items:flex-start;margin-bottom:22px}.eyebrow{color:var(--blue);font-size:12px;font-weight:800;letter-spacing:.12em;text-transform:uppercase}h1{font-size:30px;line-height:1.15;margin:7px 0 8px}.sub{color:var(--muted)}.fresh{background:#eaf1ff;color:#2858bf;border-radius:999px;padding:9px 13px;white-space:nowrap;font-weight:700}.filters{display:grid;grid-template-columns:1.2fr 1fr 1fr auto;gap:12px;background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:14px;box-shadow:var(--shadow);margin-bottom:18px}label{display:flex;flex-direction:column;gap:5px;color:var(--muted);font-size:12px}select,input,button{height:39px;border:1px solid var(--line);border-radius:9px;background:#fff;color:var(--ink);padding:0 11px;font:inherit}button{align-self:end;cursor:pointer;font-weight:700}button:hover{border-color:#b9c7df}.kpis{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin-bottom:18px}.card,.panel{background:var(--panel);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow)}.card{padding:16px}.card .label{color:var(--muted);font-size:12px}.card .value{font-size:25px;font-weight:800;margin-top:4px}.card .hint{color:var(--muted);font-size:11px;margin-top:2px}.grid{display:grid;grid-template-columns:1.2fr .8fr;gap:18px;margin-bottom:18px}.panel{padding:18px;min-width:0}.panel h2{font-size:16px;margin:0 0 3px}.panel .desc{color:var(--muted);font-size:12px;margin-bottom:12px}.chart{height:330px;position:relative}.chart svg{width:100%;height:100%;overflow:visible}.chart text{font-size:11px;fill:var(--muted)}.legend{display:flex;gap:15px;flex-wrap:wrap;margin-top:7px;color:var(--muted);font-size:12px}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:5px}.table-panel{padding:0;overflow:hidden}.table-head{display:flex;justify-content:space-between;align-items:center;padding:18px}.table-wrap{overflow:auto;max-height:600px;border-top:1px solid var(--line)}table{border-collapse:collapse;width:100%;min-width:1130px}th,td{padding:11px 12px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap}th{position:sticky;top:0;background:#f9fbfe;color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.04em;cursor:pointer;z-index:1}th:first-child,td:first-child{text-align:left;position:sticky;left:0;background:inherit}tbody tr:nth-child(even){background:#fbfcff}tbody tr:hover{background:#eef4ff}.pkg{font-weight:700}.nodata{color:var(--red);font-size:11px}.method{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:18px;color:var(--muted);font-size:12px}.method strong{color:var(--ink)}.error{padding:30px;text-align:center;color:var(--red)}@media(max-width:950px){.wrap{padding:18px}.top{flex-direction:column}.filters{grid-template-columns:1fr 1fr}.kpis{grid-template-columns:repeat(2,1fr)}.grid{grid-template-columns:1fr}.method{grid-template-columns:1fr}}@media(max-width:560px){.filters{grid-template-columns:1fr}.kpis{grid-template-columns:1fr 1fr}h1{font-size:24px}}
</style>
</head>
<body>
<main class="wrap" id="app"><div class="error">正在加载数据…</div></main>
<script>
const $=s=>document.querySelector(s), fmt={int:n=>n==null?'—':Math.round(n).toLocaleString('en-US'),money:n=>n==null?'—':'$'+n.toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2}),pct:n=>n==null?'—':(n*100).toFixed(2)+'%',num:n=>n==null?'—':n.toFixed(2)};
let DATA,sort={key:'spend',dir:-1};
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Manta 在投人群包表现</title><style>
:root{--bg:#f5f7fb;--panel:#fff;--ink:#152238;--muted:#69778c;--line:#e5eaf2;--blue:#356ae6;--orange:#ee8b2d;--red:#d95757;--shadow:0 10px 30px rgba(32,55,88,.08)}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif}.wrap{max-width:1560px;margin:auto;padding:30px}.top{display:flex;justify-content:space-between;gap:24px;align-items:flex-start;margin-bottom:22px}.eyebrow{color:var(--blue);font-size:12px;font-weight:800;letter-spacing:.12em;text-transform:uppercase}h1{font-size:30px;line-height:1.15;margin:7px 0 8px}.sub,.desc{color:var(--muted)}.fresh{background:#eaf1ff;color:#2858bf;border-radius:999px;padding:9px 13px;white-space:nowrap;font-weight:700}.filters{display:grid;grid-template-columns:1.5fr repeat(3,1fr) 1.05fr 1.05fr auto;gap:10px;background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:14px;box-shadow:var(--shadow);margin-bottom:18px}label{display:flex;flex-direction:column;gap:5px;color:var(--muted);font-size:12px}select,input,button{height:39px;border:1px solid var(--line);border-radius:9px;background:#fff;color:var(--ink);padding:0 11px;font:inherit}button{cursor:pointer;font-weight:700}.filters button{align-self:end}.period-bar{display:flex;justify-content:space-between;align-items:center;gap:15px;margin:3px 0 12px}.seg{display:inline-flex;background:#edf1f7;padding:3px;border-radius:10px}.seg button{border:0;background:transparent;height:34px;color:var(--muted)}.seg button.on{background:#fff;color:var(--blue);box-shadow:0 3px 10px rgba(30,60,100,.1)}.period-select{min-width:180px}.kpis{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin-bottom:18px}.card,.panel{background:var(--panel);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow)}.card{padding:16px}.card .label{color:var(--muted);font-size:12px}.card .value{font-size:25px;font-weight:800;margin-top:4px}.card .hint{color:var(--muted);font-size:11px;margin-top:2px}.compare{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:18px}.group-card{padding:17px;border-left:4px solid var(--blue)}.group-card.control{border-left-color:var(--orange)}.group-card h3{margin:0 0 11px}.mini{display:grid;grid-template-columns:repeat(5,1fr);gap:8px}.mini span{background:#f7f9fc;border-radius:8px;padding:8px;color:var(--muted);font-size:11px}.mini b{display:block;color:var(--ink);font-size:14px;margin-top:2px}.panel{padding:18px;min-width:0;margin-bottom:18px}.panel h2{font-size:16px;margin:0 0 3px}.desc{font-size:12px}.table-panel{padding:0;overflow:hidden}.table-head{display:flex;justify-content:space-between;align-items:center;gap:15px;padding:18px}.table-wrap{overflow:auto;max-height:510px;border-top:1px solid var(--line)}table{border-collapse:collapse;width:100%;min-width:1380px}th,td{padding:10px 11px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap}th{position:sticky;top:0;background:#f9fbfe;color:var(--muted);font-size:11px;text-transform:uppercase;cursor:pointer;z-index:2}th:first-child,td:first-child{text-align:left;position:sticky;left:0;background:inherit;z-index:1}tbody tr:nth-child(even){background:#fbfcff}tbody tr:hover{background:#eef4ff}.pkg{font-weight:700}.badge{display:inline-block;border-radius:999px;padding:2px 8px;font-size:11px;font-weight:700}.exp{background:#e8f1ff;color:#2858bf}.ctl{background:#fff0e2;color:#b66113}.daily-tools{display:flex;align-items:end;gap:10px}.method{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:18px;color:var(--muted);font-size:12px}.method strong{color:var(--ink)}.error,.empty{padding:30px;text-align:center;color:var(--red)}.empty{color:var(--muted)}@media(max-width:1100px){.filters{grid-template-columns:repeat(3,1fr)}.kpis{grid-template-columns:repeat(3,1fr)}.compare{grid-template-columns:1fr}.mini{grid-template-columns:repeat(3,1fr)}}@media(max-width:700px){.wrap{padding:18px}.top,.period-bar,.table-head{flex-direction:column;align-items:flex-start}.filters{grid-template-columns:1fr 1fr}.kpis{grid-template-columns:1fr 1fr}.mini{grid-template-columns:1fr 1fr}.method{grid-template-columns:1fr}}
</style></head><body><main class="wrap" id="app"><div class="error">正在加载数据…</div></main><script>
const $=s=>document.querySelector(s),fmt={int:n=>n==null?'—':Math.round(n).toLocaleString('en-US'),money:n=>n==null?'—':'$'+n.toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2}),pct:n=>n==null?'—':(n*100).toFixed(2)+'%'};let DATA,grain='week',sort={key:'spend',dir:-1};
fetch('data.json',{cache:'no-store'}).then(r=>{if(!r.ok)throw Error('data.json '+r.status);return r.json()}).then(d=>{DATA=d;boot()}).catch(e=>{$('#app').innerHTML='<div class="error">数据加载失败:'+e.message+'</div>'});
function boot(){const m=DATA.meta,pkgs=DATA.summary.map(x=>x.pkg);$('#app').innerHTML=`
<section class="top"><div><div class="eyebrow">Manta · Audience Package Tracking</div><h1>在投人群包 pkg 表现</h1><div class="sub">覆盖 ${m.date_start||'—'} 至 ${m.date_end||'—'} · Tableau 最近 45 天窗口</div></div><div class="fresh">更新于 ${new Date(m.generated_at).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai'})}</div></section>
<section class="filters"><label>人群包 pkg<select id="pkg"><option value="">全部在投 pkg</option>${pkgs.map(p=>`<option>${esc(p)}</option>`).join('')}</select></label><label>开始日期<input id="start" type="date" value="${m.date_start||''}"></label><label>结束日期<input id="end" type="date" value="${m.date_end||''}"></label><button id="reset">重置筛选</button></section>
<section class="kpis" id="kpis"></section>
<section class="grid"><article class="panel"><h2>量级趋势</h2><div class="desc">Impression、Click 与 Conversion(日)</div><div id="trend" class="chart"></div><div class="legend"><span><i class="dot" style="background:var(--blue)"></i>Impression</span><span><i class="dot" style="background:var(--cyan)"></i>Click</span><span><i class="dot" style="background:var(--orange)"></i>Conversion</span></div></article><article class="panel"><h2>Spend 排名</h2><div class="desc">当前筛选周期内,按 pkg 汇总</div><div id="rank" class="chart"></div></article></section>
<section class="panel table-panel"><div class="table-head"><div><h2>pkg 指标明细</h2><div class="desc" id="count"></div></div><div class="sub">点击表头排序</div></div><div class="table-wrap"><table><thead><tr>${[['pkg','pkg'],['impression','Impression'],['click','Click'],['conversion','Conversion'],['spend','Spend'],['ctr','CTR'],['cvr','CVR'],['cac','CAC'],['ivr','IVR'],['ecpi','eCPI']].map(([k,l])=>`<th data-k="${k}">${l}</th>`).join('')}</tr></thead><tbody id="tbody"></tbody></table></div></section>
<section class="method"><div><strong>指标口径</strong><br>CTR = Click / Impression;CVR = Conversion / Click;CAC = Spend / Conversion;IVR = Conversion / Impression;eCPI = Spend / Conversion。所有比率均用基础量汇总后重算。</div><div><strong>数据与在投口径</strong><br>表现数据来自 Tableau「DSP Spend / DSP Daily Data」;在投 pkg 来自钉钉文档「Manta人群标签外投 / Campaign info」中 status=Active 的清单。没有 Tableau 数据的在投 pkg 仍保留并标注。</div></section>`;
['pkg','start','end'].forEach(id=>$('#'+id).onchange=render);$('#reset').onclick=()=>{$('#pkg').value='';$('#start').value=m.date_start||'';$('#end').value=m.date_end||'';render()};document.querySelectorAll('th').forEach(th=>th.onclick=()=>{const k=th.dataset.k;sort=k===sort.key?{key:k,dir:-sort.dir}:{key:k,dir:k==='pkg'?1:-1};render()});addEventListener('resize',debounce(render,100));render()}
function slice(){let start=$('#start').value,end=$('#end').value,pkg=$('#pkg').value;return DATA.daily.filter(r=>(!pkg||r.pkg===pkg)&&(!start||r.date>=start)&&(!end||r.date<=end))}
function aggregate(rows){const map=new Map(DATA.summary.map(x=>[x.pkg,{pkg:x.pkg,impression:0,click:0,conversion:0,spend:0,has_data:false}]));rows.forEach(r=>{let x=map.get(r.pkg);x.impression+=r.impression;x.click+=r.click;x.conversion+=r.conversion;x.spend+=r.spend;x.has_data=true});return [...map.values()].map(calc).filter(x=>!$('#pkg').value||x.pkg===$('#pkg').value)}
function calc(x){x.ctr=x.impression?x.click/x.impression:null;x.cvr=x.click?x.conversion/x.click:null;x.ivr=x.impression?x.conversion/x.impression:null;x.cac=x.conversion?x.spend/x.conversion:null;x.ecpi=x.cac;return x}
function render(){const rows=slice(),sum=calc(rows.reduce((a,r)=>({impression:a.impression+r.impression,click:a.click+r.click,conversion:a.conversion+r.conversion,spend:a.spend+r.spend}),{impression:0,click:0,conversion:0,spend:0}));$('#kpis').innerHTML=[['Impression',fmt.int(sum.impression),'曝光量'],['Click',fmt.int(sum.click),'点击量 · CTR '+fmt.pct(sum.ctr)],['Conversion',fmt.int(sum.conversion),'转化量 · CVR '+fmt.pct(sum.cvr)],['Spend',fmt.money(sum.spend),'总消耗'],['CAC / eCPI',fmt.money(sum.cac),'IVR '+fmt.pct(sum.ivr)]].map(x=>`<article class="card"><div class="label">${x[0]}</div><div class="value">${x[1]}</div><div class="hint">${x[2]}</div></article>`).join('');const agg=aggregate(rows);drawTrend(rows);drawRank(agg);drawTable(agg)}
function drawTable(rows){rows.sort((a,b)=>{let x=a[sort.key],y=b[sort.key];if(x==null)x=-Infinity;if(y==null)y=-Infinity;return typeof x==='string'?x.localeCompare(y)*sort.dir:(x-y)*sort.dir});$('#count').textContent=`${rows.filter(x=>x.has_data).length} 个 pkg 有数据,${rows.filter(x=>!x.has_data).length} 个暂无数据`;$('#tbody').innerHTML=rows.map(x=>`<tr><td><span class="pkg">${esc(x.pkg)}</span>${x.has_data?'':' <span class="nodata">暂无数据</span>'}</td><td>${fmt.int(x.impression)}</td><td>${fmt.int(x.click)}</td><td>${fmt.int(x.conversion)}</td><td>${fmt.money(x.spend)}</td><td>${fmt.pct(x.ctr)}</td><td>${fmt.pct(x.cvr)}</td><td>${fmt.money(x.cac)}</td><td>${fmt.pct(x.ivr)}</td><td>${fmt.money(x.ecpi)}</td></tr>`).join('')}
function drawTrend(rows){const dates=[...new Set(rows.map(r=>r.date))].sort(),by=dates.map(d=>calc(rows.filter(r=>r.date===d).reduce((a,r)=>({date:d,impression:a.impression+r.impression,click:a.click+r.click,conversion:a.conversion+r.conversion,spend:a.spend+r.spend}),{impression:0,click:0,conversion:0,spend:0})));const el=$('#trend'),w=el.clientWidth||600,h=310,p={l:48,r:48,t:15,b:35};if(!by.length){el.innerHTML='<div class="error">当前筛选无数据</div>';return}const max=Math.max(...by.flatMap(x=>[x.impression,x.click]),1),maxC=Math.max(...by.map(x=>x.conversion),1),xp=i=>p.l+i*(w-p.l-p.r)/Math.max(by.length-1,1),yp=v=>p.t+(1-v/max)*(h-p.t-p.b),yc=v=>p.t+(1-v/maxC)*(h-p.t-p.b),path=(k,y)=>by.map((d,i)=>(i?'L':'M')+xp(i).toFixed(1)+','+y(d[k]).toFixed(1)).join(' ');el.innerHTML=`<svg viewBox="0 0 ${w} ${h}">${[0,.25,.5,.75,1].map(t=>`<line x1="${p.l}" y1="${yp(max*t)}" x2="${w-p.r}" y2="${yp(max*t)}" stroke="#e8edf5"/><text x="${p.l-7}" y="${yp(max*t)+4}" text-anchor="end">${compact(max*t)}</text><text x="${w-p.r+7}" y="${yc(maxC*t)+4}" text-anchor="start" fill="#ee8b2d">${compact(maxC*t)}</text>`).join('')}<text x="${p.l}" y="10">曝光 / 点击</text><text x="${w-p.r}" y="10" text-anchor="end" fill="#ee8b2d">转化</text><path d="${path('impression',yp)}" fill="none" stroke="#356ae6" stroke-width="2.4"/><path d="${path('click',yp)}" fill="none" stroke="#28b8c7" stroke-width="2.4"/><path d="${path('conversion',yc)}" fill="none" stroke="#ee8b2d" stroke-width="2.4"/>${by.filter((_,i)=>i%Math.ceil(by.length/6)===0||i===by.length-1).map(d=>{let idx=by.indexOf(d);return `<text x="${xp(idx)}" y="${h-10}" text-anchor="middle">${d.date.slice(5)}</text>`}).join('')}</svg>`}
function drawRank(rows){const data=rows.filter(x=>x.has_data).sort((a,b)=>b.spend-a.spend).slice(0,10),el=$('#rank'),w=el.clientWidth||500,h=310,p={l:190,r:45,t:10,b:15},max=Math.max(...data.map(x=>x.spend),1),bh=(h-p.t-p.b)/Math.max(data.length,1);el.innerHTML=data.length?`<svg viewBox="0 0 ${w} ${h}">${data.map((d,i)=>{let y=p.t+i*bh+3,bw=(w-p.l-p.r)*d.spend/max;return `<text x="${p.l-7}" y="${y+bh*.55}" text-anchor="end">${esc(short(d.pkg,25))}</text><rect x="${p.l}" y="${y}" width="${bw}" height="${Math.max(bh-7,4)}" rx="4" fill="#356ae6" opacity="${.45+.5*(1-i/data.length)}"/><text x="${p.l+bw+5}" y="${y+bh*.55}">${fmt.money(d.spend)}</text>`}).join('')}</svg>`:'<div class="error">当前筛选无数据</div>'}
function compact(n){return n>=1e9?(n/1e9).toFixed(1)+'B':n>=1e6?(n/1e6).toFixed(1)+'M':n>=1e3?(n/1e3).toFixed(1)+'K':Math.round(n)}function short(s,n){return s.length>n?s.slice(0,n-1)+'…':s}function esc(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}function debounce(fn,ms){let t;return()=>{clearTimeout(t);t=setTimeout(fn,ms)}}
</script>
</body>
</html>
function boot(){let m=DATA.meta,pkgs=uniq('pkg'),geos=uniq('geo'),plats=uniq('platform');$('#app').innerHTML=`<section class="top"><div><div class="eyebrow">Manta · Experiment Tracking</div><h1>在投 pkg 实验组 / 对照组表现</h1><div class="sub">覆盖 ${m.date_start||'—'} 至 ${m.date_end||'—'} · ${m.active_campaign_count} 个 Active campaign</div></div><div class="fresh">更新于 ${new Date(m.generated_at).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai'})}</div></section><section class="filters"><label>pkg<select id="pkg"><option value="">全部 pkg</option>${pkgs.map(opt).join('')}</select></label><label>组别<select id="group"><option value="">实验组 + 对照组</option><option>实验组</option><option>对照组</option></select></label><label>GEO<select id="geo"><option value="">全部 GEO</option>${geos.map(opt).join('')}</select></label><label>Platform<select id="platform"><option value="">全部 Platform</option>${plats.map(opt).join('')}</select></label><label>开始日期<input id="start" type="date" value="${m.date_start||''}"></label><label>结束日期<input id="end" type="date" value="${m.date_end||''}"></label><button id="reset">重置筛选</button></section><section class="period-bar"><div><h2 style="margin:0">周期指标表现</h2><div class="desc">按 campaign(adping_id)查看</div></div><div style="display:flex;gap:10px;align-items:center"><div class="seg"><button id="week" class="on">Weekly</button><button id="month">Monthly</button></div><select id="period" class="period-select"></select></div></section><section id="kpis" class="kpis"></section><section id="compare" class="compare"></section>${tableSection('Campaign 周期表现','period-desc','period-body',false)}<section class="panel table-panel"><div class="table-head"><div><h2>每天实验组 / 对照组表现</h2><div class="desc" id="daily-desc"></div></div><div class="daily-tools"><label>日期<select id="day"></select></label></div></div><div class="table-wrap"><table><thead>${head(true)}</thead><tbody id="daily-body"></tbody></table></div></section>${tableSection('pkg 汇总','pkg-desc','pkg-body','pkg')}<section class="method"><div><strong>计算口径</strong><br>CTR = Click / Impression × 100%;CVR = Conversion / Click × 100%;IVR = Conversion / Impression × 100%;CAC = Spend / Conversion;eCPI = Spend / Conversion。聚合时先汇总基础量,再重算比率。</div><div><strong>分组与基础信息</strong><br>pkg、adping_id、实验/对照组、GEO、Platform、投放状态来自钉钉「Campaign info」;仅纳入 G 列为 Active 的 campaign。表现数据来自 Tableau「DSP Daily Data」。</div></section>`;['pkg','group','geo','platform','start','end'].forEach(id=>$('#'+id).onchange=filtersChanged);$('#reset').onclick=()=>{['pkg','group','geo','platform'].forEach(id=>$('#'+id).value='');$('#start').value=m.date_start||'';$('#end').value=m.date_end||'';filtersChanged()};$('#week').onclick=()=>setGrain('week');$('#month').onclick=()=>setGrain('month');$('#period').onchange=render;$('#day').onchange=renderDaily;document.querySelectorAll('th[data-k]').forEach(th=>th.onclick=()=>{let k=th.dataset.k;sort=k===sort.key?{key:k,dir:-sort.dir}:{key:k,dir:k==='pkg'?1:-1};render()});filtersChanged()}
function tableSection(title,desc,body,type){return `<section class="panel table-panel"><div class="table-head"><div><h2>${title}</h2><div class="desc" id="${desc}"></div></div><div class="sub">点击表头排序</div></div><div class="table-wrap"><table><thead>${type==='pkg'?pkgHead():head()}</thead><tbody id="${body}"></tbody></table></div></section>`}function uniq(k){return [...new Set(DATA.campaigns.map(x=>x[k]).filter(Boolean))].sort()}function opt(v){return `<option>${esc(v)}</option>`}function head(day=false){let c=[['pkg','pkg'],['adping_id','Campaign'],['group','组别'],['geo','GEO'],['platform','Platform']];if(day)c.unshift(['date','日期']);return `<tr>${c.concat(metrics()).map(([k,l])=>`<th data-k="${k}">${l}</th>`).join('')}</tr>`}function pkgHead(){return `<tr>${[['pkg','pkg'],...metrics()].map(([k,l])=>`<th data-k="${k}">${l}</th>`).join('')}</tr>`}function metrics(){return [['impression','Impression'],['click','Click'],['conversion','Conversion'],['spend','Spend'],['ctr','CTR'],['cvr','CVR'],['ivr','IVR'],['cac','CAC'],['ecpi','eCPI']]}
function filtersChanged(){fillPeriods();fillDays();render()}function setGrain(g){grain=g;$('#week').classList.toggle('on',g==='week');$('#month').classList.toggle('on',g==='month');fillPeriods();render()}function baseRows(){let f={pkg:$('#pkg').value,group:$('#group').value,geo:$('#geo').value,platform:$('#platform').value},s=$('#start').value,e=$('#end').value;return DATA.campaign_daily.filter(r=>(!f.pkg||r.pkg===f.pkg)&&(!f.group||r.group===f.group)&&(!f.geo||r.geo===f.geo)&&(!f.platform||r.platform===f.platform)&&(!s||r.date>=s)&&(!e||r.date<=e))}function pkey(date){if(grain==='month')return date.slice(0,7);let d=new Date(date+'T00:00:00Z'),n=d.getUTCDay()||7;d.setUTCDate(d.getUTCDate()-n+1);return d.toISOString().slice(0,10)}function plabel(k){if(grain==='month')return k;let e=new Date(k+'T00:00:00Z');e.setUTCDate(e.getUTCDate()+6);return k+' — '+e.toISOString().slice(0,10)}function fillPeriods(){let cur=$('#period').value,ks=[...new Set(baseRows().map(r=>pkey(r.date)))].sort().reverse();$('#period').innerHTML=ks.map(k=>`<option value="${k}">${plabel(k)}</option>`).join('');if(ks.includes(cur))$('#period').value=cur}function fillDays(){let cur=$('#day').value,ds=[...new Set(baseRows().map(r=>r.date))].sort().reverse();$('#day').innerHTML=ds.map(opt).join('');if(ds.includes(cur))$('#day').value=cur}
function calc(x){x.ctr=x.impression?x.click/x.impression:null;x.cvr=x.click?x.conversion/x.click:null;x.ivr=x.impression?x.conversion/x.impression:null;x.cac=x.conversion?x.spend/x.conversion:null;x.ecpi=x.cac;return x}function agg(rows,keys){let m=new Map;rows.forEach(r=>{let id=keys.map(k=>r[k]).join('|'),x=m.get(id);if(!x){x={};keys.forEach(k=>x[k]=r[k]);Object.assign(x,{impression:0,click:0,conversion:0,spend:0});m.set(id,x)}metrics().slice(0,4).forEach(([k])=>x[k]+=r[k])});return [...m.values()].map(calc)}function total(rows){return calc(rows.reduce((a,r)=>{metrics().slice(0,4).forEach(([k])=>a[k]+=r[k]);return a},{impression:0,click:0,conversion:0,spend:0}))}
function render(){let rows=baseRows(),pk=$('#period').value,pr=rows.filter(r=>pkey(r.date)===pk),campaign=agg(pr,['pkg','adping_id','group','geo','platform']),sum=total(pr);$('#period-desc').textContent=(pk?plabel(pk):'当前筛选无数据')+' · '+campaign.length+' 个 campaign';$('#kpis').innerHTML=[['Impression',fmt.int(sum.impression),'曝光量'],['Click',fmt.int(sum.click),'CTR '+fmt.pct(sum.ctr)],['Conversion',fmt.int(sum.conversion),'CVR '+fmt.pct(sum.cvr)],['Spend',fmt.money(sum.spend),'总消耗'],['CAC / eCPI',fmt.money(sum.cac),'IVR '+fmt.pct(sum.ivr)]].map(x=>`<article class="card"><div class="label">${x[0]}</div><div class="value">${x[1]}</div><div class="hint">${x[2]}</div></article>`).join('');renderCompare(pr);renderRows($('#period-body'),campaign);renderDaily();renderPkg(rows)}function renderCompare(rows){let map=Object.fromEntries(agg(rows,['group']).map(x=>[x.group,x]));$('#compare').innerHTML=['实验组','对照组'].map((g,i)=>{let x=map[g]||total([]);return `<article class="panel group-card ${i?'control':''}"><h3><span class="badge ${i?'ctl':'exp'}">${g}</span></h3><div class="mini">${[['CTR',fmt.pct(x.ctr)],['CVR',fmt.pct(x.cvr)],['IVR',fmt.pct(x.ivr)],['CAC',fmt.money(x.cac)],['eCPI',fmt.money(x.ecpi)]].map(v=>`<span>${v[0]}<b>${v[1]}</b></span>`).join('')}</div></article>`}).join('')}function renderDaily(){let d=$('#day').value,items=agg(baseRows().filter(r=>r.date===d),['date','pkg','adping_id','group','geo','platform']);$('#daily-desc').textContent=(d||'当前筛选无数据')+' · '+items.length+' 个 campaign';renderRows($('#daily-body'),items)}function renderPkg(rows){let items=agg(rows,['pkg']).sort((a,b)=>b.spend-a.spend);$('#pkg-desc').textContent=items.length+' 个 pkg · 当前日期与维度筛选汇总';$('#pkg-body').innerHTML=items.map(x=>`<tr><td class="pkg">${esc(x.pkg)}</td>${cells(x)}</tr>`).join('')||empty(10)}
function renderRows(el,rows){rows.sort((a,b)=>{let x=a[sort.key],y=b[sort.key];if(x==null)x=-Infinity;if(y==null)y=-Infinity;return typeof x==='string'?x.localeCompare(y)*sort.dir:(x-y)*sort.dir});el.innerHTML=rows.map(x=>`<tr>${x.date?`<td>${x.date}</td>`:''}<td class="pkg">${esc(x.pkg)}</td><td>${esc(x.adping_id)}</td><td><span class="badge ${x.group==='实验组'?'exp':'ctl'}">${x.group}</span></td><td>${esc(x.geo||'—')}</td><td>${esc(x.platform||'—')}</td>${cells(x)}</tr>`).join('')||empty(rows.some(x=>x.date)?15:14)}function cells(x){return `<td>${fmt.int(x.impression)}</td><td>${fmt.int(x.click)}</td><td>${fmt.int(x.conversion)}</td><td>${fmt.money(x.spend)}</td><td>${fmt.pct(x.ctr)}</td><td>${fmt.pct(x.cvr)}</td><td>${fmt.pct(x.ivr)}</td><td>${fmt.money(x.cac)}</td><td>${fmt.money(x.ecpi)}</td>`}function empty(n){return `<tr><td colspan="${n}" class="empty">当前筛选无数据</td></tr>`}function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}
</script></body></html>