Files
Manta-tracking-new/index.html
T

64 lines
14 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>Campaign 表现</title>
<style>
:root{--bg:#f4f6f8;--panel:#fff;--text:#17212b;--muted:#687481;--line:#e4e9ee;--brand:#1769e0;--good:#16825d;--warn:#c46a08}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif}
main{max-width:1500px;margin:auto;padding:28px}.head{display:flex;justify-content:space-between;gap:24px;align-items:flex-end;margin-bottom:20px}
h1{font-size:28px;margin:0 0 4px}.sub,.fresh{color:var(--muted)}.fresh{text-align:right}.panel{background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:0 1px 2px #16202a0a}
.filters{display:grid;grid-template-columns:repeat(8,minmax(125px,1fr));gap:12px;padding:16px;margin-bottom:16px}.field label{display:block;color:var(--muted);font-size:12px;margin-bottom:5px}
select,input{width:100%;height:36px;border:1px solid #cfd7df;border-radius:7px;background:#fff;padding:0 9px;color:var(--text)}
select[multiple]{height:72px;padding:4px 8px}.range-note{font-size:11px;color:var(--muted);margin-top:4px}
.cards{display:grid;grid-template-columns:repeat(7,1fr);gap:12px;margin-bottom:16px}.card{padding:16px}.card .label{color:var(--muted);font-size:12px}.card .value{font-size:23px;font-weight:650;margin-top:5px;font-variant-numeric:tabular-nums}
.grid{margin-bottom:16px}.chart{padding:18px;min-height:340px;position:relative}.title{font-size:16px;font-weight:650;margin-bottom:12px}.chart-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.chart-sub{font-size:12px;color:var(--muted);margin-top:-8px}
svg{width:100%;height:270px;margin-top:8px}.axis{stroke:#d8dfe6}.gridline{stroke:#edf1f4}.spend-line{fill:none;stroke:var(--brand);stroke-width:2.5}.conv-line{fill:none;stroke:var(--good);stroke-width:2.5}.legend{display:flex;gap:8px}.legend button{border:1px solid var(--line);border-radius:16px;background:#fff;color:var(--muted);padding:5px 10px;cursor:pointer}.legend button.off{opacity:.4}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:5px}.axis-label{fill:#74808c;font-size:11px}.axis-title{font-size:11px;font-weight:650}.point{stroke:#fff;stroke-width:2;cursor:crosshair}.hover-line{stroke:#9aa7b3;stroke-dasharray:3 3}.tooltip{position:absolute;display:none;pointer-events:none;background:#17212b;color:#fff;border-radius:7px;padding:8px 10px;font-size:12px;line-height:1.55;box-shadow:0 4px 14px #0003;z-index:2}.num{text-align:right;font-variant-numeric:tabular-nums}
.table-panel{padding:18px}.table-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}.table-note{color:var(--muted);font-size:12px}.table-wrap{overflow:auto;max-height:640px;border:1px solid var(--line);border-radius:8px}
table{width:100%;border-collapse:collapse;white-space:nowrap}th{position:sticky;top:0;background:#f7f9fb;color:#52606d;text-align:left;font-size:12px;z-index:1}th,td{padding:9px 10px;border-bottom:1px solid var(--line)}td.num,th.num{text-align:right}tbody tr:hover{background:#f7faff}.control{color:#536171}.experiment{color:#7b3fc6}.empty{padding:48px;text-align:center;color:var(--muted)}tr.date-start td{border-top:2px solid #cbd5df}td.date-cell{background:#f1f5f9;color:#334155;font-weight:650;vertical-align:top;border-right:1px solid #dce3ea;min-width:108px}
.foot{margin-top:14px;color:var(--muted);font-size:12px}.status{display:inline-block;padding:2px 7px;border-radius:9px;background:#e8f5ef;color:var(--good)}
@media(max-width:1050px){.filters{grid-template-columns:repeat(3,1fr)}.cards{grid-template-columns:repeat(3,1fr)}.grid{grid-template-columns:1fr}}
@media(max-width:640px){main{padding:14px}.head{display:block}.fresh{text-align:left;margin-top:8px}.filters,.cards{grid-template-columns:1fr 1fr}.card .value{font-size:19px}}
</style>
</head>
<body><main>
<div class="head"><div><h1>Campaign 表现</h1><div class="sub">按日期、组别和 Campaign 查看投放效果</div></div><div class="fresh" id="fresh">正在加载数据…</div></div>
<section class="panel filters">
<div class="field"><label>pkg</label><select id="pkg"></select></div>
<div class="field"><label>Month(可多选)</label><select id="month" multiple></select></div>
<div class="field"><label>GEO</label><select id="geo"></select></div>
<div class="field"><label>Platform</label><select id="source"></select></div>
<div class="field"><label>开始日期</label><input id="start" type="date"><div class="range-note" id="rangeNote"></div></div>
<div class="field"><label>结束日期</label><input id="end" type="date"></div>
<div class="field"><label>组别(可多选)</label><select id="group" multiple></select></div>
<div class="field"><label>Campaign / adping ID</label><input id="search" placeholder="搜索"></div>
</section>
<section class="cards" id="cards"></section>
<section class="grid">
<div class="panel chart"><div class="chart-head"><div><div class="title">每日趋势</div><div class="chart-sub">左轴:Spend(USD) · 右轴:Conversions</div></div><div class="legend"><button id="toggleSpend" aria-pressed="true"><i class="dot" style="background:#1769e0"></i>Spend</button><button id="toggleConversions" aria-pressed="true"><i class="dot" style="background:#16825d"></i>Conversions</button></div></div><svg id="trend" viewBox="0 0 900 270"></svg><div class="tooltip" id="trendTip"></div></div>
</section>
<section class="panel table-panel"><div class="table-head"><div class="title" style="margin:0">Detail</div><div class="table-note" id="count"></div></div><div class="table-wrap"><table><thead><tr><th>Date</th><th>组别</th><th>Campaign name</th><th class="num">Impression</th><th class="num">Click</th><th class="num">Conversions</th><th class="num">Spend</th><th class="num">CTR</th><th class="num">CVR</th><th class="num">CPA</th></tr></thead><tbody id="body"></tbody></table></div></section>
<div class="foot">数据源:Tableau · DSP Daily Data。订单状态以 Campaign info 的 G 列为准。实验组通过 Campaign name 中的 adping ID 识别;同一 pkg、GEO、Platform 下,其余 Campaign 合并为对照组并显示为 General campaign。比率均使用汇总后的分子与分母重新计算。</div>
</main><script>
const $=id=>document.getElementById(id); let all=[],lastRows=[],trendVisible={spend:true,conversions:true};
const n=(v,d=0)=>Number(v||0).toLocaleString('en-US',{minimumFractionDigits:d,maximumFractionDigits:d});
const rate=(a,b)=>b?a/b:0, pct=v=>(100*v).toFixed(2)+'%', money=v=>'$'+n(v,2);
function selected(id){return [...$(id).selectedOptions].map(o=>o.value)}
function singleOptions(id,values){const el=$(id),old=el.value,vals=[...new Set(values.filter(Boolean))].sort();el.innerHTML='<option value="">All</option>'+vals.map(v=>`<option>${v}</option>`).join('');el.value=vals.includes(old)?old:''}
function multiOptions(id,values){const el=$(id),old=new Set(selected(id)),vals=[...new Set(values.filter(Boolean))].sort();el.innerHTML=vals.map(v=>`<option value="${v}" ${old.has(v)?'selected':''}>${v}</option>`).join('')}
function anchorRows(){return all.filter(r=>r.status==='Active'&&(!$('pkg').value||r.pkg===$('pkg').value))}
function updateDateBounds(reset=false){const months=selected('month'),rows=anchorRows().filter(r=>!months.length||months.includes(r.date.slice(0,7))),dates=rows.map(r=>r.date).sort();if(!dates.length)return;const min=dates[0],max=dates.at(-1);for(const id of ['start','end']){$(id).min=min;$(id).max=max}if(reset||!$('start').value||$('start').value<min||$('start').value>max)$('start').value=min;if(reset||!$('end').value||$('end').value<min||$('end').value>max)$('end').value=max;$('rangeNote').textContent=`可选 ${min} 至 ${max}`}
function updateAnchoredFilters(resetDates=false){const rows=anchorRows();multiOptions('month',rows.map(r=>r.date.slice(0,7)));singleOptions('geo',rows.map(r=>r.geo));singleOptions('source',rows.map(r=>r.source));multiOptions('group',rows.map(r=>r.group));updateDateBounds(resetDates)}
function filtered(){const q=$('search').value.trim().toLowerCase(),months=selected('month'),groups=selected('group');return all.filter(r=>r.status==='Active'&&(!$('pkg').value||r.pkg===$('pkg').value)&&(!months.length||months.includes(r.date.slice(0,7)))&&(!$('geo').value||r.geo===$('geo').value)&&(!$('source').value||r.source===$('source').value)&&(!groups.length||groups.includes(r.group))&&(!$('start').value||r.date>=$('start').value)&&(!$('end').value||r.date<=$('end').value)&&(!q||(`${r.campaign} ${r.adpingId}`).toLowerCase().includes(q)))}
function aggregate(rows,keys){const m=new Map;for(const r of rows){const k=keys.map(x=>r[x]).join('\u0001');if(!m.has(k))m.set(k,{...Object.fromEntries(keys.map(x=>[x,r[x]])),impression:0,click:0,conversions:0,spend:0,kpi:r.kpi||''});const o=m.get(k);for(const x of ['impression','click','conversions','spend'])o[x]+=+r[x]||0}return [...m.values()]}
function cards(rows){const t=aggregate(rows,[])[0]||{impression:0,click:0,conversions:0,spend:0};const vals=[['Spend',money(t.spend)],['Conversions',n(t.conversions)],['CPA',t.conversions?money(t.spend/t.conversions):'—'],['Impression',n(t.impression)],['Click',n(t.click)],['CTR',pct(rate(t.click,t.impression))],['CVR',pct(rate(t.conversions,t.click))]];$('cards').innerHTML=vals.map(x=>`<div class="panel card"><div class="label">${x[0]}</div><div class="value">${x[1]}</div></div>`).join('')}
function compact(v,prefix=''){return prefix+Intl.NumberFormat('en-US',{notation:'compact',maximumFractionDigits:1}).format(v)}
function trend(rows){const d=aggregate(rows,['date']).sort((a,b)=>a.date.localeCompare(b.date)),svg=$('trend'),tip=$('trendTip');if(!d.length){svg.innerHTML='<text x="450" y="135" text-anchor="middle" class="axis-label">当前筛选没有数据</text>';return}const W=900,H=270,L=62,R=66,T=20,B=38,maxS=Math.max(...d.map(x=>x.spend),1),maxC=Math.max(...d.map(x=>x.conversions),1),x=i=>L+(W-L-R)*(d.length===1?.5:i/(d.length-1)),y=(v,max)=>T+(H-T-B)*(1-v/max),path=(key,max)=>d.map((r,i)=>(i?'L':'M')+x(i).toFixed(1)+' '+y(r[key],max).toFixed(1)).join(' ');let parts=[];for(let i=0;i<=4;i++){const yy=T+(H-T-B)*i/4,ratio=1-i/4;parts.push(`<line class="gridline" x1="${L}" y1="${yy}" x2="${W-R}" y2="${yy}"/><text class="axis-label" x="${L-8}" y="${yy+4}" text-anchor="end">${compact(maxS*ratio,'$')}</text><text class="axis-label" x="${W-R+8}" y="${yy+4}">${compact(maxC*ratio)}</text>`)}const labelStep=Math.max(1,Math.ceil(d.length/8));parts.push(...d.map((r,i)=>(i%labelStep===0||i===d.length-1)?`<text class="axis-label" x="${x(i)}" y="${H-12}" text-anchor="middle">${r.date.slice(5)}</text>`:''));parts.push(`<text class="axis-title" x="${L}" y="11" fill="#1769e0">Spend (USD)</text><text class="axis-title" x="${W-R}" y="11" text-anchor="end" fill="#16825d">Conversions</text>`);if(trendVisible.spend)parts.push(`<path class="spend-line" d="${path('spend',maxS)}"/>`);if(trendVisible.conversions)parts.push(`<path class="conv-line" d="${path('conversions',maxC)}"/>`);parts.push(...d.map((r,i)=>`<g class="trend-hit" data-index="${i}"><circle cx="${x(i)}" cy="${trendVisible.spend?y(r.spend,maxS):y(r.conversions,maxC)}" r="12" fill="transparent"/><circle class="point" cx="${x(i)}" cy="${trendVisible.spend?y(r.spend,maxS):y(r.conversions,maxC)}" r="3.5" fill="${trendVisible.spend?'#1769e0':'#16825d'}"/></g>`));svg.innerHTML=parts.join('');svg.querySelectorAll('.trend-hit').forEach(el=>{el.addEventListener('mouseenter',()=>{const r=d[+el.dataset.index];tip.innerHTML=`<strong>${r.date}</strong><br>Spend:${money(r.spend)}<br>Conversions:${n(r.conversions)}`;tip.style.display='block'});el.addEventListener('mousemove',e=>{const box=svg.getBoundingClientRect(),panel=svg.parentElement.getBoundingClientRect();tip.style.left=Math.min(e.clientX-panel.left+12,panel.width-tip.offsetWidth-12)+'px';tip.style.top=(e.clientY-panel.top-tip.offsetHeight-10)+'px'});el.addEventListener('mouseleave',()=>tip.style.display='none')})}
function table(rows){const d=aggregate(rows,['date','group','campaign']).sort((a,b)=>b.date.localeCompare(a.date)||(a.group==='对照组'?-1:1)-(b.group==='对照组'?-1:1)||b.spend-a.spend);$('count').textContent=`${n(d.length)} 行`;const limited=d.slice(0,2000),dateCounts={};for(const r of limited)dateCounts[r.date]=(dateCounts[r.date]||0)+1;const seen=new Set;$('body').innerHTML=limited.map(r=>{const first=!seen.has(r.date);seen.add(r.date);return`<tr class="${first?'date-start':''}">${first?`<td class="date-cell" rowspan="${dateCounts[r.date]}">${r.date}</td>`:''}<td class="${r.group==='对照组'?'control':'experiment'}">${r.group}</td><td>${r.campaign}</td><td class="num">${n(r.impression)}</td><td class="num">${n(r.click)}</td><td class="num">${n(r.conversions)}</td><td class="num">${money(r.spend)}</td><td class="num">${pct(rate(r.click,r.impression))}</td><td class="num">${pct(rate(r.conversions,r.click))}</td><td class="num">${r.conversions?money(r.spend/r.conversions):'—'}</td></tr>`}).join('')||'<tr><td colspan="10" class="empty">当前筛选没有数据</td></tr>'}
function render(){lastRows=filtered();cards(lastRows);trend(lastRows);table(lastRows)}
function toggleTrend(key,id){trendVisible[key]=!trendVisible[key];if(!trendVisible.spend&&!trendVisible.conversions)trendVisible[key]=true;$(id).classList.toggle('off',!trendVisible[key]);$(id).setAttribute('aria-pressed',trendVisible[key]);trend(lastRows)}
$('toggleSpend').addEventListener('click',()=>toggleTrend('spend','toggleSpend'));$('toggleConversions').addEventListener('click',()=>toggleTrend('conversions','toggleConversions'));
fetch('data.json',{cache:'no-store'}).then(r=>r.ok?r.json():Promise.reject()).then(d=>{all=d.rows||[];singleOptions('pkg',all.filter(r=>r.status==='Active').map(r=>r.pkg));if([...$('pkg').options].some(o=>o.value==='com.amazon.mShop.android.shopping'))$('pkg').value='com.amazon.mShop.android.shopping';updateAnchoredFilters(true);$('fresh').innerHTML=`数据截至 <strong>${d.latestDate||'—'}</strong><br>更新于 ${new Date(d.generatedAt).toLocaleString('zh-CN',{hour12:false})}`;$('pkg').addEventListener('change',()=>{updateAnchoredFilters(true);render()});$('month').addEventListener('change',()=>{updateDateBounds(true);render()});for(const id of ['geo','source','start','end','group'])$(id).addEventListener('change',render);$('search').addEventListener('input',render);render()}).catch(()=>{$('fresh').textContent='数据加载失败';$('body').innerHTML='<tr><td colspan="10" class="empty">无法读取 data.json</td></tr>'});
</script></body></html>