274 lines
16 KiB
HTML
274 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">
|
||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate"><meta http-equiv="Pragma" content="no-cache"><meta http-equiv="Expires" content="0">
|
||
<title>Top10 KA · FFR 与 GP</title>
|
||
<style>
|
||
:root{--bg:#f4f7fb;--card:#fff;--text:#14213d;--muted:#6b7280;--line:#e5eaf1;--blue:#5577ee;--green:#23a673;--pink:#e4568b;--amber:#b45309}
|
||
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:Inter,"Microsoft YaHei",system-ui,sans-serif}
|
||
.shell{max-width:1440px;margin:auto;padding:26px}
|
||
.hero{display:flex;justify-content:space-between;gap:18px;align-items:end;padding:24px 27px;border-radius:20px;color:#fff;background:linear-gradient(125deg,#111827,#26355e)}
|
||
h1{margin:0 0 6px;font-size:27px}.hero p,.updated{margin:0;color:#cbd5e1;font-size:12px;line-height:1.7}.updated{text-align:right}
|
||
.tabs{display:flex;gap:8px;flex-wrap:wrap;margin:16px 0 0}
|
||
.tab{border:1px solid var(--line);background:#fff;color:var(--text);padding:9px 15px;border-radius:10px;font:inherit;cursor:pointer;white-space:nowrap}
|
||
.tab.active{background:var(--blue);border-color:var(--blue);color:#fff;font-weight:700}
|
||
.tab.empty{color:#9aa4b2}
|
||
.grid{display:grid;grid-template-columns:repeat(5,1fr);gap:13px;margin:14px 0}
|
||
.card,.panel{background:var(--card);border:1px solid var(--line);border-radius:15px;box-shadow:0 8px 28px rgba(24,39,75,.05)}
|
||
.card{padding:17px}.label{font-size:12px;color:var(--muted)}.value{font-size:24px;font-weight:800;margin-top:6px}.hint{font-size:11px;color:#94a3b8;margin-top:5px}
|
||
.panel{padding:18px;margin-top:14px}.panel-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap}.panel-title{font-size:17px;font-weight:800}
|
||
.chart-wrap{position:relative;overflow:auto}.chart{display:block;width:100%;min-width:780px;height:auto}
|
||
.tip{position:absolute;pointer-events:none;opacity:0;transform:translate(-50%,-100%);background:rgba(17,24,39,.94);color:#fff;font-size:12px;line-height:1.6;padding:8px 11px;border-radius:9px;white-space:nowrap;z-index:5;transition:opacity .12s}
|
||
.legend{text-align:center;color:var(--muted);font-size:12px;margin-top:7px}.legend span{margin:0 9px;white-space:nowrap}
|
||
.dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:5px}
|
||
.table-wrap{overflow:auto;max-height:560px;border:1px solid var(--line);border-radius:10px}
|
||
table{width:100%;border-collapse:separate;border-spacing:0;font-size:12px}
|
||
th,td{padding:9px 11px;text-align:right;white-space:nowrap;border-bottom:1px solid #edf0f5}
|
||
th{position:sticky;top:0;background:#f8fafc;color:#64748b;z-index:1}
|
||
th:first-child,td:first-child{text-align:left}
|
||
tr.link{cursor:pointer}tr.link:hover{background:#f8fafc}
|
||
.total-row{background:#f8fafc;font-weight:800}
|
||
.pos{color:var(--green)}.neg{color:var(--pink)}.warn{color:var(--amber)}
|
||
.bar{position:relative;display:block;height:16px;border-radius:4px;background:#eef1f7;min-width:78px}
|
||
.bar>i{position:absolute;inset:0 auto 0 0;border-radius:4px;background:var(--green)}
|
||
.bar>b{position:relative;display:block;text-align:right;padding-right:4px;font-size:11px;line-height:16px}
|
||
.bar.over>i{background:var(--blue)}.bar.low>i{background:var(--pink)}
|
||
.notes{font-size:11px;color:#94a3b8;margin-top:8px;line-height:1.8}
|
||
.error{margin:40px auto;max-width:700px;text-align:center;padding:44px;color:#b42318}
|
||
.footer{text-align:center;color:#94a3b8;font-size:11px;margin:18px}
|
||
@media(max-width:1100px){.grid{grid-template-columns:repeat(3,1fr)}}
|
||
@media(max-width:640px){.shell{padding:12px}.hero{display:block}.updated{text-align:left;margin-top:12px}.grid{grid-template-columns:1fr 1fr;gap:9px}.card{padding:13px}.value{font-size:19px}.panel{padding:13px}}
|
||
</style></head><body><main class="shell">
|
||
<header class="hero"><div><h1>Top10 KA · FFR 与 GP</h1><p id="period">正在加载数据…</p></div><div class="updated" id="updated">数据加载中</div></header>
|
||
<div class="tabs" id="tabs"></div>
|
||
<div id="view"></div>
|
||
<div class="footer">Revenue 来自各 KA 日报表客户口径消耗 · Cost 来自 Sands · GP = Revenue − Cost</div>
|
||
</main>
|
||
<script>
|
||
'use strict';
|
||
const $=id=>document.getElementById(id),
|
||
esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])),
|
||
ok=v=>v!==null&&v!==undefined&&v!==''&&Number.isFinite(Number(v)),
|
||
n=v=>ok(v)?Number(v):0,
|
||
num=(v,d=0)=>n(v).toLocaleString('en-US',{minimumFractionDigits:d,maximumFractionDigits:d}),
|
||
usd=v=>ok(v)?'$'+num(v,2):'—',
|
||
pct=v=>ok(v)?num(v,2)+'%':'—';
|
||
let report=null,current='overview';
|
||
|
||
function progress(){const p=report.period||{};return ok(p.time_progress_percent)?Number(p.time_progress_percent):null}
|
||
|
||
/* 完成率与时间进度的差距:正数超前,负数落后 */
|
||
function gapCell(value){
|
||
const p=progress();
|
||
if(!ok(value)||p===null)return '—';
|
||
const diff=Number(value)-p;
|
||
return `<span class="${diff>=0?'pos':'neg'}">${diff>=0?'+':''}${num(diff,2)} pt</span>`;
|
||
}
|
||
|
||
function bar(value){
|
||
if(!ok(value))return '—';
|
||
const v=Number(value),width=Math.min(100,Math.max(0,v)),p=progress();
|
||
const cls=v>=100?'over':(p!==null&&v<p?'low':'');
|
||
return `<span class="bar ${cls}"><i style="width:${width}%"></i><b>${num(v,2)}%</b></span>`;
|
||
}
|
||
|
||
function accounts(){return Array.isArray(report.accounts)?report.accounts:[]}
|
||
function findAccount(key){return accounts().find(item=>item.key===key)}
|
||
function hasData(item){return n(item.actual&&item.actual.revenue)!==0||ok(item.actual&&item.actual.cost)}
|
||
|
||
function renderTabs(){
|
||
const items=[{key:'overview',name:'总览'}].concat(accounts().map(item=>({
|
||
key:item.key,name:item.name,empty:!hasData(item)
|
||
})));
|
||
$('tabs').innerHTML=items.map(item=>
|
||
`<button class="tab${item.key===current?' active':''}${item.empty?' empty':''}" data-key="${item.key}">${esc(item.name)}</button>`
|
||
).join('');
|
||
document.querySelectorAll('.tab').forEach(button=>button.onclick=()=>{
|
||
current=button.dataset.key;renderTabs();renderView();window.scrollTo({top:0,behavior:'smooth'});
|
||
});
|
||
}
|
||
|
||
function overview(){
|
||
const totals=report.totals||{},rows=accounts();
|
||
const cards=[
|
||
['Revenue',usd(totals.revenue),'全部 KA 合计'],
|
||
['Cost',usd(totals.cost),'Sands 口径'],
|
||
['GP',usd(totals.gp),'Revenue − Cost'],
|
||
['GP rate',pct(totals.gp_rate),'GP ÷ Revenue'],
|
||
['Target Revenue',usd(totals.target_revenue),`完成 ${pct((totals.completion||{}).revenue)}`]
|
||
];
|
||
const body=rows.map(item=>{
|
||
const a=item.actual||{},t=item.target||{},c=item.completion||{};
|
||
const missing=Array.isArray(item.missing_pkgs)&&item.missing_pkgs.length;
|
||
return `<tr class="link" data-key="${item.key}">
|
||
<td>${esc(item.name)}${item.note?` <span class="hint">(${esc(item.note)})</span>`:''}</td>
|
||
<td>${usd(a.revenue)}</td>
|
||
<td>${ok(a.cost)?usd(a.cost):`<span class="warn" title="Sands 缺少包:${esc((item.missing_pkgs||[]).join(', '))}">待补</span>`}</td>
|
||
<td>${ok(a.gp)?`<span class="${n(a.gp)>=0?'pos':'neg'}">${usd(a.gp)}</span>`:'—'}</td>
|
||
<td>${pct(a.gp_rate)}</td>
|
||
<td>${usd(t.revenue)}</td>
|
||
<td>${bar(c.revenue)}</td>
|
||
<td>${gapCell(c.revenue)}</td>
|
||
<td>${usd(t.gp)}</td>
|
||
<td>${bar(c.gp)}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
return `<div class="grid">${cards.map(item=>
|
||
`<div class="card"><div class="label">${item[0]}</div><div class="value">${item[1]}</div><div class="hint">${esc(item[2])}</div></div>`
|
||
).join('')}</div>
|
||
<section class="panel"><div class="panel-head"><div class="panel-title">各 KA 对比</div>
|
||
<span class="hint">点击任意行进入该 KA 的明细</span></div>
|
||
<div class="table-wrap"><table><thead><tr>
|
||
<th>KA</th><th>Revenue</th><th>Cost</th><th>GP</th><th>GP rate</th>
|
||
<th>Target Rev</th><th>Rev 完成率</th><th>对比时间进度</th><th>Target GP</th><th>GP 完成率</th>
|
||
</tr></thead><tbody>${body}
|
||
<tr class="total-row"><td>合计</td><td>${usd(totals.revenue)}</td><td>${usd(totals.cost)}</td>
|
||
<td>${usd(totals.gp)}</td><td>${pct(totals.gp_rate)}</td><td>${usd(totals.target_revenue)}</td>
|
||
<td>${bar((totals.completion||{}).revenue)}</td><td>${gapCell((totals.completion||{}).revenue)}</td>
|
||
<td>${usd(totals.target_gp)}</td><td>${bar((totals.completion||{}).gp)}</td></tr>
|
||
</tbody></table></div>
|
||
<div class="notes">Cost 显示「待补」表示 Sands 抓取结果中没有该 KA 的包,GP 无法计算,不做估算。</div>
|
||
</section>`;
|
||
}
|
||
|
||
function accountView(item){
|
||
const a=item.actual||{},t=item.target||{},c=item.completion||{};
|
||
const showImpression=n(a.impressions)>0;
|
||
const cards=[
|
||
['Revenue',usd(a.revenue),'客户口径消耗'],
|
||
['Cost',ok(a.cost)?usd(a.cost):'待补',ok(a.cost)?'Sands 口径':'Sands 缺少该 KA 的包'],
|
||
['GP',ok(a.gp)?usd(a.gp):'—','Revenue − Cost'],
|
||
['GP rate',pct(a.gp_rate),'GP ÷ Revenue'],
|
||
[showImpression?'Impression':'Install',showImpression?num(a.impressions):num(a.installs),
|
||
showImpression?'CPM 单,单独统计':'安装量']
|
||
];
|
||
if(showImpression&&n(a.installs)>0)cards.push(['Install',num(a.installs),'安装量']);
|
||
|
||
const targetRows=[
|
||
['Revenue',usd(t.revenue),usd(a.revenue),bar(c.revenue),gapCell(c.revenue)],
|
||
['GP',usd(t.gp),ok(a.gp)?usd(a.gp):'—',bar(c.gp),gapCell(c.gp)],
|
||
['GP rate',pct(t.gp_rate),pct(a.gp_rate),'—',ratePoints(a.gp_rate,t.gp_rate)]
|
||
];
|
||
|
||
const campaigns=(item.campaigns||[]).map(row=>`<tr>
|
||
<td>${esc(row.campaign)}</td>
|
||
<td>${row.metric==='impression'?'—':num(row.installs)}</td>
|
||
<td>${row.metric==='impression'?num(row.impressions):'—'}</td>
|
||
<td>${usd(row.revenue)}</td>
|
||
<td>${ok(row.ecpi)?'$'+num(row.ecpi,3):(ok(row.ecpm)?'$'+num(row.ecpm,2)+' eCPM':'—')}</td>
|
||
</tr>`).join('');
|
||
|
||
return `<div class="grid">${cards.map(card=>
|
||
`<div class="card"><div class="label">${card[0]}</div><div class="value">${card[1]}</div><div class="hint">${esc(card[2])}</div></div>`
|
||
).join('')}</div>
|
||
|
||
<section class="panel"><div class="panel-head"><div class="panel-title">Target 完成情况</div>
|
||
<span class="hint">${item.data_cutoff?`数据截止 ${esc(item.data_cutoff)}`:''}</span></div>
|
||
<div class="table-wrap"><table><thead><tr>
|
||
<th>指标</th><th>Target(整月)</th><th>实际(至昨天)</th><th>完成率</th><th>对比时间进度</th>
|
||
</tr></thead><tbody>${targetRows.map(row=>
|
||
`<tr><td>${row[0]}</td><td>${row[1]}</td><td>${row[2]}</td><td>${row[3]}</td><td>${row[4]}</td></tr>`
|
||
).join('')}</tbody></table></div></section>
|
||
|
||
<section class="panel"><div class="panel-head"><div class="panel-title">Revenue 与量级趋势</div></div>
|
||
<div class="chart-wrap"><svg id="chart" class="chart" viewBox="0 0 1120 360" role="img" aria-label="趋势图"></svg><div class="tip" id="tip"></div></div>
|
||
<div class="legend"><span><i class="dot" style="background:var(--blue)"></i>${showImpression?'Impression':'Install'}(左轴,实线)</span>
|
||
<span><i class="dot" style="background:var(--green)"></i>Revenue(右轴 USD,虚线)</span></div></section>
|
||
|
||
<section class="panel"><div class="panel-head"><div class="panel-title">Campaign 明细</div></div>
|
||
<div class="table-wrap"><table><thead><tr>
|
||
<th>Campaign</th><th>Install</th><th>Impression</th><th>Revenue</th><th>eCPI / eCPM</th>
|
||
</tr></thead><tbody>${campaigns||'<tr><td colspan="5">暂无数据</td></tr>'}</tbody></table></div>
|
||
${item.note?`<div class="notes">${esc(item.note)}</div>`:''}
|
||
</section>`;
|
||
}
|
||
|
||
function ratePoints(actual,target){
|
||
if(!ok(actual)||!ok(target))return '—';
|
||
const diff=Number(actual)-Number(target);
|
||
return `<span class="${diff>=0?'pos':'neg'}">${diff>=0?'+':''}${num(diff,2)} pt</span>`;
|
||
}
|
||
|
||
function series(item){
|
||
const map={};
|
||
(item.records||[]).forEach(row=>{
|
||
const entry=map[row.date]||(map[row.date]={date:row.date,volume:0,revenue:0});
|
||
entry.volume+=n(row.installs)+n(row.impressions);
|
||
entry.revenue+=n(row.revenue);
|
||
});
|
||
return Object.values(map).sort((a,b)=>a.date.localeCompare(b.date));
|
||
}
|
||
|
||
function px(i,count,m,iw){return count>1?m.l+i*iw/(count-1):m.l+iw/2}
|
||
|
||
function drawChart(item){
|
||
const svg=$('chart');if(!svg)return;
|
||
const rows=series(item);
|
||
if(!rows.length){svg.innerHTML='<text x="560" y="170" text-anchor="middle" fill="#8190a5" font-size="14">暂无趋势数据</text>';return}
|
||
const W=1120,H=360,m={l:72,r:80,t:22,b:48},iw=W-m.l-m.r,ih=H-m.t-m.b,
|
||
maxV=Math.max(1,...rows.map(r=>r.volume))*1.12,
|
||
maxR=Math.max(1,...rows.map(r=>r.revenue))*1.12,
|
||
x=i=>px(i,rows.length,m,iw),
|
||
yV=v=>m.t+ih-v/maxV*ih,
|
||
yR=v=>m.t+ih-v/maxR*ih;
|
||
let h='';
|
||
for(let i=0;i<=5;i++){const y=m.t+ih*i/5,ratio=1-i/5;
|
||
h+=`<line x1="${m.l}" y1="${y}" x2="${W-m.r}" y2="${y}" stroke="#e8edf5"/>`;
|
||
h+=`<text x="${m.l-9}" y="${y+4}" text-anchor="end" fill="#8190a5" font-size="11">${num(maxV*ratio)}</text>`;
|
||
h+=`<text x="${W-m.r+8}" y="${y+4}" fill="#23a673" font-size="11">${num(maxR*ratio)}</text>`}
|
||
rows.forEach((row,i)=>{if(i%2===0||i===rows.length-1)
|
||
h+=`<text x="${x(i)}" y="${H-16}" text-anchor="middle" fill="#8190a5" font-size="11">${esc(row.date.slice(5))}</text>`});
|
||
h+=`<polyline points="${rows.map((r,i)=>`${x(i)},${yV(r.volume)}`).join(' ')}" fill="none" stroke="#5577ee" stroke-width="3"/>`;
|
||
h+=`<polyline points="${rows.map((r,i)=>`${x(i)},${yR(r.revenue)}`).join(' ')}" fill="none" stroke="#23a673" stroke-width="2.5" stroke-dasharray="8 5"/>`;
|
||
rows.forEach((r,i)=>{
|
||
h+=`<circle cx="${x(i)}" cy="${yR(r.revenue)}" r="4.2" fill="#fff" stroke="#23a673" stroke-width="2"/>`;
|
||
h+=`<circle cx="${x(i)}" cy="${yV(r.volume)}" r="3.2" fill="#5577ee"/>`});
|
||
h+=`<line id="cursor" x1="0" y1="${m.t}" x2="0" y2="${m.t+ih}" stroke="#94a3b8" stroke-dasharray="4 4" opacity="0"/>`;
|
||
h+=`<rect x="${m.l}" y="${m.t}" width="${iw}" height="${ih}" fill="transparent"/>`;
|
||
svg.innerHTML=h;
|
||
const tip=$('tip'),unit=n(item.actual&&item.actual.impressions)>0?'Impression':'Install';
|
||
svg.onmousemove=event=>{
|
||
const box=svg.getBoundingClientRect(),vb=svg.viewBox.baseVal,
|
||
vx=(event.clientX-box.left)/box.width*vb.width;
|
||
let index=0,best=Infinity;
|
||
rows.forEach((row,i)=>{const d=Math.abs(x(i)-vx);if(d<best){best=d;index=i}});
|
||
const row=rows[index],cx=x(index),cursor=svg.querySelector('#cursor');
|
||
if(cursor){cursor.setAttribute('x1',cx);cursor.setAttribute('x2',cx);cursor.setAttribute('opacity','1')}
|
||
tip.innerHTML=`${esc(row.date)}<br>${unit}:${num(row.volume)}<br>Revenue:${usd(row.revenue)}`;
|
||
tip.style.left=(cx/vb.width*box.width)+'px';
|
||
tip.style.top=Math.max(38,event.clientY-box.top-14)+'px';
|
||
tip.style.opacity='1'};
|
||
svg.onmouseleave=()=>{tip.style.opacity='0';const cursor=svg.querySelector('#cursor');if(cursor)cursor.setAttribute('opacity','0')};
|
||
}
|
||
|
||
function renderView(){
|
||
if(current==='overview'){
|
||
$('view').innerHTML=overview();
|
||
document.querySelectorAll('tr.link').forEach(row=>row.onclick=()=>{
|
||
current=row.dataset.key;renderTabs();renderView();window.scrollTo({top:0,behavior:'smooth'});
|
||
});
|
||
return;
|
||
}
|
||
const item=findAccount(current);
|
||
if(!item){$('view').innerHTML='<section class="panel">未找到该 KA</section>';return}
|
||
$('view').innerHTML=accountView(item);
|
||
drawChart(item);
|
||
}
|
||
|
||
function render(){
|
||
const p=report.period||{},sands=report.sands||{};
|
||
$('period').textContent=`${p.start||'—'} 至 ${p.end||'—'} · ${num(p.days)}/${num(p.month_days)} 天 · 时间进度 ${pct(p.time_progress_percent)}`;
|
||
const parts=[`最近更新:${esc(String(report.generated_at||'—').replace('T',' '))}`];
|
||
if(sands.fetched_at)parts.push(`Sands Cost 抓取于 ${esc(String(sands.fetched_at).replace('T',' '))}`);
|
||
if(sands.carried_over)parts.push('Cost 沿用上次抓取值');
|
||
$('updated').innerHTML=parts.join('<br>');
|
||
renderTabs();renderView();
|
||
}
|
||
|
||
window.addEventListener('resize',()=>{if(report&¤t!=='overview')drawChart(findAccount(current))});
|
||
|
||
fetch('data.json',{cache:'no-store'})
|
||
.then(response=>{if(!response.ok)throw new Error(`HTTP ${response.status}`);return response.json()})
|
||
.then(data=>{report=data;render()})
|
||
.catch(error=>{document.querySelector('.shell').innerHTML=`<section class="panel error"><h2>数据加载失败</h2><p>${esc(error.message)}</p><p>请稍后刷新,页面不会使用旧值冒充最新数据。</p></section>`});
|
||
</script></body></html>
|