67 lines
16 KiB
HTML
67 lines
16 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:#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(9,1fr);gap:12px;margin-bottom:16px}.card{padding:16px;min-width:0}.card .label{color:var(--muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.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>Manta标签外投测试</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>KPI Event</th><th class="num">Event</th><th class="num">Spend</th><th class="num">CAC</th><th class="num">Impression</th><th class="num">Click</th><th class="num">Conversions</th><th class="num">CTR</th><th class="num">CVR</th><th class="num">CPI</th></tr></thead><tbody id="body"></tbody></table></div></section>
|
||
<div class="foot">数据源:Tableau · DSP Daily Data。订单状态以 Campaign info 的 G 列为准,KPI Event 以 I 列为准;I 列为“-”时不追踪事件质量。实验组通过 Campaign name 中的 adping ID 识别;同一 pkg、GEO、Platform 下,其余 Campaign 合并为对照组并显示为 General campaign。CAC = Spend / KPI Event,CPI = Spend / Install;比率均使用汇总后的分子与分母重新计算。</div>
|
||
</main><script>
|
||
const $=id=>document.getElementById(id); let all=[],campaignConfig=[],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)))}
|
||
const tracksEvent=r=>{const k=String(r.kpi||'').trim();return k&&k!=='-'};
|
||
function latestKpi(r){const matches=campaignConfig.filter(c=>String(c.status).toLowerCase()==='active'&&c.pkg===r.pkg&&String(c.channel).toLowerCase()===r.source&&String(c.geo).toUpperCase()===String(r.geo).toUpperCase());if(r.group==='实验组'){const hit=matches.find(c=>c.group==='实验组'&&String(c.adping_id)===String(r.adpingId));return hit?String(hit.kpi||'-').trim()||'-':r.kpi}const names=[...new Set(matches.filter(c=>c.group==='对照组').map(c=>String(c.kpi||'-').trim()||'-'))];return names.length===1?names[0]:r.kpi}
|
||
function eventLabel(rows){const names=[...new Set(rows.filter(tracksEvent).map(r=>String(r.kpi).trim()))];return names.length?names.join(' / '):'KPI Event'}
|
||
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,event:0,eventSpend:0,eventKnown:false,kpi:r.kpi||''});const o=m.get(k);for(const x of ['impression','click','conversions','spend'])o[x]+=+r[x]||0;if(r.event!==null&&r.event!==undefined&&r.event!==''){o.event+=+r.event||0;o.eventSpend+=+r.spend||0;o.eventKnown=true}}return [...m.values()]}
|
||
function cards(rows){const t=aggregate(rows,[])[0]||{impression:0,click:0,conversions:0,spend:0},e=aggregate(rows.filter(tracksEvent),[])[0]||{event:0,eventKnown:false,eventSpend:0},eventName=eventLabel(rows);const vals=[['Spend',money(t.spend)], [eventName,e.eventKnown?n(e.event):'—'],['CAC',e.eventKnown&&e.event?money(e.eventSpend/e.event):'—'],['Conversions',n(t.conversions)],['CPI',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" title="${x[0]}">${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','kpi']).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),required=tracksEvent(r);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>${r.kpi||'-'}</td><td class="num">${required&&r.eventKnown?n(r.event):'—'}</td><td class="num">${money(r.spend)}</td><td class="num">${required&&r.eventKnown&&r.event?money(r.eventSpend/r.event):'—'}</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">${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="13" 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'));
|
||
Promise.all([fetch('data.json',{cache:'no-store'}),fetch('campaign_config.json',{cache:'no-store'})]).then(async([dataResponse,configResponse])=>{if(!dataResponse.ok||!configResponse.ok)throw new Error('source load failed');return [await dataResponse.json(),await configResponse.json()]}).then(([d,config])=>{campaignConfig=config||[];all=(d.rows||[]).map(r=>({...r,kpi:latestKpi(r)}));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="13" class="empty">无法读取 data.json 或 campaign_config.json</td></tr>'});
|
||
</script></body></html>
|