Files
moboos-ad-report/index.html
T

465 lines
20 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.0">
<title>Moboost 广告投放报告</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
background: #f7f8fa;
color: #1f2933;
line-height: 1.5;
}
.container { max-width: 1400px; margin: 0 auto; padding: 24px 20px 48px; }
.header { padding: 12px 0 20px; }
.header h1 { font-size: 22px; font-weight: 700; color: #111827; }
.header p { color: #6b7280; font-size: 13px; margin-top: 4px; }
/* Tabs */
.tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid #e5e7eb;
margin-bottom: 24px;
}
.tab-btn {
padding: 10px 20px;
border: none;
background: transparent;
color: #6b7280;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.15s, border-color 0.15s;
}
.tab-btn:hover { color: #111827; }
.tab-btn.active { color: #2563eb; border-bottom-color: #2563eb; font-weight: 600; }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* Summary cards */
.summary-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 16px 18px;
}
.card .label { font-size: 12px; color: #6b7280; font-weight: 500; }
.card .value { font-size: 22px; font-weight: 700; color: #111827; margin-top: 4px; }
.card .sub { font-size: 11px; color: #9ca3af; margin-top: 2px; }
/* Section */
.section {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 20px;
margin-bottom: 20px;
}
.section h2 { font-size: 15px; font-weight: 600; color: #111827; margin-bottom: 14px; }
.section .hint { font-size: 12px; color: #9ca3af; margin: -8px 0 14px; }
/* Controls */
.controls { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; margin-bottom: 14px; }
.control-group { display: flex; align-items: center; gap: 6px; }
.controls label { font-size: 13px; color: #6b7280; }
.controls select {
padding: 6px 10px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 13px;
background: #fff;
color: #374151;
outline: none;
cursor: pointer;
}
.controls select:focus { border-color: #2563eb; }
/* Table */
.table-wrapper { overflow-x: auto; border: 1px solid #e5e7eb; border-radius: 8px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead { background: #f9fafb; }
th {
padding: 10px 12px; text-align: left; font-weight: 600; color: #6b7280;
white-space: nowrap; font-size: 12px; border-bottom: 1px solid #e5e7eb;
}
td { padding: 10px 12px; border-bottom: 1px solid #f3f4f6; white-space: nowrap; color: #374151; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #f9fafb; }
.num { font-variant-numeric: tabular-nums; font-family: 'SF Mono', 'Menlo', monospace; font-size: 12px; }
.tag { display: inline-block; padding: 2px 8px; border-radius: 6px; font-size: 11px; font-weight: 500; background: #eff6ff; color: #2563eb; }
.badge-active { background: #ecfdf5; color: #059669; border: 1px solid #a7f3d0; }
.badge-pause { background: #fffbeb; color: #d97706; border: 1px solid #fde68a; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 6px; font-size: 11px; font-weight: 600; }
.muted { color: #d1d5db; }
.chart-box { background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; padding: 20px; margin-bottom: 20px; }
.chart-box h2 { font-size: 15px; font-weight: 600; margin-bottom: 14px; color: #111827; }
.chart-box canvas { width: 100% !important; height: 280px !important; }
.updated { color: #9ca3af; font-size: 12px; margin-top: 8px; }
.empty { text-align: center; color: #9ca3af; padding: 30px; font-size: 13px; }
@media (max-width: 720px) {
.summary-row { grid-template-columns: repeat(2, 1fr); }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Moboost 广告投放报告</h1>
<p>投放数据 · 订单信息 · Campaign ID 明细</p>
</div>
<div class="tabs">
<button class="tab-btn active" data-tab="performance">投放数据</button>
<button class="tab-btn" data-tab="orders">订单信息</button>
<button class="tab-btn" data-tab="campaigns">Campaign ID 明细</button>
</div>
<!-- ============ Tab 1: 投放数据 ============ -->
<div class="tab-panel active" id="tab-performance">
<div class="summary-row" id="perfSummary"></div>
<div class="section">
<div class="controls">
<div class="control-group">
<label>渠道</label>
<select id="perfChannel"><option value="all">全部</option></select>
</div>
<div class="control-group">
<label>产品</label>
<select id="perfProduct"><option value="all">全部</option></select>
</div>
<div class="control-group">
<label>日期</label>
<select id="perfDate"><option value="all">全部</option></select>
</div>
</div>
<div class="table-wrapper">
<table id="perfTable">
<thead>
<tr>
<th>日期</th>
<th>渠道</th>
<th>产品</th>
<th>Offer Name</th>
<th>国家</th>
<th>展示</th>
<th>点击</th>
<th>转化</th>
<th>CTR</th>
<th>CVR</th>
<th>Spend</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<div class="chart-box">
<h2>各 Campaign 展示量对比</h2>
<canvas id="chartImp"></canvas>
</div>
</div>
<!-- ============ Tab 2: 订单信息 ============ -->
<div class="tab-panel" id="tab-orders">
<div class="summary-row" id="orderSummary"></div>
<div class="section">
<h2>产品订单基础信息</h2>
<div class="table-wrapper">
<table id="orderTable">
<thead>
<tr>
<th>Product</th>
<th>Pkg / 包名</th>
<th>平台</th>
<th>地区</th>
<th>付费类型</th>
<th>单价(USD)</th>
<th>日预算</th>
<th>状态</th>
<th>MMP</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<div class="section">
<h2>渠道投放矩阵(渠道 × 产品)</h2>
<p class="hint">
<span class="badge badge-active">Active</span> 投放中 
<span class="badge badge-pause">Pause</span> 投过已停 
空白 = 未投放
</p>
<div class="table-wrapper">
<table id="matrixTable"><thead></thead><tbody></tbody></table>
</div>
</div>
</div>
<!-- ============ Tab 3: Campaign ID 明细 ============ -->
<div class="tab-panel" id="tab-campaigns">
<div class="section">
<div class="controls">
<div class="control-group">
<label>产品</label>
<select id="campProduct"><option value="all">全部</option></select>
</div>
<div class="control-group">
<label>渠道</label>
<select id="campChannel"><option value="all">全部</option></select>
</div>
</div>
<div class="table-wrapper">
<table id="campaignTable">
<thead>
<tr>
<th>Campaign ID</th>
<th>Product</th>
<th>Pkg / 包名</th>
<th>投放国家</th>
<th>渠道</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
<div class="updated" id="updated"></div>
</div>
<script>
let perfData = [], orderData = [], campaignData = [];
let impChart = null;
async function load() {
try {
const [p, o, c] = await Promise.all([
fetch('mtg_data.json?' + Date.now()).then(r => r.json()).catch(() => []),
fetch('orders.json?' + Date.now()).then(r => r.json()).catch(() => []),
fetch('campaigns.json?' + Date.now()).then(r => r.json()).catch(() => [])
]);
perfData = p; orderData = o; campaignData = c;
initTabs();
renderPerformance();
renderOrders();
renderCampaigns();
document.getElementById('updated').textContent = '最后刷新:' + new Date().toLocaleString('zh-CN');
} catch (e) { console.error(e); }
}
function initTabs() {
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
});
});
}
/* ---------- Tab 1: 投放数据 ---------- */
function renderPerformance() {
// filters
const chans = [...new Set(perfData.map(d => d.channel))];
const prods = [...new Set(perfData.map(d => d.product))];
const dates = [...new Set(perfData.map(d => d.date))].sort();
fillSelect('perfChannel', chans);
fillSelect('perfProduct', prods);
fillSelect('perfDate', dates);
['perfChannel', 'perfProduct', 'perfDate'].forEach(id =>
document.getElementById(id).addEventListener('change', drawPerf));
drawPerf();
}
function getPerfFiltered() {
let d = perfData;
const ch = document.getElementById('perfChannel').value;
const pr = document.getElementById('perfProduct').value;
const dt = document.getElementById('perfDate').value;
if (ch !== 'all') d = d.filter(x => x.channel === ch);
if (pr !== 'all') d = d.filter(x => x.product === pr);
if (dt !== 'all') d = d.filter(x => x.date === dt);
return d;
}
function drawPerf() {
const data = getPerfFiltered();
// summary
const imp = data.reduce((s, d) => s + d.impression, 0);
const clk = data.reduce((s, d) => s + d.clicks, 0);
const conv = data.reduce((s, d) => s + d.conversions, 0);
const spend = data.reduce((s, d) => s + d.spend, 0);
const ctr = imp ? clk / imp : 0;
document.getElementById('perfSummary').innerHTML = `
<div class="card"><div class="label">总展示</div><div class="value">${imp.toLocaleString()}</div><div class="sub">${data.length} 条 campaign</div></div>
<div class="card"><div class="label">总点击</div><div class="value">${clk.toLocaleString()}</div><div class="sub">CTR ${(ctr*100).toFixed(2)}%</div></div>
<div class="card"><div class="label">总转化</div><div class="value">${conv.toLocaleString()}</div><div class="sub">CVR ${(clk?conv/clk*100:0).toFixed(2)}%</div></div>
<div class="card"><div class="label">总花费</div><div class="value">$${spend.toFixed(2)}</div><div class="sub">Spend</div></div>
`;
// table
const tbody = document.querySelector('#perfTable tbody');
if (!data.length) {
tbody.innerHTML = `<tr><td colspan="11" class="empty">暂无数据</td></tr>`;
} else {
const sorted = [...data].sort((a, b) => b.impression - a.impression);
tbody.innerHTML = sorted.map(d => `
<tr>
<td>${d.date}</td>
<td><span class="tag">${d.channel}</span></td>
<td style="font-weight:500">${d.product}</td>
<td class="num" style="font-size:11px">${d.offer_name}</td>
<td>${d.location}</td>
<td class="num">${d.impression.toLocaleString()}</td>
<td class="num">${d.clicks.toLocaleString()}</td>
<td class="num">${d.conversions}</td>
<td class="num">${(d.ctr*100).toFixed(2)}%</td>
<td class="num">${(d.cvr*100).toFixed(2)}%</td>
<td class="num">$${d.spend.toFixed(2)}</td>
</tr>
`).join('');
}
// chart
const sorted = [...data].sort((a, b) => b.impression - a.impression);
const labels = sorted.map(d => d.offer_name);
const vals = sorted.map(d => d.impression);
if (impChart) impChart.destroy();
impChart = new Chart(document.getElementById('chartImp'), {
type: 'bar',
data: {
labels,
datasets: [{ label: '展示量', data: vals, backgroundColor: '#3b82f6', borderRadius: 4 }]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { font: { size: 10 }, color: '#9ca3af' }, grid: { display: false } },
y: { beginAtZero: true, ticks: { color: '#9ca3af' }, grid: { color: '#f3f4f6' } }
}
}
});
}
/* ---------- Tab 2: 订单信息 ---------- */
function renderOrders() {
const total = orderData.length;
const active = orderData.filter(o => o.status === 'Active').length;
const products = new Set(orderData.map(o => o.product)).size;
const chSet = new Set();
orderData.forEach(o => { (o.agency_channels||[]).forEach(c => chSet.add(c)); (o.self_channels||[]).forEach(c => chSet.add(c)); });
document.getElementById('orderSummary').innerHTML = `
<div class="card"><div class="label">订单条目</div><div class="value">${total}</div><div class="sub">${products} 个产品</div></div>
<div class="card"><div class="label">在投 Active</div><div class="value" style="color:#059669">${active}</div><div class="sub">${total-active} 个 Pause</div></div>
<div class="card"><div class="label">投放渠道</div><div class="value">${chSet.size}</div><div class="sub">媒体渠道</div></div>
<div class="card"><div class="label">MMP</div><div class="value" style="font-size:16px">AppsFlyer</div><div class="sub">统一归因</div></div>
`;
const tbody = document.querySelector('#orderTable tbody');
tbody.innerHTML = orderData.map(o => {
const cls = o.status === 'Active' ? 'badge-active' : 'badge-pause';
return `
<tr>
<td style="font-weight:500">${o.product}</td>
<td class="num" style="font-size:11px">${o.pkg}</td>
<td>${o.platform}</td>
<td>${o.geo}</td>
<td><span class="tag">${o.pay_mode}</span></td>
<td class="num">${o.price}</td>
<td class="num">${o.budget}</td>
<td><span class="badge ${cls}">${o.status}</span></td>
<td>${o.mmp}</td>
</tr>`;
}).join('');
renderMatrix();
}
function renderMatrix() {
const cols = orderData.map(o => ({
label: o.product, sub: o.geo, status: o.status,
channels: new Set([...(o.agency_channels||[]), ...(o.self_channels||[])])
}));
const channels = [];
orderData.forEach(o => [...(o.agency_channels||[]), ...(o.self_channels||[])].forEach(c => { if (!channels.includes(c)) channels.push(c); }));
document.querySelector('#matrixTable thead').innerHTML = `
<tr>
<th style="position:sticky;left:0;background:#f9fafb;z-index:2;">渠道 \\ 产品</th>
${cols.map(c => `<th style="text-align:center;min-width:88px;"><div>${c.label}</div><div style="font-weight:400;font-size:10px;color:#9ca3af;">${c.sub}</div></th>`).join('')}
</tr>`;
const cell = (col, ch) => {
if (!col.channels.has(ch)) return '<td></td>';
const cls = col.status === 'Active' ? 'badge-active' : 'badge-pause';
return `<td style="text-align:center;"><span class="badge ${cls}">${col.status}</span></td>`;
};
document.querySelector('#matrixTable tbody').innerHTML = channels.map(ch => `
<tr>
<td style="position:sticky;left:0;background:#fff;font-weight:500;">${ch}</td>
${cols.map(col => cell(col, ch)).join('')}
</tr>`).join('');
}
/* ---------- Tab 3: Campaign ID 明细 ---------- */
function renderCampaigns() {
const prods = [...new Set(campaignData.map(c => c.product))].sort();
const chans = [...new Set(campaignData.map(c => c.channel))].sort();
fillSelect('campProduct', prods);
fillSelect('campChannel', chans);
document.getElementById('campProduct').addEventListener('change', drawCampaigns);
document.getElementById('campChannel').addEventListener('change', drawCampaigns);
drawCampaigns();
}
function drawCampaigns() {
const pf = document.getElementById('campProduct').value;
const cf = document.getElementById('campChannel').value;
let data = campaignData;
if (pf !== 'all') data = data.filter(c => c.product === pf);
if (cf !== 'all') data = data.filter(c => c.channel === cf);
document.querySelector('#campaignTable tbody').innerHTML = data.map(c => `
<tr>
<td class="num" style="font-weight:600;color:#2563eb;">${c.campid}</td>
<td style="font-weight:500">${c.product}</td>
<td class="num" style="font-size:11px">${c.pkg}</td>
<td><span class="tag">${c.geo}</span></td>
<td>${c.channel}</td>
</tr>`).join('');
}
/* ---------- helpers ---------- */
function fillSelect(id, items) {
const sel = document.getElementById(id);
items.forEach(v => { const o = document.createElement('option'); o.value = v; o.textContent = v; sel.appendChild(o); });
}
load();
</script>
</body>
</html>