feat: multi-dimensional performance dashboard (KPI/trend/channel/product/geo)
This commit is contained in:
+231
-92
@@ -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: 订单信息 ---------- */
|
||||
|
||||
Reference in New Issue
Block a user