看板增加ECharts图表(自托管): 近30天趋势折线/级别环形/学历柱状/期望城市TOP8/筛选漏斗; 统计接口扩充

This commit is contained in:
mzl92
2026-08-27 14:37:06 +08:00
parent 06a907acbf
commit 6e9470a980
4 changed files with 107 additions and 10 deletions
+45
View File
File diff suppressed because one or more lines are too long
+55 -7
View File
@@ -49,6 +49,8 @@ tr:hover td{background:#f8fafc;cursor:pointer}
.bars .bar.g{background:var(--ok)}
.bars .lbl{font-size:11px;color:var(--sub)}
.hidden{display:none}
.chartRow{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:12px;margin-bottom:16px}
.chartRow .panel{margin-bottom:0}
#toast{position:fixed;top:60px;left:50%;transform:translateX(-50%);background:#1e293b;color:#fff;padding:8px 18px;border-radius:8px;z-index:9;opacity:.95}
#detailModal{position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:8;display:flex;align-items:center;justify-content:center}
#detailModal.hidden{display:none}
@@ -76,7 +78,13 @@ tr:hover td{background:#f8fafc;cursor:pointer}
</div>
<div id="main" class="hidden">
<div class="cards" id="statCards"></div>
<div class="panel"><h3>近7天趋势(绿=打招呼)</h3><div class="bars" id="trend"></div></div>
<div class="panel"><h3>近30天趋势</h3><div id="chTrend" style="height:300px"></div></div>
<div class="chartRow">
<div class="panel"><h3>评分级别分布</h3><div id="chLevel" style="height:280px"></div></div>
<div class="panel"><h3>学历分布</h3><div id="chEdu" style="height:280px"></div></div>
<div class="panel"><h3>期望城市 TOP8</h3><div id="chCity" style="height:280px"></div></div>
<div class="panel"><h3>筛选漏斗</h3><div id="chFunnel" style="height:280px"></div></div>
</div>
<div class="panel">
<h3>候选人</h3>
<div class="filters">
@@ -119,6 +127,7 @@ tr:hover td{background:#f8fafc;cursor:pointer}
<div id="detailModal" onclick="if(event.target===this)this.classList.add('hidden')" class="hidden">
<div class="panel detail" style="width:820px;max-width:94vw;max-height:88vh;overflow:auto" id="detailBody"></div>
</div>
<script src="echarts.min.js"></script>
<script>
let me=null,pageCur=1,pageTotal=1;
const $=id=>document.getElementById(id);
@@ -164,13 +173,52 @@ async function loadStats(){
<div class="card"><div class="k">累计打招呼</div><div class="v">${s.greetedTotal}</div></div>
${s.byLevel.map(x=>`<div class="card"><div class="k">${esc(x.k)}</div><div class="v">${x.c}</div></div>`).join('')}
<div class="card"><div class="k">数据快照</div><div class="v" style="font-size:16px">${s.snapshotAt?new Date(new Date(s.snapshotAt).getTime()+8*3600e3).toISOString().slice(11,19):'-'}<span style="font-size:11px;color:var(--sub)"> +8 每5分钟</span></div></div>`;
const mx=Math.max(1,...s.last7.map(d=>d.c));
$('trend').innerHTML=s.last7.map(d=>`<div class="b">
<div class="lbl">${d.c}人/${d.greeted}招呼</div>
<div class="bar" style="height:${d.c/mx*100}%"></div>
<div class="bar g" style="height:${d.greeted/mx*100}%;width:24%"></div>
<div class="lbl">${d.d.slice(5)}</div></div>`).join('');
renderCharts(s);
}
// ---------- 图表(ECharts 自托管) ----------
const charts=[];
function mkChart(id){const c=echarts.init($(id));charts.push(c);return c}
function renderCharts(s){
if(typeof echarts==='undefined'){['chTrend','chLevel','chEdu','chCity','chFunnel'].forEach(id=>{$(id).innerHTML='<div style="color:var(--sub);padding:40px;text-align:center">图表库未加载</div>'});return}
charts.splice(0).forEach(c=>c.dispose());
const axis='#6b7280',split='#e5e7eb';
mkChart('chTrend').setOption({
tooltip:{trigger:'axis'},legend:{data:['新增','推荐','打招呼']},
grid:{left:50,right:20,top:40,bottom:30},
xAxis:{type:'category',data:s.last30.map(d=>d.d.slice(5)),axisLine:{lineStyle:{color:split}},axisLabel:{color:axis}},
yAxis:{type:'value',axisLabel:{color:axis},splitLine:{lineStyle:{color:split}}},
series:[
{name:'新增',type:'line',smooth:true,data:s.last30.map(d=>d.c),areaStyle:{opacity:.15},itemStyle:{color:'#2563eb'}},
{name:'推荐',type:'line',smooth:true,data:s.last30.map(d=>d.rec),itemStyle:{color:'#16a34a'}},
{name:'打招呼',type:'line',smooth:true,data:s.last30.map(d=>d.greeted),itemStyle:{color:'#d97706'}}]});
const lvColor={'推荐':'#16a34a','待定':'#d97706','不推荐':'#9ca3af','不相关':'#dc2626','?':'#9ca3af'};
mkChart('chLevel').setOption({
tooltip:{trigger:'item',formatter:'{b}: {c} ({d}%)'},legend:{bottom:0},
series:[{type:'pie',radius:['42%','68%'],center:['50%','45%'],
data:s.byLevel.map(x=>({name:x.k,value:x.c,itemStyle:{color:lvColor[x.k]||'#9ca3af'}})),
label:{formatter:'{b}\n{c}'}}]});
mkChart('chEdu').setOption({
tooltip:{},grid:{left:40,right:20,top:20,bottom:30},
xAxis:{type:'category',data:s.byEducation.map(x=>x.k),axisLabel:{color:axis,interval:0},axisLine:{lineStyle:{color:split}}},
yAxis:{type:'value',axisLabel:{color:axis},splitLine:{lineStyle:{color:split}}},
series:[{type:'bar',barWidth:'55%',data:s.byEducation.map(x=>x.c),itemStyle:{color:'#2563eb',borderRadius:[4,4,0,0]}}]});
mkChart('chCity').setOption({
tooltip:{},grid:{left:70,right:30,top:20,bottom:30},
xAxis:{type:'value',axisLabel:{color:axis},splitLine:{lineStyle:{color:split}}},
yAxis:{type:'category',data:s.byCity.map(x=>x.k).reverse(),axisLabel:{color:axis}},
series:[{type:'bar',barWidth:'55%',data:s.byCity.map(x=>x.c).reverse(),itemStyle:{color:'#0ea5e9',borderRadius:[0,4,4,0]}}]});
const lv=s.byLevel.reduce((m,x)=>(m[x.k]=x.c,m),{});
mkChart('chFunnel').setOption({
tooltip:{trigger:'item',formatter:'{b}: {c}'},
series:[{type:'funnel',left:'10%',width:'80%',top:10,bottom:10,gap:2,
label:{formatter:'{b} {c}'},
data:[
{name:'累计抓取',value:s.total,itemStyle:{color:'#94a3b8'}},
{name:'过门槛',value:s.total-(lv['不相关']||0),itemStyle:{color:'#2563eb'}},
{name:'推荐+待定',value:(lv['推荐']||0)+(lv['待定']||0),itemStyle:{color:'#d97706'}},
{name:'已打招呼',value:s.greetedTotal,itemStyle:{color:'#16a34a'}}]}]});
}
window.addEventListener('resize',()=>charts.forEach(c=>c.resize()));
function qs(){return `?level=${$('q_level').value}&greeted=${$('q_greeted').value}&account=${$('q_account').value}&date_from=${$('q_from').value}&date_to=${$('q_to').value}&q=${encodeURIComponent($('q_kw').value.trim())}`}
async function loadList(p){
pageCur=p||pageCur;
+2 -1
View File
@@ -42,7 +42,8 @@ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const statText = await $('#statCards').innerText();
ok('累计候选人为五位数(数据在库)', /\d{5,}/.test(statText));
ok('级别分布含推荐', statText.includes('推荐'));
ok('近7天趋势有柱', await p.evaluate(() => document.querySelectorAll('#trend .b').length) > 0);
const canvasN = await p.evaluate(() => document.querySelectorAll('#main canvas').length);
ok(`图表已渲染(canvas=${canvasN},≥5)`, canvasN >= 5);
console.log('== 4. 候选人列表与筛选 ==');
await sleep(500);
+5 -2
View File
@@ -153,10 +153,13 @@ app.get('/api/stats', requireAuth, (req, res) => {
today: rdb.prepare("SELECT COUNT(*) c FROM candidates WHERE scraped_at >= ?").get(today + 'T00:00').c,
greetedTotal: rdb.prepare('SELECT COUNT(*) c FROM candidates WHERE greeted = 1').get().c,
byLevel: rdb.prepare("SELECT COALESCE(score_level,'?') k, COUNT(*) c FROM candidates GROUP BY score_level").all(),
last7: rdb.prepare(`SELECT substr(scraped_at,1,10) d, COUNT(*) c,
byEducation: rdb.prepare("SELECT COALESCE(NULLIF(education,''),'未知') k, COUNT(*) c FROM candidates GROUP BY education ORDER BY c DESC LIMIT 8").all(),
byCity: rdb.prepare("SELECT expected_city k, COUNT(*) c FROM candidates WHERE expected_city != '' GROUP BY expected_city ORDER BY c DESC LIMIT 8").all(),
byAccount: rdb.prepare("SELECT COALESCE(NULLIF(account,''),'未知') k, COUNT(*) c, SUM(greeted) g FROM candidates GROUP BY account ORDER BY c DESC").all(),
last30: rdb.prepare(`SELECT substr(scraped_at,1,10) d, COUNT(*) c,
SUM(CASE WHEN score_level='推荐' THEN 1 ELSE 0 END) rec,
SUM(CASE WHEN greeted=1 THEN 1 ELSE 0 END) greeted
FROM candidates WHERE scraped_at >= date('now','localtime','-6 days','start of day')
FROM candidates WHERE scraped_at >= date('now','-29 days','start of day')
GROUP BY substr(scraped_at,1,10) ORDER BY d`).all(),
lastBatch: rdb.prepare('SELECT created_at, mode, new_count, greet_count FROM scrape_log ORDER BY id DESC LIMIT 1').get() || null,
});