832 lines
29 KiB
HTML
832 lines
29 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: 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>
|
||
|
||
<!-- 模块3:Campaign ID 明细 -->
|
||
<div class="table-section" style="margin-top:24px;">
|
||
<h2>Campaign ID 明细(唯一识别值)</h2>
|
||
<div class="controls" style="box-shadow:none;padding:0 0 14px;background:transparent;">
|
||
<div class="control-group">
|
||
<label>产品</label>
|
||
<select id="campProductFilter"><option value="all">全部</option></select>
|
||
</div>
|
||
<div class="control-group">
|
||
<label>渠道</label>
|
||
<select id="campChannelFilter"><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><!-- /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 campaignData = [];
|
||
let charts = {};
|
||
|
||
async function loadData() {
|
||
try {
|
||
const [dResp, oResp, cResp] = await Promise.all([
|
||
fetch('data.json?' + Date.now()),
|
||
fetch('orders.json?' + Date.now()),
|
||
fetch('campaigns.json?' + Date.now())
|
||
]);
|
||
rawData = await dResp.json();
|
||
orderData = await oResp.json();
|
||
campaignData = await cResp.json();
|
||
init();
|
||
} catch(e) {
|
||
console.error('Failed to load data:', e);
|
||
}
|
||
}
|
||
|
||
function init() {
|
||
populateFilters();
|
||
renderAll();
|
||
renderOrders();
|
||
renderCampaigns();
|
||
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 renderCampaigns() {
|
||
// 填充筛选器
|
||
const prods = [...new Set(campaignData.map(c => c.product))].sort();
|
||
const chans = [...new Set(campaignData.map(c => c.channel))].sort();
|
||
const pf = document.getElementById('campProductFilter');
|
||
const cf = document.getElementById('campChannelFilter');
|
||
prods.forEach(p => { const o=document.createElement('option'); o.value=p; o.textContent=p; pf.appendChild(o); });
|
||
chans.forEach(c => { const o=document.createElement('option'); o.value=c; o.textContent=c; cf.appendChild(o); });
|
||
pf.addEventListener('change', drawCampaignRows);
|
||
cf.addEventListener('change', drawCampaignRows);
|
||
drawCampaignRows();
|
||
}
|
||
|
||
function drawCampaignRows() {
|
||
const pf = document.getElementById('campProductFilter').value;
|
||
const cf = document.getElementById('campChannelFilter').value;
|
||
let data = campaignData;
|
||
if (pf !== 'all') data = data.filter(c => c.product === pf);
|
||
if (cf !== 'all') data = data.filter(c => c.channel === cf);
|
||
|
||
const tbody = document.querySelector('#campaignTable tbody');
|
||
tbody.innerHTML = data.map(c => `
|
||
<tr>
|
||
<td class="num-cell" style="font-weight:600;color:#4338ca;">${c.campid}</td>
|
||
<td style="font-weight:500">${c.product}</td>
|
||
<td class="num-cell" style="font-size:11px">${c.pkg}</td>
|
||
<td><span class="pkg-tag">${c.geo}</span></td>
|
||
<td>${c.channel}</td>
|
||
</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>
|