init: voypace ad report
This commit is contained in:
+694
@@ -0,0 +1,694 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Voypace 广告投放报告</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.2.0/dist/chartjs-plugin-datalabels.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>Voypace 广告投放报告</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="controls" style="background:#fff;border:1px solid #e5e7eb;border-radius:10px;padding:14px 18px;margin-bottom:16px;">
|
||||
<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="perfCountry"><option value="all">全部</option></select>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>时间范围</label>
|
||||
<select id="perfRange">
|
||||
<option value="all">全部</option>
|
||||
<option value="7">近 7 天</option>
|
||||
<option value="14">近 14 天</option>
|
||||
<option value="30">近 30 天</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>按天</label>
|
||||
<select id="perfDay"><option value="all">全部日期</option></select>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>自定义日期</label>
|
||||
<input type="date" id="perfStart" style="padding:6px 10px;border:1px solid #d1d5db;border-radius:8px;font-size:13px;color:#374151;">
|
||||
<span style="color:#9ca3af;">~</span>
|
||||
<input type="date" id="perfEnd" style="padding:6px 10px;border:1px solid #d1d5db;border-radius:8px;font-size:13px;color:#374151;">
|
||||
<button id="perfDateApply" style="padding:6px 14px;border:none;border-radius:8px;background:#2563eb;color:#fff;font-size:12px;font-weight:600;cursor:pointer;">确认</button>
|
||||
<button id="perfDateReset" style="padding:6px 12px;border:1px solid #d1d5db;border-radius:8px;background:#f8fafc;color:#374151;font-size:12px;cursor:pointer;">清除</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模块1:核心指标概览 -->
|
||||
<div class="summary-row" id="perfSummary"></div>
|
||||
|
||||
<!-- 模块2:趋势分析 -->
|
||||
<div class="chart-box">
|
||||
<h2>投放趋势(展示 / 花费 / 转化)</h2>
|
||||
<canvas id="chartTrend"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- 模块3:渠道对比 -->
|
||||
<div style="display:grid;grid-template-columns:1.4fr 1fr;gap:16px;margin-bottom:20px;">
|
||||
<div class="chart-box" style="margin-bottom:0;">
|
||||
<h2>渠道对比(花费 / 转化)</h2>
|
||||
<canvas id="chartChannel"></canvas>
|
||||
</div>
|
||||
<div class="chart-box" style="margin-bottom:0;">
|
||||
<h2>花费占比</h2>
|
||||
<canvas id="chartSpendShare"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模块4:渠道效率对比(按 产品_国家 分组,展开各渠道)-->
|
||||
<div class="section">
|
||||
<h2>渠道效率对比(产品_国家 × 渠道)</h2>
|
||||
<p class="hint">每个产品_国家下列出其投放过的各渠道及效率指标,同组内可横向对比不同渠道表现。</p>
|
||||
<div class="table-wrapper">
|
||||
<table id="channelTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>产品_国家</th><th>渠道</th><th>展示</th><th>点击</th><th>转化</th>
|
||||
<th>花费</th><th>CTR</th><th>CVR</th><th>eCPM</th><th>CPC</th><th>CPA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模块5:产品表现 -->
|
||||
<div class="section">
|
||||
<h2>产品表现排行</h2>
|
||||
<div class="table-wrapper">
|
||||
<table id="productTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>产品</th><th>渠道数</th><th>展示</th><th>点击</th><th>转化</th>
|
||||
<th>花费</th><th>CTR</th><th>CVR</th><th>CPA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模块6:国家分布 -->
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:20px;">
|
||||
<div class="chart-box" style="margin-bottom:0;">
|
||||
<h2>国家展示分布 Top</h2>
|
||||
<canvas id="chartCountry"></canvas>
|
||||
</div>
|
||||
<div class="section" style="margin-bottom:0;">
|
||||
<h2>国家维度明细</h2>
|
||||
<div class="table-wrapper" style="max-height:280px;overflow-y:auto;">
|
||||
<table id="countryTable">
|
||||
<thead><tr><th>国家</th><th>展示</th><th>点击</th><th>转化</th><th>花费</th><th>CTR</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模块7:明细数据 -->
|
||||
<div class="section">
|
||||
<h2>明细数据(按 Campaign×日期)</h2>
|
||||
<div class="table-wrapper" style="max-height:460px;overflow-y:auto;">
|
||||
<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>
|
||||
|
||||
<!-- ============ 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> 投过已停
|
||||
空白 = 暂无数据(未接入或未投放)<br>
|
||||
状态依据实际投放数据判定:产品订单为 Pause 直接判停;产品在投则看该渠道近期展示趋势(掉到个位数/断更视为已停)。仅显示已接入数据的渠道。
|
||||
</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 = [], matrixOverrides = {};
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [p, o, c, m] = 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(() => []),
|
||||
fetch('matrix_overrides.json?' + Date.now()).then(r => r.json()).catch(() => ({}))
|
||||
]);
|
||||
perfData = p; orderData = o; campaignData = c; matrixOverrides = m;
|
||||
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: 投放数据 ---------- */
|
||||
// datalabels 默认全局注册,先取消,只在需要的图表按需启用
|
||||
if (window.ChartDataLabels) { try { Chart.unregister(ChartDataLabels); } catch(e){} }
|
||||
const CH_COLORS = { Mintegral:'#3b82f6', MediaGo:'#10b981', Bigo:'#f59e0b', Adsterra:'#8b5cf6', TrafficStars:'#ec4899', Eagllwin:'#06b6d4' };
|
||||
const PALETTE = ['#3b82f6','#10b981','#f59e0b','#ef4444','#8b5cf6','#06b6d4','#ec4899','#84cc16','#f97316','#6366f1'];
|
||||
let perfCharts = {};
|
||||
|
||||
function renderPerformance() {
|
||||
fillSelect('perfChannel', [...new Set(perfData.map(d => d.channel))].sort());
|
||||
fillSelect('perfProduct', [...new Set(perfData.map(d => d.product))].sort());
|
||||
fillSelect('perfCountry', [...new Set(perfData.map(d => d.location))].filter(x=>x&&x!=='0').sort());
|
||||
fillSelect('perfDay', [...new Set(perfData.map(d => d.date))].sort().reverse());
|
||||
// 自定义日期选择器:限制可选范围为数据的最小/最大日期
|
||||
const allD = [...new Set(perfData.map(d => d.date))].sort();
|
||||
if (allD.length) {
|
||||
const s = document.getElementById('perfStart'), e = document.getElementById('perfEnd');
|
||||
s.min = e.min = allD[0]; s.max = e.max = allD[allD.length-1];
|
||||
}
|
||||
// 下拉类筛选即时生效;自定义日期不即时,需点"确认"才应用
|
||||
['perfChannel','perfProduct','perfCountry','perfRange','perfDay'].forEach(id =>
|
||||
document.getElementById(id).addEventListener('change', drawPerf));
|
||||
document.getElementById('perfDateApply').addEventListener('click', drawPerf);
|
||||
document.getElementById('perfDateReset').addEventListener('click', () => {
|
||||
document.getElementById('perfStart').value = '';
|
||||
document.getElementById('perfEnd').value = '';
|
||||
drawPerf();
|
||||
});
|
||||
drawPerf();
|
||||
}
|
||||
|
||||
function getPerfFiltered() {
|
||||
let d = perfData;
|
||||
const ch = document.getElementById('perfChannel').value;
|
||||
const pr = document.getElementById('perfProduct').value;
|
||||
const co = document.getElementById('perfCountry').value;
|
||||
const rg = document.getElementById('perfRange').value;
|
||||
const day = document.getElementById('perfDay').value;
|
||||
const start = document.getElementById('perfStart').value;
|
||||
const end = document.getElementById('perfEnd').value;
|
||||
if (ch !== 'all') d = d.filter(x => x.channel === ch);
|
||||
if (pr !== 'all') d = d.filter(x => x.product === pr);
|
||||
if (co !== 'all') d = d.filter(x => x.location === co);
|
||||
// 日期过滤优先级:自定义日期区间 > 按天 > 时间范围
|
||||
if (start || end) {
|
||||
if (start) d = d.filter(x => x.date >= start);
|
||||
if (end) d = d.filter(x => x.date <= end);
|
||||
} else if (day !== 'all') {
|
||||
d = d.filter(x => x.date === day);
|
||||
} else if (rg !== 'all') {
|
||||
const allDates = [...new Set(perfData.map(x => x.date))].sort();
|
||||
const cut = allDates[Math.max(0, allDates.length - parseInt(rg))];
|
||||
d = d.filter(x => x.date >= cut);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
// 指标计算
|
||||
const sum = (arr, k) => arr.reduce((s, x) => s + (x[k]||0), 0);
|
||||
const metrics = (arr) => {
|
||||
const imp=sum(arr,'impression'), clk=sum(arr,'clicks'), cv=sum(arr,'conversions'), sp=sum(arr,'spend');
|
||||
return { imp, clk, cv, sp,
|
||||
ctr: imp?clk/imp:0, cvr: clk?cv/clk:0,
|
||||
ecpm: imp?sp/imp*1000:0, cpc: clk?sp/clk:0, cpa: cv?sp/cv:0 };
|
||||
};
|
||||
const fmtMoney = v => '$'+v.toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2});
|
||||
const fmtNum = v => v.toLocaleString();
|
||||
|
||||
function drawPerf() {
|
||||
const data = getPerfFiltered();
|
||||
const m = metrics(data);
|
||||
|
||||
// ---- 模块1:核心指标概览 ----
|
||||
document.getElementById('perfSummary').innerHTML = `
|
||||
<div class="card"><div class="label">总展示</div><div class="value">${fmtNum(m.imp)}</div><div class="sub">${data.length} 条记录</div></div>
|
||||
<div class="card"><div class="label">总点击</div><div class="value">${fmtNum(m.clk)}</div><div class="sub">CTR ${(m.ctr*100).toFixed(2)}%</div></div>
|
||||
<div class="card"><div class="label">总转化</div><div class="value">${fmtNum(m.cv)}</div><div class="sub">CVR ${(m.cvr*100).toFixed(2)}%</div></div>
|
||||
<div class="card"><div class="label">总花费</div><div class="value">${fmtMoney(m.sp)}</div><div class="sub">CPA ${m.cpa?fmtMoney(m.cpa):'-'}</div></div>
|
||||
<div class="card"><div class="label">eCPM</div><div class="value">$${m.ecpm.toFixed(2)}</div><div class="sub">CPC $${m.cpc.toFixed(3)}</div></div>
|
||||
`;
|
||||
|
||||
// ---- 模块2:趋势分析(展示/花费/转化 多轴)----
|
||||
const dates = [...new Set(data.map(d => d.date))].sort();
|
||||
const byDate = k => dates.map(dt => sum(data.filter(x=>x.date===dt), k));
|
||||
drawChart('chartTrend', {
|
||||
type: 'line',
|
||||
data: { labels: dates.map(d=>d.slice(5)), datasets: [
|
||||
{ label:'展示', data:byDate('impression'), borderColor:'#3b82f6', backgroundColor:'#3b82f620', yAxisID:'y', tension:.3, fill:true, pointRadius:2 },
|
||||
{ label:'花费($)', data:byDate('spend'), borderColor:'#f59e0b', backgroundColor:'transparent', yAxisID:'y1', tension:.3, pointRadius:2 },
|
||||
{ label:'转化', data:byDate('conversions'), borderColor:'#ef4444', backgroundColor:'transparent', yAxisID:'y2', tension:.3, pointRadius:2 },
|
||||
]},
|
||||
options: { responsive:true, maintainAspectRatio:false, interaction:{mode:'index',intersect:false},
|
||||
plugins:{ legend:{position:'top',labels:{boxWidth:12,font:{size:11}}} },
|
||||
scales:{
|
||||
x:{ grid:{display:false}, ticks:{font:{size:10},color:'#9ca3af'} },
|
||||
y:{ position:'left', title:{display:true,text:'展示',font:{size:10}}, ticks:{color:'#9ca3af'}, grid:{color:'#f3f4f6'} },
|
||||
y1:{ position:'right', title:{display:true,text:'花费',font:{size:10}}, ticks:{color:'#9ca3af'}, grid:{display:false} },
|
||||
y2:{ display:false }
|
||||
} }
|
||||
});
|
||||
|
||||
// ---- 模块3:渠道对比 ----
|
||||
const chans = [...new Set(data.map(d=>d.channel))];
|
||||
const chSpend = chans.map(c => sum(data.filter(x=>x.channel===c),'spend'));
|
||||
const chConv = chans.map(c => sum(data.filter(x=>x.channel===c),'conversions'));
|
||||
drawChart('chartChannel', {
|
||||
type:'bar',
|
||||
data:{ labels:chans, datasets:[
|
||||
{ label:'花费($)', data:chSpend, backgroundColor:'#3b82f6', borderRadius:4, yAxisID:'y' },
|
||||
{ label:'转化', data:chConv, backgroundColor:'#ef4444', borderRadius:4, yAxisID:'y1' },
|
||||
]},
|
||||
options:{ responsive:true, maintainAspectRatio:false,
|
||||
layout:{padding:{top:24}},
|
||||
plugins:{
|
||||
legend:{position:'top',labels:{boxWidth:12,font:{size:11}}},
|
||||
datalabels:{
|
||||
anchor:'end', align:'end', color:'#374151', font:{size:11,weight:'600'},
|
||||
formatter:(v,ctx)=> ctx.datasetIndex===0 ? '$'+Math.round(v).toLocaleString() : Math.round(v).toLocaleString()
|
||||
}
|
||||
},
|
||||
scales:{ x:{grid:{display:false},ticks:{color:'#9ca3af'}},
|
||||
y:{position:'left',ticks:{color:'#9ca3af'},grid:{color:'#f3f4f6'}},
|
||||
y1:{position:'right',ticks:{color:'#9ca3af'},grid:{display:false}} } },
|
||||
plugins:[ChartDataLabels]
|
||||
});
|
||||
const totalSpend = chSpend.reduce((a,b)=>a+b,0) || 1;
|
||||
drawChart('chartSpendShare', {
|
||||
type:'doughnut',
|
||||
data:{ labels:chans, datasets:[{ data:chSpend, backgroundColor:chans.map(c=>CH_COLORS[c]||'#999') }] },
|
||||
options:{ responsive:true, maintainAspectRatio:false,
|
||||
plugins:{
|
||||
legend:{position:'right',labels:{boxWidth:12,font:{size:11}}},
|
||||
datalabels:{
|
||||
color:'#fff', font:{size:12,weight:'700'},
|
||||
formatter:(v)=> (v/totalSpend*100)>=5 ? '$'+Math.round(v).toLocaleString()+'\n'+(v/totalSpend*100).toFixed(0)+'%' : '',
|
||||
textAlign:'center'
|
||||
}
|
||||
} },
|
||||
plugins:[ChartDataLabels]
|
||||
});
|
||||
|
||||
// ---- 模块4:渠道效率对比(按 产品_国家 分组,展开各渠道)----
|
||||
// 分组键 = 产品 + 国家
|
||||
const groupMap = {};
|
||||
data.forEach(x => {
|
||||
const g = x.product + '_' + x.location;
|
||||
(groupMap[g] = groupMap[g] || []).push(x);
|
||||
});
|
||||
// 分组按总展示降序;组内渠道也按展示降序
|
||||
const groupKeys = Object.keys(groupMap).sort((a,b)=> sum(groupMap[b],'impression') - sum(groupMap[a],'impression'));
|
||||
let rowsHtml = '';
|
||||
groupKeys.forEach(g => {
|
||||
const rows = groupMap[g];
|
||||
const chansInG = [...new Set(rows.map(x=>x.channel))]
|
||||
.sort((a,b)=> sum(rows.filter(x=>x.channel===b),'impression') - sum(rows.filter(x=>x.channel===a),'impression'));
|
||||
chansInG.forEach((c, idx) => {
|
||||
const cm = metrics(rows.filter(x=>x.channel===c));
|
||||
const groupCell = idx===0
|
||||
? `<td rowspan="${chansInG.length}" style="font-weight:600;vertical-align:top;background:#fafbfc;">${g}</td>`
|
||||
: '';
|
||||
rowsHtml += `<tr>
|
||||
${groupCell}
|
||||
<td><span class="tag" style="background:${(CH_COLORS[c]||'#999')}20;color:${CH_COLORS[c]||'#666'}">${c}</span></td>
|
||||
<td class="num">${fmtNum(cm.imp)}</td><td class="num">${fmtNum(cm.clk)}</td><td class="num">${cm.cv}</td>
|
||||
<td class="num">${fmtMoney(cm.sp)}</td><td class="num">${(cm.ctr*100).toFixed(2)}%</td>
|
||||
<td class="num">${(cm.cvr*100).toFixed(2)}%</td><td class="num">$${cm.ecpm.toFixed(2)}</td>
|
||||
<td class="num">$${cm.cpc.toFixed(3)}</td><td class="num">${cm.cpa?fmtMoney(cm.cpa):'-'}</td>
|
||||
</tr>`;
|
||||
});
|
||||
});
|
||||
document.querySelector('#channelTable tbody').innerHTML = rowsHtml || `<tr><td colspan="11" class="empty">暂无数据</td></tr>`;
|
||||
|
||||
// ---- 模块5:产品表现排行(按花费降序)----
|
||||
const prods = [...new Set(data.map(d=>d.product))];
|
||||
const prodRows = prods.map(p => {
|
||||
const pd = data.filter(x=>x.product===p);
|
||||
const pm = metrics(pd);
|
||||
const chCount = new Set(pd.map(x=>x.channel)).size;
|
||||
return { p, chCount, ...pm };
|
||||
}).sort((a,b)=>b.sp-a.sp);
|
||||
document.querySelector('#productTable tbody').innerHTML = prodRows.map(r => `
|
||||
<tr>
|
||||
<td style="font-weight:500">${r.p}</td><td class="num">${r.chCount}</td>
|
||||
<td class="num">${fmtNum(r.imp)}</td><td class="num">${fmtNum(r.clk)}</td><td class="num">${r.cv}</td>
|
||||
<td class="num">${fmtMoney(r.sp)}</td><td class="num">${(r.ctr*100).toFixed(2)}%</td>
|
||||
<td class="num">${(r.cvr*100).toFixed(2)}%</td><td class="num">${r.cpa?fmtMoney(r.cpa):'-'}</td>
|
||||
</tr>`).join('') || `<tr><td colspan="9" class="empty">暂无数据</td></tr>`;
|
||||
|
||||
// ---- 模块6:国家分布 ----
|
||||
const countries = [...new Set(data.map(d=>d.location))].filter(x=>x&&x!=='0');
|
||||
const cRows = countries.map(c => ({ c, ...metrics(data.filter(x=>x.location===c)) })).sort((a,b)=>b.imp-a.imp);
|
||||
const topC = cRows.slice(0,8);
|
||||
drawChart('chartCountry', {
|
||||
type:'bar',
|
||||
data:{ labels:topC.map(r=>r.c), datasets:[{ label:'展示', data:topC.map(r=>r.imp), backgroundColor:topC.map((_,i)=>PALETTE[i%PALETTE.length]), borderRadius:4 }] },
|
||||
options:{ indexAxis:'y', responsive:true, maintainAspectRatio:false, plugins:{legend:{display:false}},
|
||||
scales:{ x:{ticks:{color:'#9ca3af'},grid:{color:'#f3f4f6'}}, y:{ticks:{color:'#9ca3af'},grid:{display:false}} } }
|
||||
});
|
||||
document.querySelector('#countryTable tbody').innerHTML = cRows.map(r => `
|
||||
<tr><td><span class="tag">${r.c}</span></td><td class="num">${fmtNum(r.imp)}</td>
|
||||
<td class="num">${fmtNum(r.clk)}</td><td class="num">${r.cv}</td>
|
||||
<td class="num">${fmtMoney(r.sp)}</td><td class="num">${(r.ctr*100).toFixed(2)}%</td></tr>`).join('')
|
||||
|| `<tr><td colspan="6" class="empty">暂无数据</td></tr>`;
|
||||
|
||||
// ---- 模块7:明细表 ----
|
||||
const sorted = [...data].sort((a,b)=> a.date<b.date?1:a.date>b.date?-1 : b.impression-a.impression);
|
||||
document.querySelector('#perfTable tbody').innerHTML = sorted.map(d => `
|
||||
<tr>
|
||||
<td>${d.date}</td><td><span class="tag" style="background:${(CH_COLORS[d.channel]||'#999')}20;color:${CH_COLORS[d.channel]||'#666'}">${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">${fmtNum(d.impression)}</td><td class="num">${fmtNum(d.clicks)}</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">${fmtMoney(d.spend)}</td>
|
||||
</tr>`).join('') || `<tr><td colspan="11" class="empty">暂无数据</td></tr>`;
|
||||
}
|
||||
|
||||
function drawChart(id, cfg) {
|
||||
if (perfCharts[id]) perfCharts[id].destroy();
|
||||
perfCharts[id] = new Chart(document.getElementById(id), cfg);
|
||||
}
|
||||
|
||||
/* ---------- 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() {
|
||||
// 列 = 产品(带 pkg 用于查数据状态)
|
||||
const cols = orderData.map(o => ({
|
||||
label: o.product, sub: o.geo, pkg: o.pkg,
|
||||
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>`;
|
||||
|
||||
// 单元格:仅当该「渠道|包名」在 matrixOverrides 有记录时才显示状态(遵从事实:接了数据才判定)
|
||||
// 产品 pkg 可能是 "a / b" 合并形式,任一命中即取用
|
||||
const lookup = (ch, pkg) => {
|
||||
const parts = String(pkg).replace(/\s/g,'').split('/');
|
||||
for (const p of parts) {
|
||||
const k = ch + '|' + p;
|
||||
if (k in matrixOverrides) return matrixOverrides[k];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const cell = (col, ch) => {
|
||||
const st = lookup(ch, col.pkg);
|
||||
if (st === undefined || st === 'none') return '<td></td>'; // 无数据 / 零曝光 -> 留空
|
||||
const cls = st === 'Active' ? 'badge-active' : 'badge-pause';
|
||||
return `<td style="text-align:center;"><span class="badge ${cls}">${st}</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>
|
||||
Reference in New Issue
Block a user