292 lines
18 KiB
HTML
292 lines
18 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>King · Campaign 表现</title>
|
||
<style>
|
||
:root{--bg:#f4f7fb;--card:#fff;--text:#14213d;--muted:#6b7280;--line:#e5eaf1;--blue:#5577ee;--green:#23a673;--pink:#e4568b;--amber:#f59e0b}
|
||
*{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:1420px;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}
|
||
.scope{display:flex;align-items:center;gap:10px;margin-top:16px;font-size:13px;color:var(--muted);flex-wrap:wrap}
|
||
select,input{border:1px solid var(--line);background:#fff;padding:8px 11px;border-radius:9px;color:var(--text);font:inherit}
|
||
.grid{display:grid;grid-template-columns:repeat(5,1fr);gap:13px;margin:13px 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:620px;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;cursor:pointer;user-select:none}
|
||
th:first-child,td:first-child,th:nth-child(2),td:nth-child(2){text-align:left}
|
||
td.name{max-width:340px;overflow:hidden;text-overflow:ellipsis}
|
||
.total-row{background:#f8fafc;font-weight:800}
|
||
.bar{position:relative;display:block;height:16px;border-radius:4px;background:#eef1f7;min-width:70px}
|
||
.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}
|
||
.over>i{background:var(--pink)}
|
||
.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>King · Campaign 表现</h1><p id="period">正在加载数据…</p></div><div class="updated" id="updated">数据加载中</div></header>
|
||
|
||
<div class="scope">
|
||
<label>Campaign <select id="scope"></select></label>
|
||
<label>App <select id="appFilter"></select></label>
|
||
<label>搜索 <input id="search" type="search" placeholder="Campaign 关键词" autocomplete="off"></label>
|
||
<span id="scopeHint"></span>
|
||
</div>
|
||
<div class="grid" id="kpis"></div>
|
||
|
||
<section class="panel" id="perfPanel" hidden>
|
||
<div class="panel-head"><div class="panel-title">Target 完成情况</div><span class="hint" id="perfHint"></span></div>
|
||
<div class="table-wrap"><table>
|
||
<thead><tr><th>指标</th><th>Target(整月)</th><th>实际(至昨天)</th><th>完成率</th><th>对比时间进度</th></tr></thead>
|
||
<tbody id="perfRows"></tbody></table></div>
|
||
<div class="legend" id="perfNote"></div>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<div class="panel-head"><div class="panel-title">Install 与 Cost 趋势</div></div>
|
||
<div class="chart-wrap"><svg id="mainChart" class="chart" viewBox="0 0 1120 380" role="img" aria-label="Install 与 Cost 趋势图"></svg><div class="tip" id="mainTip"></div></div>
|
||
<div class="legend"><span><i class="dot" style="background:var(--blue)"></i>Install(左轴,实线)</span><span><i class="dot" style="background:var(--green)"></i>Cost(右轴 USD,虚线)</span></div>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<div class="panel-head"><div class="panel-title">Campaign 汇总</div><span class="hint" id="tableHint"></span></div>
|
||
<div class="table-wrap"><table>
|
||
<thead><tr>
|
||
<th data-sort="app">App</th><th data-sort="campaign">Campaign</th>
|
||
<th data-sort="installs">Install</th><th data-sort="cost">Cost</th><th data-sort="cpi">CPI</th>
|
||
<th data-sort="budget">Budget</th><th data-sort="ffr_percent">FFR</th>
|
||
</tr></thead>
|
||
<tbody id="summaryRows"></tbody></table></div>
|
||
</section>
|
||
|
||
<div class="footer">数据来源:Google Sheets · Breakdown_cost【day】 / Breakdown_install / forPrebill</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,sortKey='cost',sortDesc=true;
|
||
|
||
function records(){return Array.isArray(report.records)?report.records:[]}
|
||
function summary(){return Array.isArray(report.summary)?report.summary:[]}
|
||
function dates(){const period=report.period||{},out=[];
|
||
if(!period.start||!ok(period.days))return [...new Set(records().map(r=>r.date))].sort();
|
||
const start=new Date(period.start+'T00:00:00Z');
|
||
for(let i=0;i<Number(period.days);i++){const d=new Date(start.getTime()+i*86400000);out.push(d.toISOString().slice(0,10))}
|
||
return out}
|
||
|
||
/* 表格筛选:App + 关键词 */
|
||
function visibleRows(){
|
||
const app=$('appFilter').value,keyword=$('search').value.trim().toLowerCase();
|
||
return summary().filter(row=>(app==='ALL'||row.app===app)
|
||
&&(!keyword||String(row.campaign||'').toLowerCase().includes(keyword)));
|
||
}
|
||
|
||
function scopeRecords(){
|
||
const scope=$('scope').value;
|
||
if(scope==='TOTAL'){const allowed=new Set(visibleRows().map(r=>r.campaign));
|
||
return records().filter(r=>allowed.has(r.campaign))}
|
||
return records().filter(r=>r.campaign===scope);
|
||
}
|
||
|
||
function series(){
|
||
const rows=scopeRecords();
|
||
return dates().map(date=>{const day=rows.filter(r=>r.date===date);
|
||
return {date,installs:day.reduce((s,r)=>s+n(r.installs),0),cost:day.reduce((s,r)=>s+n(r.cost),0)}});
|
||
}
|
||
|
||
function kpi(){
|
||
const scope=$('scope').value;
|
||
if(scope!=='TOTAL'){
|
||
const row=summary().find(r=>r.campaign===scope)||{};
|
||
return {installs:n(row.installs),cost:n(row.cost),budget:ok(row.budget)?Number(row.budget):null,
|
||
ffr:ok(row.ffr_percent)?Number(row.ffr_percent):null,cpi:ok(row.cpi)?Number(row.cpi):null,label:scope};
|
||
}
|
||
const rows=visibleRows(),stored=(report.kpis&&report.kpis.TOTAL)||{},
|
||
filtered=rows.length!==summary().length,
|
||
installs=rows.reduce((s,r)=>s+n(r.installs),0),
|
||
cost=rows.reduce((s,r)=>s+n(r.cost),0),
|
||
budgets=rows.map(r=>r.budget).filter(ok),
|
||
budget=budgets.length?budgets.reduce((a,b)=>a+Number(b),0):null;
|
||
if(!filtered&&ok(stored.cost))
|
||
return {installs:n(stored.installs),cost:n(stored.cost),budget:ok(stored.budget)?Number(stored.budget):budget,
|
||
ffr:ok(stored.ffr_percent)?Number(stored.ffr_percent):null,cpi:ok(stored.cpi)?Number(stored.cpi):null,
|
||
label:`全部 ${rows.length} 个 Campaign`};
|
||
return {installs,cost,budget,ffr:budget?cost/budget*100:null,cpi:installs?cost/installs:null,
|
||
label:`${rows.length} 个 Campaign${filtered?'(已筛选)':''}`};
|
||
}
|
||
|
||
function px(i,count,m,iw){return count>1?m.l+i*iw/(count-1):m.l+iw/2}
|
||
|
||
function drawMain(){
|
||
const svg=$('mainChart'),rows=series();
|
||
if(!rows.length){svg.innerHTML='<text x="560" y="180" text-anchor="middle" fill="#8190a5" font-size="14">暂无趋势数据</text>';return}
|
||
const W=1120,H=380,m={l:70,r:78,t:22,b:50},iw=W-m.l-m.r,ih=H-m.t-m.b,
|
||
maxI=Math.max(1,...rows.map(r=>r.installs))*1.12,
|
||
maxC=Math.max(1,...rows.map(r=>r.cost))*1.12,
|
||
x=i=>px(i,rows.length,m,iw),
|
||
yI=v=>m.t+ih-v/maxI*ih,
|
||
yC=v=>m.t+ih-v/maxC*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(maxI*ratio)}</text>`;
|
||
h+=`<text x="${W-m.r+8}" y="${y+4}" fill="#23a673" font-size="11">${num(maxC*ratio)}</text>`}
|
||
rows.forEach((row,i)=>{if(i%2===0||i===rows.length-1)
|
||
h+=`<text x="${x(i)}" y="${H-18}" text-anchor="middle" fill="#8190a5" font-size="11">${esc(row.date.slice(5))}</text>`});
|
||
h+=`<polyline points="${rows.map((r,i)=>`${x(i)},${yI(r.installs)}`).join(' ')}" fill="none" stroke="#5577ee" stroke-width="3"/>`;
|
||
h+=`<polyline points="${rows.map((r,i)=>`${x(i)},${yC(r.cost)}`).join(' ')}" fill="none" stroke="#23a673" stroke-width="2.5" stroke-dasharray="8 5"/>`;
|
||
rows.forEach((r,i)=>{
|
||
h+=`<circle cx="${x(i)}" cy="${yC(r.cost)}" r="4.4" fill="#fff" stroke="#23a673" stroke-width="2"/>`;
|
||
h+=`<circle cx="${x(i)}" cy="${yI(r.installs)}" 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=$('mainTip'),label=kpi().label;
|
||
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)} · ${esc(label)}<br>Install:${num(row.installs)}<br>Cost:${usd(row.cost)}`;
|
||
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 renderKpis(){
|
||
const k=kpi(),period=report.period||{};
|
||
$('scopeHint').textContent=`${k.label} · ${period.start||'—'} 至 ${period.end||'—'}`;
|
||
$('kpis').innerHTML=[
|
||
['Install',num(k.installs),k.label],
|
||
['Cost',usd(k.cost),'USD'],
|
||
['CPI',ok(k.cpi)?'$'+num(k.cpi,3):'—','Cost ÷ Install'],
|
||
['Budget',usd(k.budget),ok(k.budget)?'当月预算':'预算表无对应值'],
|
||
['FFR',pct(k.ffr),ok(k.ffr)?'Cost ÷ Budget':'缺预算无法计算']
|
||
].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('');
|
||
}
|
||
|
||
/* 预算表命名与投放表不一致时为近似匹配,用 ~ 标注并在悬停说明 */
|
||
function budgetCell(row){
|
||
if(!ok(row.budget))return '<span title="预算表中未找到对应 Campaign">—</span>';
|
||
const approx=row.budget_match&&row.budget_match!=='exact';
|
||
return approx
|
||
? `<span title="近似匹配:${esc(row.budget_match)}(预算表命名与投放表不一致)" style="color:#b45309">~${usd(row.budget)}</span>`
|
||
: usd(row.budget);
|
||
}
|
||
|
||
function ffrBar(value){
|
||
if(!ok(value))return '—';
|
||
const width=Math.min(100,Math.max(0,Number(value)));
|
||
return `<span class="bar${Number(value)>100?' over':''}"><i style="width:${width}%"></i><b>${num(value,2)}%</b></span>`;
|
||
}
|
||
|
||
function renderTable(){
|
||
const rows=visibleRows().slice().sort((a,b)=>{
|
||
const x=a[sortKey],y=b[sortKey];
|
||
if(typeof x==='string'||typeof y==='string')
|
||
return String(x||'').localeCompare(String(y||''))*(sortDesc?-1:1);
|
||
const dx=ok(x)?Number(x):-Infinity,dy=ok(y)?Number(y):-Infinity;
|
||
return (dx-dy)*(sortDesc?-1:1)});
|
||
const totals=rows.reduce((acc,row)=>{acc.installs+=n(row.installs);acc.cost+=n(row.cost);
|
||
if(ok(row.budget))acc.budget+=Number(row.budget);return acc},{installs:0,cost:0,budget:0});
|
||
$('summaryRows').innerHTML=rows.map(row=>`<tr>
|
||
<td>${esc(row.app||'—')}</td><td class="name" title="${esc(row.campaign)}">${esc(row.campaign)}</td>
|
||
<td>${num(row.installs)}</td><td>${usd(row.cost)}</td><td>${ok(row.cpi)?'$'+num(row.cpi,3):'—'}</td>
|
||
<td>${budgetCell(row)}</td><td>${ffrBar(row.ffr_percent)}</td></tr>`).join('')
|
||
+`<tr class="total-row"><td>Total</td><td>${rows.length} 个 Campaign</td>
|
||
<td>${num(totals.installs)}</td><td>${usd(totals.cost)}</td>
|
||
<td>${totals.installs?'$'+num(totals.cost/totals.installs,3):'—'}</td>
|
||
<td>${totals.budget?usd(totals.budget):'—'}</td>
|
||
<td>${totals.budget?ffrBar(totals.cost/totals.budget*100):'—'}</td></tr>`;
|
||
$('tableHint').textContent=`点击表头排序 · 当前 ${sortKey} ${sortDesc?'降序':'升序'}`;
|
||
}
|
||
|
||
/* Target 完成情况:Revenue 取日报 Cost,Cost 取 Sands,GP = Revenue - Cost */
|
||
function renderPerformance(){
|
||
const perf=report.performance;
|
||
if(!perf){$('perfPanel').hidden=true;return}
|
||
$('perfPanel').hidden=false;
|
||
const actual=perf.actual||{},target=perf.target||{},done=perf.completion||{},
|
||
progress=ok(perf.time_progress_percent)?Number(perf.time_progress_percent):null;
|
||
const gap=value=>{
|
||
if(!ok(value)||progress===null)return '—';
|
||
const diff=Number(value)-progress;
|
||
const color=diff>=0?'#23a673':'#e4568b';
|
||
return `<span style="color:${color}">${diff>=0?'+':''}${num(diff,2)} pt</span>`;
|
||
};
|
||
/* 比率不做完成率,只看与目标的百分点差距 */
|
||
const ratePoints=(actualRate,targetRate)=>{
|
||
if(!ok(actualRate)||!ok(targetRate))return '—';
|
||
const diff=Number(actualRate)-Number(targetRate);
|
||
const color=diff>=0?'#23a673':'#e4568b';
|
||
return `<span style="color:${color}" title="实际 GP rate 与目标的差距">${diff>=0?'+':''}${num(diff,2)} pt</span>`;
|
||
};
|
||
const rows=[
|
||
['Revenue',usd(target.revenue),usd(actual.revenue),pct(done.revenue),gap(done.revenue)],
|
||
['GP',usd(target.gp),usd(actual.gp),pct(done.gp),gap(done.gp)],
|
||
['GP rate',pct(target.gp_rate),pct(actual.gp_rate),'—',ratePoints(actual.gp_rate,target.gp_rate)]
|
||
];
|
||
$('perfRows').innerHTML=rows.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('')
|
||
+`<tr class="total-row"><td>Cost(Sands)</td><td>—</td><td>${usd(actual.cost)}</td><td>—</td><td>—</td></tr>`;
|
||
$('perfHint').textContent=progress===null?''
|
||
:`时间进度 ${num(progress,2)}%(${num(perf.elapsed_days)}/${num(perf.month_days)} 天)`;
|
||
const sands=perf.sands||{};
|
||
const notes=[];
|
||
notes.push('Revenue = 本报表 Cost 合计(客户口径)');
|
||
notes.push('GP = Revenue − Sands Cost');
|
||
if(!ok(actual.cost))notes.push('Sands Cost 缺失:需在本地抓取后再发布');
|
||
else{
|
||
if(sands.fetched_at)notes.push(`Sands Cost 抓取于 ${esc(String(sands.fetched_at).replace('T',' '))},不随每日自动更新`);
|
||
if(sands.scope)notes.push(`Cost 范围:${esc(sands.scope)}`);
|
||
if(sands.carried_over)notes.push('<b style="color:#b45309">Cost 沿用上次抓取值,需本地重新抓取才会更新</b>');
|
||
if(Array.isArray(sands.missing_pkgs)&&sands.missing_pkgs.length)
|
||
notes.push(`<b style="color:#b42318">Cost 不完整,缺少包:${esc(sands.missing_pkgs.join(', '))}</b>`);
|
||
}
|
||
if(!ok(target.revenue))notes.push('Target 未取到:请确认 Monthly FFR 表访问权限');
|
||
$('perfNote').innerHTML=notes.map(item=>`<span>${item}</span>`).join('');
|
||
}
|
||
|
||
function render(){
|
||
const period=report.period||{},rows=summary();
|
||
$('period').textContent=`${period.start||'—'} 至 ${period.end||'—'}${ok(period.days)?` · ${num(period.days)} 天`:''}`;
|
||
$('updated').innerHTML=`最近更新:${esc(String(report.generated_at||'—').replace('T',' '))}<br>时区:${esc(report.timezone||'—')}`;
|
||
$('scope').innerHTML='<option value="TOTAL">Total(全部 Campaign)</option>'
|
||
+rows.map(row=>`<option value="${esc(row.campaign)}">${esc(row.campaign)}</option>`).join('');
|
||
const apps=[...new Set(rows.map(row=>row.app).filter(Boolean))].sort();
|
||
$('appFilter').innerHTML='<option value="ALL">全部 App</option>'+apps.map(app=>`<option>${esc(app)}</option>`).join('');
|
||
renderPerformance();renderKpis();renderTable();drawMain();
|
||
}
|
||
|
||
$('scope').onchange=()=>{renderKpis();drawMain()};
|
||
$('appFilter').onchange=()=>{renderKpis();renderTable();drawMain()};
|
||
$('search').oninput=()=>{renderKpis();renderTable();drawMain()};
|
||
document.querySelectorAll('th[data-sort]').forEach(th=>th.onclick=()=>{
|
||
const key=th.dataset.sort;
|
||
if(key===sortKey)sortDesc=!sortDesc;else{sortKey=key;sortDesc=true}
|
||
renderTable()});
|
||
window.addEventListener('resize',()=>{if(report)drawMain()});
|
||
|
||
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>
|