Files
2026-09-10 15:26:54 +08:00

695 lines
36 KiB
HTML
Raw Permalink 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>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>