465 lines
20 KiB
HTML
465 lines
20 KiB
HTML
<!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>
|