feat: multi-dimensional performance dashboard (KPI/trend/channel/product/geo)

This commit is contained in:
王源
2026-09-09 20:57:15 +08:00
parent 95e4849934
commit b8c7afc1d8
+231 -92
View File
@@ -133,38 +133,61 @@
<!-- ============ 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>
<!-- 模块1:核心指标概览 -->
<div class="summary-row" id="perfSummary"></div>
<div class="section">
<div class="controls">
<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="perfDate"><option value="all">全部</option></select>
</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>
<div class="table-wrapper">
<table id="perfTable">
<table id="channelTable">
<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>
<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>
@@ -172,9 +195,53 @@
</div>
</div>
<div class="chart-box">
<h2>各 Campaign 展示量对比</h2>
<canvas id="chartImp"></canvas>
<!-- 模块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>
@@ -253,7 +320,6 @@
<script>
let perfData = [], orderData = [], campaignData = [], matrixOverrides = {};
let impChart = null;
async function load() {
try {
@@ -284,15 +350,15 @@
}
/* ---------- Tab 1: 投放数据 ---------- */
const CH_COLORS = { Mintegral:'#3b82f6', MediaGo:'#10b981', Bigo:'#f59e0b' };
const PALETTE = ['#3b82f6','#10b981','#f59e0b','#ef4444','#8b5cf6','#06b6d4','#ec4899','#84cc16','#f97316','#6366f1'];
let perfCharts = {};
function renderPerformance() {
// filters
const chans = [...new Set(perfData.map(d => d.channel))];
const prods = [...new Set(perfData.map(d => d.product))];
const dates = [...new Set(perfData.map(d => d.date))].sort();
fillSelect('perfChannel', chans);
fillSelect('perfProduct', prods);
fillSelect('perfDate', dates);
['perfChannel', 'perfProduct', 'perfDate'].forEach(id =>
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());
['perfChannel','perfProduct','perfCountry','perfRange'].forEach(id =>
document.getElementById(id).addEventListener('change', drawPerf));
drawPerf();
}
@@ -301,71 +367,144 @@
let d = perfData;
const ch = document.getElementById('perfChannel').value;
const pr = document.getElementById('perfProduct').value;
const dt = document.getElementById('perfDate').value;
const co = document.getElementById('perfCountry').value;
const rg = document.getElementById('perfRange').value;
if (ch !== 'all') d = d.filter(x => x.channel === ch);
if (pr !== 'all') d = d.filter(x => x.product === pr);
if (dt !== 'all') d = d.filter(x => x.date === dt);
if (co !== 'all') d = d.filter(x => x.location === co);
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();
// summary
const imp = data.reduce((s, d) => s + d.impression, 0);
const clk = data.reduce((s, d) => s + d.clicks, 0);
const conv = data.reduce((s, d) => s + d.conversions, 0);
const spend = data.reduce((s, d) => s + d.spend, 0);
const ctr = imp ? clk / imp : 0;
const m = metrics(data);
// ---- 模块1:核心指标概览 ----
document.getElementById('perfSummary').innerHTML = `
<div class="card"><div class="label">总展示</div><div class="value">${imp.toLocaleString()}</div><div class="sub">${data.length} 条 campaign</div></div>
<div class="card"><div class="label">总点击</div><div class="value">${clk.toLocaleString()}</div><div class="sub">CTR ${(ctr*100).toFixed(2)}%</div></div>
<div class="card"><div class="label">总转化</div><div class="value">${conv.toLocaleString()}</div><div class="sub">CVR ${(clk?conv/clk*100:0).toFixed(2)}%</div></div>
<div class="card"><div class="label">总花费</div><div class="value">$${spend.toFixed(2)}</div><div class="sub">Spend</div></div>
<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>
`;
// table
const tbody = document.querySelector('#perfTable tbody');
if (!data.length) {
tbody.innerHTML = `<tr><td colspan="11" class="empty">暂无数据</td></tr>`;
} else {
const sorted = [...data].sort((a, b) => b.impression - a.impression);
tbody.innerHTML = sorted.map(d => `
<tr>
<td>${d.date}</td>
<td><span class="tag">${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">${d.impression.toLocaleString()}</td>
<td class="num">${d.clicks.toLocaleString()}</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">$${d.spend.toFixed(2)}</td>
</tr>
`).join('');
}
// chart
const sorted = [...data].sort((a, b) => b.impression - a.impression);
const labels = sorted.map(d => d.offer_name);
const vals = sorted.map(d => d.impression);
if (impChart) impChart.destroy();
impChart = new Chart(document.getElementById('chartImp'), {
type: 'bar',
data: {
labels,
datasets: [{ label: '展示量', data: vals, backgroundColor: '#3b82f6', borderRadius: 4 }]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { font: { size: 10 }, color: '#9ca3af' }, grid: { display: false } },
y: { beginAtZero: true, ticks: { color: '#9ca3af' }, grid: { color: '#f3f4f6' } }
}
}
// ---- 模块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,
plugins:{legend:{position:'top',labels:{boxWidth:12,font:{size:11}}}},
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}} } }
});
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}}}} }
});
// ---- 模块4:渠道效率表 ----
document.querySelector('#channelTable tbody').innerHTML = chans.map(c => {
const cm = metrics(data.filter(x=>x.channel===c));
return `<tr>
<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>`;
}).join('') || `<tr><td colspan="10" 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: 订单信息 ---------- */