37 lines
12 KiB
HTML
37 lines
12 KiB
HTML
<!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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}function debounce(fn,ms){let t;return()=>{clearTimeout(t);t=setTimeout(fn,ms)}}
|
||
</script>
|
||
</body>
|
||
</html>
|