Files
moboos-ad-report/index.html
T

767 lines
26 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: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
color: #2d3748;
}
.container {
max-width: 1440px;
margin: 0 auto;
padding: 30px 24px;
}
.header {
text-align: center;
padding: 40px 0 35px;
}
.header h1 {
font-size: 28px;
font-weight: 700;
color: #fff;
letter-spacing: -0.5px;
margin-bottom: 8px;
text-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header p {
color: rgba(255,255,255,0.8);
font-size: 14px;
}
/* Summary Cards */
.summary-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.summary-card {
background: #fff;
border-radius: 16px;
padding: 20px 24px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
transition: transform 0.2s, box-shadow 0.2s;
}
.summary-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 30px rgba(0,0,0,0.12);
}
.summary-card .label {
font-size: 12px;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
.summary-card .value {
font-size: 26px;
font-weight: 700;
color: #1e293b;
margin-top: 6px;
}
.summary-card .sub {
font-size: 12px;
color: #64748b;
margin-top: 4px;
}
/* Controls */
.controls {
background: #fff;
border-radius: 16px;
padding: 16px 24px;
margin-bottom: 24px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
display: flex;
gap: 20px;
flex-wrap: wrap;
align-items: center;
}
.control-group {
display: flex;
align-items: center;
gap: 8px;
}
.controls label {
font-size: 13px;
color: #64748b;
font-weight: 500;
}
.controls select {
padding: 8px 14px;
border: 1.5px solid #e2e8f0;
border-radius: 10px;
font-size: 13px;
background: #f8fafc;
cursor: pointer;
color: #334155;
font-weight: 500;
transition: border-color 0.2s, box-shadow 0.2s;
outline: none;
}
.controls select:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102,126,234,0.15);
}
/* Charts */
.charts-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 24px;
}
.chart-card {
background: #fff;
border-radius: 16px;
padding: 24px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
transition: transform 0.2s, box-shadow 0.2s;
}
.chart-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 30px rgba(0,0,0,0.12);
}
.chart-card h3 {
font-size: 14px;
color: #475569;
margin-bottom: 16px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.chart-card h3::before {
content: '';
display: inline-block;
width: 4px;
height: 16px;
border-radius: 2px;
background: linear-gradient(135deg, #667eea, #764ba2);
}
.chart-card canvas {
width: 100% !important;
height: 240px !important;
}
/* Table */
.table-section {
background: #fff;
border-radius: 16px;
padding: 24px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
overflow: hidden;
}
.table-section h2 {
font-size: 16px;
color: #1e293b;
margin-bottom: 16px;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
}
.table-section h2::before {
content: '';
display: inline-block;
width: 4px;
height: 18px;
border-radius: 2px;
background: linear-gradient(135deg, #667eea, #764ba2);
}
.table-wrapper {
overflow-x: auto;
border-radius: 12px;
border: 1px solid #e2e8f0;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
thead {
background: linear-gradient(135deg, #f8fafc, #f1f5f9);
}
th {
padding: 12px 14px;
text-align: left;
font-weight: 600;
color: #475569;
white-space: nowrap;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.3px;
border-bottom: 2px solid #e2e8f0;
}
td {
padding: 11px 14px;
border-bottom: 1px solid #f1f5f9;
white-space: nowrap;
color: #334155;
}
tr:last-child td {
border-bottom: none;
}
tr:hover td {
background: #f8fafc;
}
.pkg-tag {
display: inline-block;
padding: 3px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 500;
background: linear-gradient(135deg, #eef2ff, #e0e7ff);
color: #4338ca;
border: 1px solid #c7d2fe;
}
.num-cell {
font-variant-numeric: tabular-nums;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 12px;
}
.updated-time {
text-align: center;
color: rgba(255,255,255,0.7);
font-size: 12px;
margin-top: 24px;
padding-bottom: 20px;
}
/* Tabs */
.tabs {
display: flex;
gap: 8px;
margin-bottom: 24px;
background: rgba(255,255,255,0.15);
padding: 6px;
border-radius: 14px;
backdrop-filter: blur(10px);
width: fit-content;
margin-left: auto;
margin-right: auto;
}
.tab-btn {
padding: 10px 28px;
border: none;
background: transparent;
color: rgba(255,255,255,0.85);
font-size: 14px;
font-weight: 600;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
}
.tab-btn.active {
background: #fff;
color: #667eea;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* Order status badges */
.status-badge {
display: inline-block;
padding: 3px 12px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
}
.status-active {
background: #dcfce7;
color: #16a34a;
border: 1px solid #86efac;
}
.status-pause {
background: #fef3c7;
color: #d97706;
border: 1px solid #fcd34d;
}
.pay-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
background: #f1f5f9;
color: #475569;
}
.channel-chip {
display: inline-block;
padding: 2px 8px;
margin: 2px;
border-radius: 6px;
font-size: 10px;
background: #eef2ff;
color: #4338ca;
border: 1px solid #c7d2fe;
}
.no-channel {
color: #cbd5e1;
font-size: 12px;
}
.order-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
@media (max-width: 900px) {
.charts-grid { grid-template-columns: 1fr; }
.summary-row { grid-template-columns: repeat(2, 1fr); }
.container { padding: 16px; }
.header h1 { font-size: 22px; }
}
@media (max-width: 500px) {
.summary-row { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Moboost 广告投放数据报告</h1>
<p>数据概览与趋势分析</p>
</div>
<div class="tabs">
<button class="tab-btn active" data-tab="performance">投放数据</button>
<button class="tab-btn" data-tab="orders">订单信息</button>
</div>
<div class="tab-panel active" id="tab-performance">
<div class="summary-row" id="summaryRow"></div>
<div class="controls">
<div class="control-group">
<label>广告主</label>
<select id="pkgFilter">
<option value="all">全部</option>
</select>
</div>
<div class="control-group">
<label>日期范围</label>
<select id="dateFilter">
<option value="all">全部日期</option>
</select>
</div>
</div>
<div class="charts-grid">
<div class="chart-card">
<h3>展示量趋势</h3>
<canvas id="chartImpressions"></canvas>
</div>
<div class="chart-card">
<h3>点击量趋势</h3>
<canvas id="chartClicks"></canvas>
</div>
<div class="chart-card">
<h3>转化量趋势</h3>
<canvas id="chartConversions"></canvas>
</div>
<div class="chart-card">
<h3>eCPM 趋势</h3>
<canvas id="chartEcpm"></canvas>
</div>
</div>
<div class="table-section">
<h2>明细数据</h2>
<div class="table-wrapper">
<table id="dataTable">
<thead>
<tr>
<th>日期</th>
<th>广告单元</th>
<th>Pkg</th>
<th>展示</th>
<th>点击</th>
<th>转化</th>
<th>eCPM</th>
<th>CPC</th>
<th>CTR</th>
<th>IVR</th>
<th>CVR</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div><!-- /tab-performance -->
<div class="tab-panel" id="tab-orders">
<div class="order-stats" id="orderStats"></div>
<!-- 模块1:产品订单基础信息 -->
<div class="table-section" style="margin-bottom:24px;">
<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>
<!-- 模块2:渠道投放矩阵 -->
<div class="table-section">
<h2>渠道投放矩阵(渠道 × 产品)</h2>
<p style="font-size:12px;color:#94a3b8;margin:-8px 0 14px;">
<span class="status-badge status-active" style="padding:1px 8px;">Active</span> 投放中 
<span class="status-badge status-pause" style="padding:1px 8px;">Pause</span> 投过已停 
空白 = 未投放
</p>
<div class="table-wrapper">
<table id="matrixTable">
<thead></thead>
<tbody></tbody>
</table>
</div>
</div>
</div><!-- /tab-orders -->
<div class="updated-time" id="updatedTime"></div>
</div>
<script>
const COLORS = [
'#667eea','#f56565','#48bb78','#ed8936','#9f7aea',
'#38b2ac','#e53e3e','#4299e1','#d69e2e','#ed64a6'
];
let rawData = [];
let orderData = [];
let charts = {};
async function loadData() {
try {
const [dResp, oResp] = await Promise.all([
fetch('data.json?' + Date.now()),
fetch('orders.json?' + Date.now())
]);
rawData = await dResp.json();
orderData = await oResp.json();
init();
} catch(e) {
console.error('Failed to load data:', e);
}
}
function init() {
populateFilters();
renderAll();
renderOrders();
setupTabs();
document.getElementById('pkgFilter').addEventListener('change', renderAll);
document.getElementById('dateFilter').addEventListener('change', renderAll);
document.getElementById('updatedTime').textContent = '最后刷新:' + new Date().toLocaleString('zh-CN');
}
function setupTabs() {
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');
});
});
}
function renderOrders() {
// Stats
const total = orderData.length;
const active = orderData.filter(o => o.status === 'Active').length;
const products = new Set(orderData.map(o => o.product)).size;
const allChannels = new Set();
orderData.forEach(o => {
(o.agency_channels || []).forEach(c => allChannels.add(c));
(o.self_channels || []).forEach(c => allChannels.add(c));
});
document.getElementById('orderStats').innerHTML = `
<div class="summary-card">
<div class="label">订单条目</div>
<div class="value">${total}</div>
<div class="sub">${products} 个独立产品</div>
</div>
<div class="summary-card">
<div class="label">在投中 (Active)</div>
<div class="value" style="color:#16a34a">${active}</div>
<div class="sub">${total - active} 个 Pause</div>
</div>
<div class="summary-card">
<div class="label">已铺投放渠道</div>
<div class="value">${allChannels.size}</div>
<div class="sub">媒体/代投渠道</div>
</div>
<div class="summary-card">
<div class="label">MMP 归因</div>
<div class="value" style="font-size:20px">AppsFlyer</div>
<div class="sub">全线统一</div>
</div>
`;
// Table
const tbody = document.querySelector('#orderTable tbody');
tbody.innerHTML = orderData.map(o => {
const statusClass = o.status === 'Active' ? 'status-active' : 'status-pause';
return `
<tr>
<td style="font-weight:600">${o.product}</td>
<td class="num-cell" style="font-size:11px">${o.pkg}</td>
<td>${o.platform}</td>
<td>${o.geo}</td>
<td><span class="pay-badge">${o.pay_mode}</span></td>
<td class="num-cell">${o.price}</td>
<td class="num-cell">${o.budget}</td>
<td><span class="status-badge ${statusClass}">${o.status}</span></td>
<td>${o.mmp}</td>
</tr>
`;
}).join('');
renderMatrix();
}
function renderMatrix() {
// 列 = 产品(按 pkg 区分,同名产品用地区区分显示);行 = 渠道
// 每个订单条目是一列,代投+自投渠道合并
const cols = orderData.map(o => ({
key: o.pkg,
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);
});
});
// 表头
const thead = document.querySelector('#matrixTable thead');
thead.innerHTML = `
<tr>
<th style="position:sticky;left:0;background:#f1f5f9;z-index:2;">渠道 \\ 产品</th>
${cols.map(c => `<th style="text-align:center;min-width:90px;">
<div>${c.label}</div>
<div style="font-weight:400;font-size:10px;color:#94a3b8;">${c.sub}</div>
</th>`).join('')}
</tr>
`;
// 单元格生成
const cell = (col, channel) => {
if (!col.channels.has(channel)) return '<td></td>';
const cls = col.status === 'Active' ? 'status-active' : 'status-pause';
return `<td style="text-align:center;"><span class="status-badge ${cls}" style="padding:2px 8px;font-size:10px;">${col.status}</span></td>`;
};
const tbody = document.querySelector('#matrixTable tbody');
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('');
}
function populateFilters() {
const pkgs = [...new Set(rawData.map(d => d.pkg))];
const sel = document.getElementById('pkgFilter');
pkgs.forEach(p => {
const opt = document.createElement('option');
opt.value = p;
opt.textContent = p.split('.').pop();
sel.appendChild(opt);
});
const dates = [...new Set(rawData.map(d => d.date))].sort();
const dateSel = document.getElementById('dateFilter');
dates.forEach(d => {
const opt = document.createElement('option');
opt.value = d;
opt.textContent = d.slice(5);
dateSel.appendChild(opt);
});
}
function getFilteredData() {
let data = rawData;
const pkg = document.getElementById('pkgFilter').value;
const date = document.getElementById('dateFilter').value;
if (pkg !== 'all') data = data.filter(d => d.pkg === pkg);
if (date !== 'all') data = data.filter(d => d.date === date);
return data;
}
function renderAll() {
const data = getFilteredData();
renderSummary(data);
renderCharts();
renderTable(data);
}
function renderSummary(data) {
const totalImp = data.reduce((s, d) => s + d.impressions, 0);
const totalClk = data.reduce((s, d) => s + d.clicks, 0);
const totalConv = data.reduce((s, d) => s + d.conversions, 0);
const avgEcpm = data.length ? (data.reduce((s, d) => s + d.ecpm, 0) / data.length) : 0;
const avgCtr = data.length ? (data.reduce((s, d) => s + d.ctr, 0) / data.length) : 0;
document.getElementById('summaryRow').innerHTML = `
<div class="summary-card">
<div class="label">总展示</div>
<div class="value">${totalImp.toLocaleString()}</div>
<div class="sub">${data.length} 条记录</div>
</div>
<div class="summary-card">
<div class="label">总点击</div>
<div class="value">${totalClk.toLocaleString()}</div>
<div class="sub">整体 CTR ${(totalClk/Math.max(totalImp,1)*100).toFixed(2)}%</div>
</div>
<div class="summary-card">
<div class="label">总转化</div>
<div class="value">${totalConv.toLocaleString()}</div>
<div class="sub">整体 CVR ${(totalConv/Math.max(totalClk,1)*100).toFixed(2)}%</div>
</div>
<div class="summary-card">
<div class="label">平均 eCPM</div>
<div class="value">${avgEcpm.toFixed(3)}</div>
<div class="sub">均值</div>
</div>
<div class="summary-card">
<div class="label">平均 CTR</div>
<div class="value">${(avgCtr*100).toFixed(2)}%</div>
<div class="sub">均值</div>
</div>
`;
}
function renderCharts() {
const pkg = document.getElementById('pkgFilter').value;
const chartData = pkg === 'all' ? rawData : rawData.filter(d => d.pkg === pkg);
const dates = [...new Set(chartData.map(d => d.date))].sort();
const pkgs = [...new Set(chartData.map(d => d.pkg))];
renderChart('chartImpressions', dates, pkgs, chartData, 'impressions');
renderChart('chartClicks', dates, pkgs, chartData, 'clicks');
renderChart('chartConversions', dates, pkgs, chartData, 'conversions');
renderChart('chartEcpm', dates, pkgs, chartData, 'ecpm');
}
function renderChart(canvasId, dates, pkgs, data, metric) {
if (charts[canvasId]) charts[canvasId].destroy();
const datasets = pkgs.map((pkg, i) => {
const values = dates.map(date => {
const rows = data.filter(d => d.date === date && d.pkg === pkg);
if (rows.length === 0) return null;
return rows.reduce((sum, r) => sum + (r[metric] || 0), 0);
});
return {
label: pkg.split('.').pop(),
data: values,
borderColor: COLORS[i % COLORS.length],
backgroundColor: COLORS[i % COLORS.length] + '15',
borderWidth: 2.5,
pointRadius: 4,
pointBackgroundColor: '#fff',
pointBorderWidth: 2,
pointHoverRadius: 6,
tension: 0.4,
fill: true
};
});
const ctx = document.getElementById(canvasId).getContext('2d');
charts[canvasId] = new Chart(ctx, {
type: 'line',
data: { labels: dates.map(d => d.slice(5)), datasets },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: {
position: 'bottom',
labels: { boxWidth: 10, padding: 15, font: { size: 11 }, usePointStyle: true }
},
tooltip: {
backgroundColor: 'rgba(30,41,59,0.95)',
titleFont: { size: 12 },
bodyFont: { size: 12 },
padding: 12,
cornerRadius: 8,
displayColors: true
}
},
scales: {
x: {
grid: { display: false },
ticks: { font: { size: 11 }, color: '#94a3b8' }
},
y: {
beginAtZero: true,
grid: { color: '#f1f5f9', drawBorder: false },
ticks: { font: { size: 11 }, color: '#94a3b8' }
}
}
}
});
}
function renderTable(data) {
const sorted = [...data].sort((a, b) => b.date.localeCompare(a.date) || a.pkg.localeCompare(b.pkg));
const tbody = document.querySelector('#dataTable tbody');
tbody.innerHTML = sorted.map(d => `
<tr>
<td>${d.date.slice(5)}</td>
<td style="font-weight:500">${d.ad_unit}</td>
<td><span class="pkg-tag">${d.pkg.split('.').pop()}</span></td>
<td class="num-cell">${d.impressions.toLocaleString()}</td>
<td class="num-cell">${d.clicks.toLocaleString()}</td>
<td class="num-cell">${d.conversions}</td>
<td class="num-cell">${d.ecpm.toFixed(3)}</td>
<td class="num-cell">${d.cpc.toFixed(3)}</td>
<td class="num-cell">${(d.ctr * 100).toFixed(2)}%</td>
<td class="num-cell">${(d.ivr * 100).toFixed(3)}%</td>
<td class="num-cell">${(d.cvr * 100).toFixed(2)}%</td>
</tr>
`).join('');
}
loadData();
</script>
</body>
</html>