Files
king-daily-report/index.html
T

284 lines
18 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>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=>({'&':'&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,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 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),pct(done.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(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>