init: voypace ad report

This commit is contained in:
王源
2026-09-10 15:26:54 +08:00
commit 3c525a9672
9 changed files with 1833 additions and 0 deletions
+694
View File
@@ -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>