Files
top10-ffr/index.html
T
2026-08-31 14:19:00 +08:00

274 lines
16 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">
<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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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&&current!=='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>