Files
Manta-tracking/index.html
T

37 lines
12 KiB
HTML
Raw 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">
<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};
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>