看板增加ECharts图表(自托管): 近30天趋势折线/级别环形/学历柱状/期望城市TOP8/筛选漏斗; 统计接口扩充
This commit is contained in:
Vendored
+45
File diff suppressed because one or more lines are too long
+55
-7
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user