Files
zhaopin-admin/public/index.html
T

474 lines
30 KiB
HTML
Raw 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">
<title>招聘数据后台</title>
<style>
:root{--bg:#f5f6f8;--card:#fff;--line:#e5e7eb;--txt:#1f2937;--sub:#6b7280;--pri:#2563eb;--ok:#16a34a;--warn:#d97706;--bad:#dc2626}
*{box-sizing:border-box;margin:0;padding:0}
body{font:14px/1.6 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif;background:var(--bg);color:var(--txt)}
.topbar{background:#1e293b;color:#fff;padding:0 20px;height:52px;display:flex;align-items:center;gap:16px}
.topbar b{font-size:16px}.topbar .sp{flex:1}
.taskStatus{display:flex;gap:8px;align-items:center}
.tchip{display:inline-flex;align-items:center;gap:5px;padding:3px 10px;border-radius:12px;font-size:12px;background:rgba(255,255,255,.08);color:#cbd5e1}
.tchip .dot{width:8px;height:8px;border-radius:50%;background:#94a3b8}
.tchip.running .dot{background:#22c55e;animation:pulse 1.5s infinite}
.tchip.done .dot{background:#10b981}
.tchip.error .dot{background:#ef4444;animation:pulse 1s infinite}
.tchip.error{color:#fca5a5}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
.topbar button{background:transparent;color:#cbd5e1;border:1px solid #475569;border-radius:6px;padding:4px 12px;cursor:pointer}
.topbar button:hover{color:#fff;border-color:#94a3b8}
.wrap{max-width:1200px;margin:20px auto;padding:0 16px}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:16px}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px 16px}
.card .k{color:var(--sub);font-size:12px}.card .v{font-size:26px;font-weight:600;margin-top:2px}
.panel{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px;margin-bottom:16px}
.panel h3{font-size:15px;margin-bottom:12px}
.filters{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:12px}
.filters select,.filters input{padding:6px 8px;border:1px solid var(--line);border-radius:6px;background:#fff}
.filters input[type=text]{width:160px}
.btn{background:var(--pri);color:#fff;border:none;border-radius:6px;padding:6px 14px;cursor:pointer}
.btn.sub{background:#fff;color:var(--txt);border:1px solid var(--line)}
.btn.small{padding:3px 10px;font-size:12px}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{padding:8px 10px;border-bottom:1px solid var(--line);text-align:left;white-space:nowrap}
th{color:var(--sub);font-weight:500;background:#f9fafb}
tr:hover td{background:#f8fafc;cursor:pointer}
.tag{display:inline-block;padding:1px 8px;border-radius:10px;font-size:12px}
.tag.rec{background:#dcfce7;color:#15803d}.tag.maybe{background:#fef3c7;color:#b45309}
.tag.no{background:#f3f4f6;color:#6b7280}.tag.irr{background:#fee2e2;color:#b91c1c}
.tag.yes{background:#dbeafe;color:#1d4ed8}
.pager{display:flex;gap:8px;align-items:center;justify-content:flex-end;margin-top:10px;color:var(--sub)}
#loginBox{max-width:360px;margin:80px auto}
#loginBox .panel{padding:28px}
#loginBox h2{font-size:18px;margin-bottom:16px;text-align:center}
#loginBox input{width:100%;padding:9px 10px;border:1px solid var(--line);border-radius:6px;margin-bottom:10px}
#loginBox .btn{width:100%;padding:10px}
.err{color:var(--bad);font-size:13px;margin-bottom:8px;min-height:18px}
.note{color:var(--sub);font-size:12px;margin-top:10px;text-align:center}
.detail pre{white-space:pre-wrap;background:#f9fafb;border:1px solid var(--line);border-radius:8px;padding:12px;font-size:12px;max-height:400px;overflow:auto}
.scores{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:6px;margin:8px 0 10px}
.sc{background:#f9fafb;border:1px solid var(--line);border-radius:8px;padding:6px 10px;display:flex;align-items:center;gap:8px}
.sc .k{font-size:12px;color:var(--sub);flex:0 0 56px}
.sc .bars{display:flex;gap:2px}
.sc .bars i{width:13px;height:8px;border-radius:2px;background:#e5e7eb}
.sc .bars i.on{background:var(--ok)}
.sc b{margin-left:auto;font-size:13px}
.chips{display:flex;flex-wrap:wrap;gap:6px;margin:4px 0 12px}
.chip{padding:2px 12px;border-radius:12px;font-size:12px}
.chip.ok{background:#dcfce7;color:#15803d}
.chip.bad{background:#fee2e2;color:#b91c1c}
.resume{white-space:pre-wrap;font-size:14px;line-height:1.9;background:#f9fafb;border:1px solid var(--line);border-radius:8px;padding:14px 18px;max-height:460px;overflow:auto;color:#374151}
.secTitle{display:inline-block;font-weight:600;color:var(--pri);margin-top:8px;border-bottom:2px solid #dbeafe;padding-bottom:2px}
.kv{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:8px;margin-bottom:12px}
.kv div{background:#f9fafb;border-radius:6px;padding:6px 10px}.kv span{color:var(--sub)}
.bars{display:flex;align-items:flex-end;gap:10px;height:120px;padding:8px 4px}
.bars .b{flex:1;display:flex;flex-direction:column;justify-content:flex-end;align-items:center;gap:4px}
.bars .bar{width:70%;background:var(--pri);border-radius:4px 4px 0 0;min-height:2px}
.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}
.slider{position:relative;overflow:hidden;margin-bottom:4px}
.sliderTrack{display:flex;transition:transform .35s ease;width:100%}
.pane{flex:0 0 100%;min-width:100%;padding:2px 14px 2px 2px}
.navArrow{position:fixed;top:50%;transform:translateY(-50%);z-index:6;width:46px;height:46px;border-radius:50%;background:rgba(255,255,255,.95);border:1px solid var(--line);font-size:24px;line-height:1;color:var(--txt);cursor:pointer;box-shadow:0 2px 12px rgba(0,0,0,.12)}
.navArrow:hover{background:#fff;color:var(--pri);border-color:var(--pri)}
.navArrow.left{left:10px}.navArrow.right{right:10px}
.dots{text-align:center;margin:2px 0 14px}
.dot{display:inline-block;width:9px;height:9px;border-radius:50%;background:#cbd5e1;margin:0 5px;cursor:pointer;transition:all .2s}
.dot.on{background:var(--pri);width:22px;border-radius:5px}
.tabs{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}
.tab{padding:5px 16px;border-radius:18px;border:1px solid var(--line);background:#fff;cursor:pointer;font-size:13px}
.tab:hover{border-color:var(--pri);color:var(--pri)}
.tab.on{background:var(--pri);color:#fff;border-color:var(--pri)}
.tab.on[data-q="valid"]{background:var(--ok);border-color:var(--ok)}
.tab.on[data-q="today"]{background:var(--warn);border-color:var(--warn)}
tr.today td{background:#fef9c3!important}
tr.today:hover td{background:#fde68a!important}
#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}
#userModal{position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:8;display:flex;align-items:center;justify-content:center}
#userModal.hidden{display:none}
#accessModal{position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:8;display:flex;align-items:center;justify-content:center}
#accessModal.hidden{display:none}
#rulesModal{position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:8;display:flex;align-items:center;justify-content:center}
#rulesModal.hidden{display:none}
.mdview{font-size:14px;line-height:1.7;color:#374151}
.mdview h1{font-size:20px;margin:4px 0 12px;color:var(--txt)}
.mdview h2{font-size:16px;margin:22px 0 8px;padding:6px 10px;background:#eff6ff;border-left:4px solid var(--pri);border-radius:4px}
.mdview h3{font-size:14px;margin:14px 0 6px}
.mdview p{margin:6px 0}
.mdview table{width:100%;border-collapse:collapse;font-size:13px;margin:8px 0}
.mdview th,.mdview td{border:1px solid var(--line);padding:6px 10px;text-align:left;white-space:normal}
.mdview th{background:#f9fafb}
.mdview pre{background:#f9fafb;border:1px solid var(--line);border-radius:8px;padding:12px;font-size:12.5px;overflow:auto;margin:8px 0}
.mdview code{background:#f1f5f9;padding:1px 5px;border-radius:4px;font-size:12.5px}
.mdview pre code{background:none;padding:0}
.mdview blockquote{border-left:4px solid #cbd5e1;background:#f8fafc;margin:8px 0;padding:8px 12px;color:var(--sub)}
.mdview hr{border:none;border-top:1px solid var(--line);margin:16px 0}
.mdview li{margin:3px 0}
</style>
</head>
<body>
<div class="topbar">
<b>📋 招聘数据后台</b><span id="whoami" style="color:#94a3b8;font-size:12px"></span>
<span class="taskStatus" id="taskStatus"></span>
<span class="sp"></span>
<button id="rulesBtn" class="hidden" onclick="openRulesModal()">筛选规则</button>
<button id="userMgrBtn" class="hidden" onclick="openUserModal()">账号管理</button>
<button id="accessLogBtn" class="hidden" onclick="openAccessModal()">访问记录</button>
<button id="logoutBtn" class="hidden" onclick="logout()">退出登录</button>
</div>
<div class="wrap">
<div id="loginBox">
<div class="panel">
<h2 id="loginTitle">登录</h2>
<div class="err" id="loginErr"></div>
<form onsubmit="event.preventDefault();doLoginOrInit()">
<input id="f_user" placeholder="用户名" autocomplete="username">
<input id="f_pass" type="password" placeholder="密码(至少6位)" autocomplete="current-password">
<button class="btn" id="loginBtn" type="submit">登录</button>
</form>
<div class="note" id="loginNote"></div>
</div>
</div>
<div id="main" class="hidden">
<div class="slider" id="slider">
<div class="sliderTrack" id="sliderTrack">
<section class="pane">
<div class="cards" id="statCards"></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>筛选漏斗</h3><div id="chFunnel" style="height:280px"></div></div>
</div>
</section>
<section class="pane">
<div class="panel">
<h3>候选人</h3>
<div class="tabs" id="timeTabs">
<span style="color:var(--sub);font-size:12px;align-self:center">时间:</span>
<button class="tab on" data-q="" onclick="timeTab('')">全部时间</button>
<button class="tab" data-q="today" onclick="timeTab('today')">☀️ 今日</button>
<span style="color:var(--sub);font-size:12px;align-self:center;margin-left:10px">或选日期区间 ↓</span>
</div>
<div class="tabs" id="levelTabs">
<span style="color:var(--sub);font-size:12px;align-self:center">级别:</span>
<button class="tab on" data-q="" onclick="levelTab('')">全部</button>
<button class="tab" data-q="valid" onclick="levelTab('valid')">✅ 有效人员</button>
<button class="tab" data-q="rec" onclick="levelTab('rec')">推荐</button>
<button class="tab" data-q="maybe" onclick="levelTab('maybe')">待定</button>
<button class="tab" data-q="greeted" onclick="levelTab('greeted')">已打招呼</button>
</div>
<div class="filters">
<select id="q_level"><option value="">全部级别</option><option value="有效">有效(推荐+待定)</option><option>推荐</option><option>待定</option><option>不推荐</option><option>不相关</option></select>
<select id="q_greeted"><option value="">打招呼不限</option><option value="1">已打招呼</option><option value="0">未打招呼</option></select>
<select id="q_account"><option value="">全部账号</option><option>hr1</option><option>hr2</option></select>
<input type="date" id="q_from" onchange="qToday=0"><span style="color:var(--sub)">至</span><input type="date" id="q_to" onchange="qToday=0">
<input type="text" id="q_kw" placeholder="姓名/学校/备注">
<button class="btn" onclick="loadList(1)">查询</button>
<button class="btn sub" onclick="exportCsv()">导出CSV</button>
</div>
<div style="overflow-x:auto">
<table><thead><tr>
<th>姓名</th><th>年龄</th><th>学历</th><th>经验</th><th>学校</th><th>期望城市</th>
<th>评分</th><th>级别</th><th>门槛备注</th><th>打招呼</th><th>账号</th><th>日期</th>
</tr></thead><tbody id="tbody"></tbody></table>
</div>
<div class="pager">
<span id="totalInfo"></span>
<button class="btn sub small" onclick="page(-1)">上一页</button>
<span id="pageNo">1</span>
<button class="btn sub small" onclick="page(1)">下一页</button>
</div>
</div>
</section>
</div>
<button class="navArrow left" onclick="slide(-1)">‹</button>
<button class="navArrow right" onclick="slide(1)">›</button>
</div>
<div class="dots"><span class="dot on" onclick="setPane(0)"></span><span class="dot" onclick="setPane(1)"></span></div>
<div class="panel hidden" id="userPanel"></div>
<div id="userModal" onclick="if(event.target===this)this.classList.add('hidden')" class="hidden">
<div class="panel" style="width:780px;max-width:94vw;max-height:88vh;overflow:auto">
<h3>账号管理 <button class="btn sub small" style="float:right" onclick="document.getElementById('userModal').classList.add('hidden')">关闭</button></h3>
<div class="filters">
<input type="text" id="nu_user" placeholder="新HR用户名" style="width:140px">
<input type="password" id="nu_pass" placeholder="密码至少6位" style="width:140px">
<button class="btn" onclick="addUser()">创建HR账号</button>
</div>
<table><thead><tr><th>ID</th><th>用户名</th><th>角色</th><th>状态</th><th>创建时间</th><th>最近登录</th><th>操作</th></tr></thead>
<tbody id="utable"></tbody></table>
<div style="color:var(--sub);font-size:12px;margin-top:8px">提示:停用后该账号无法登录,已登录的会话在下次请求时失效;启用后恢复。</div>
</div>
</div>
<div id="accessModal" onclick="if(event.target===this)this.classList.add('hidden')" class="hidden">
<div class="panel" style="width:820px;max-width:94vw;max-height:88vh;overflow:auto">
<h3>访问记录(最近500条)
<span style="float:right">
<button class="btn sub small" onclick="clearAccessLog()">清空</button>
<button class="btn sub small" onclick="document.getElementById('accessModal').classList.add('hidden')">关闭</button>
</span>
</h3>
<table><thead><tr><th>时间</th><th>用户</th><th>动作</th><th>说明</th><th>IP</th></tr></thead>
<tbody id="alogBody"></tbody></table>
</div>
</div>
<div id="rulesModal" onclick="if(event.target===this)this.classList.add('hidden')" class="hidden">
<div class="panel" style="width:900px;max-width:94vw;max-height:88vh;overflow:auto">
<h3>筛选规则与流程总览
<button class="btn sub small" style="float:right" onclick="document.getElementById('rulesModal').classList.add('hidden')">关闭</button>
</h3>
<div class="mdview" id="rulesBody">加载中...</div>
</div>
</div>
</div>
<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 src="marked.min.js"></script>
<script>
let me=null,pageCur=1,pageTotal=1;
const $=id=>document.getElementById(id);
const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
// ---------- 左右滑动双屏(0=看板 1=列表) ----------
let curPane=0;
function slide(d){setPane((curPane+d+2)%2)} // 循环: 0←→1 互相绕
function setPane(n){
curPane=(n+2)%2;
$('sliderTrack').style.transform=`translateX(-${curPane*100}%)`;
document.querySelectorAll('.dot').forEach((el,i)=>el.classList.toggle('on',i===curPane));
setTimeout(()=>charts.forEach(c=>c.resize()),400);
}
document.addEventListener('keydown',e=>{
if(!me||e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA')return;
if(e.key==='ArrowLeft')slide(-1);if(e.key==='ArrowRight')slide(1);
});
(()=>{let x0=null;const sl=$('slider');
sl.addEventListener('touchstart',e=>x0=e.touches[0].clientX,{passive:true});
sl.addEventListener('touchend',e=>{if(x0===null)return;const dx=e.changedTouches[0].clientX-x0;if(Math.abs(dx)>40)slide(dx<0?1:-1);x0=null},{passive:true});
})();
const tagCls={'推荐':'rec','待定':'maybe','不推荐':'no','不相关':'irr'};
function toast(t){const d=document.createElement('div');d.id='toast';d.textContent=t;document.body.appendChild(d);setTimeout(()=>d.remove(),1800)}
async function api(path,opts={}){const r=await fetch(path,{headers:{'Content-Type':'application/json'},...opts});const j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.error||r.status);return j}
async function init(){
try{
const b=await api('/api/bootstrap');
if(b.needsInit){$('loginTitle').textContent='初始化超管账号';$('loginNote').textContent='首个注册的账号将成为超级管理员';$('loginBtn').textContent='创建超管账号';mode='init'}
else mode='login';
const m=await api('/api/me');enter(m);
}catch(e){}
}
let mode='login';
async function doLoginOrInit(){
const u=$('f_user').value.trim(),p=$('f_pass').value;
try{
if(mode==='init'){await api('/api/register',{method:'POST',body:JSON.stringify({username:u,password:p})});toast('超管创建成功')}
await api('/api/login',{method:'POST',body:JSON.stringify({username:u,password:p})});
location.reload();
}catch(e){$('loginErr').textContent=e.message}
}
async function logout(){await api('/api/logout',{method:'POST'});location.reload()}
async function enter(m){
me=m;$('loginBox').classList.add('hidden');$('main').classList.remove('hidden');$('logoutBtn').classList.remove('hidden');
$('whoami').textContent=`${m.username} · ${m.role==='super'?'超管':'HR'}`;
$('rulesBtn').classList.remove('hidden');
if(m.role==='super'){$('userMgrBtn').classList.remove('hidden');$('accessLogBtn').classList.remove('hidden')}
loadStats().catch(e=>dataErr(e));loadList(1).catch(e=>dataErr(e));
refreshTaskStatus();
}
// ---------- 任务状态灯 ----------
async function refreshTaskStatus(){
try{
const s=await api('/api/task-status');
$('taskStatus').innerHTML=['hr1','hr2'].map(a=>{
const t=s[a]||{state:'idle',text:'今日未运行'};
const extra=t.chatLeft!=null?`·剩${t.chatLeft}次`:'';
return `<span class="tchip ${t.state}" title="${a}"><span class="dot"></span>${a} ${t.text}${extra}</span>`;
}).join('');
}catch(_){}
}
setInterval(refreshTaskStatus,60000);
function dataErr(e){
$('statCards').innerHTML=`<div class="card" style="grid-column:1/-1"><div class="k">⚠ 数据加载失败</div><div style="font-size:14px">${esc(e.message)} <button class="btn sub small" onclick="loadStats().then(()=>loadList(1)).catch(e=>dataErr(e))">重试</button></div></div>`;
toast('数据加载失败: '+e.message);
}
async function loadStats(){
const s=await api('/api/stats');
$('statCards').innerHTML=`
<div class="card"><div class="k">累计候选人</div><div class="v">${s.total}</div></div>
<div class="card"><div class="k">今日新增</div><div class="v">${s.today}</div></div>
<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>`;
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','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]}}]});
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()));
let qToday=0;
function timeTab(t){qToday=t==='today'?1:0;if(qToday){$('q_from').value='';$('q_to').value=''}loadList(1)}
function levelTab(l){
$('q_level').value=l==='valid'?'有效':(l==='rec'?'推荐':(l==='maybe'?'待定':''));
$('q_greeted').value=l==='greeted'?'1':'';
loadList(1);
}
function syncTabs(){
document.querySelectorAll('#timeTabs .tab').forEach(b=>b.classList.toggle('on',(b.dataset.q==='today')===!!qToday));
const lv=$('q_level').value,gr=$('q_greeted').value;
const which=gr==='1'?'greeted':(lv==='有效'?'valid':(lv==='推荐'?'rec':(lv==='待定'?'maybe':'')));
document.querySelectorAll('#levelTabs .tab').forEach(b=>b.classList.toggle('on',b.dataset.q===which));
}
function qs(){return `?level=${$('q_level').value}&greeted=${$('q_greeted').value}&account=${$('q_account').value}&today=${qToday}&date_from=${$('q_from').value}&date_to=${$('q_to').value}&q=${encodeURIComponent($('q_kw').value.trim())}`}
async function loadList(p){
pageCur=p||pageCur;syncTabs();
const d=await api(`/api/candidates${qs()}&page=${pageCur}&size=20`);
pageTotal=Math.max(1,Math.ceil(d.total/d.size));
$('totalInfo').textContent=`共 ${d.total} 人`;
$('pageNo').textContent=pageCur+' / '+pageTotal;
$('tbody').innerHTML=d.rows.map(r=>`<tr class="${r.is_today?'today':''}" onclick="showDetail('${r.id}')">
<td><b>${esc(r.name)}</b></td><td>${r.age??'-'}</td><td>${esc(r.education)||'-'}</td><td>${esc(r.experience)||'-'}</td>
<td>${esc(r.school)||'-'}</td><td>${esc(r.expected_city)||'-'}</td>
<td><b>${r.score??'-'}</b></td><td><span class="tag ${tagCls[r.score_level]||'no'}">${esc(r.score_level)||'-'}</span></td>
<td style="max-width:180px;overflow:hidden;text-overflow:ellipsis" title="${esc(r.notes)}">${esc(r.notes)||'-'}</td>
<td>${r.greeted?'<span class="tag yes">是</span>':'否'}</td><td>${esc(r.account)||'-'}</td>
<td>${r.scraped_at}${r.is_today?' <span class="tag maybe">今</span>':''}</td></tr>`).join('');
}
function page(d){const n=pageCur+d;if(n>=1&&n<=pageTotal)loadList(n)}
function exportCsv(){location.href='/api/export.csv'+qs()}
async function showDetail(id){
const r=await api('/api/candidates/'+encodeURIComponent(id));
// 评分明细 → 中文标签 + 五格能量条
let detHtml='';
try{
const j=JSON.parse(r.score_details||'{}');
const names={industryScore:'行业匹配',tradeScore:'外贸核心',langScore:'语言能力',channelScore:'客户开发',majorScore:'专业背景',stabilityScore:'稳定性',activityScore:'活跃度'};
const dims=Object.entries(j).filter(([k])=>names[k]);
if(dims.length)detHtml+='<div class="scores">'+dims.map(([k,v])=>`<div class="sc"><span class="k">${names[k]}</span><span class="bars">${[1,2,3,4,5].map(i=>`<i class="${i<=(+v||0)?'on':''}"></i>`).join('')}</span><b>${v??'-'}</b></div>`).join('')+'</div>';
const gr=j.gateReasons?(Array.isArray(j.gateReasons)?j.gateReasons:[j.gateReasons]):[];
if(gr.length)detHtml+='<div class="chips">'+gr.map(x=>`<span class="chip ok">✓ ${esc(String(x))}</span>`).join('')+'</div>';
if(j.gateReason)detHtml+=`<div class="chips"><span class="chip bad">✗ ${esc(j.gateReason)}</span></div>`;
}catch{}
// 简历全文 → 按段落标题分节高亮
let body=esc(r.raw_text||'(无)');
for(const t of ['教育经历','工作经历','项目经历','个人优势','专业技能','语言能力','所获证书','证书','求职期望','岗位经验'])
body=body.replace(new RegExp('^('+t+')$','gm'),'<span class="secTitle">$1</span>');
$('detailBody').innerHTML=`<h3>${esc(r.name)} <span class="tag ${tagCls[r.score_level]||'no'}">${esc(r.score_level)}</span> ${r.score}分 ${r.role_tag?`<span class="tag yes">${esc(r.role_tag)}</span>`:''}</h3>
<div class="kv">
<div><span>年龄</span> ${r.age??'-'}(${r.exp_years??'?'}年经验)</div>
<div><span>学历</span> ${esc(r.education)||'-'} ${esc(r.school)||''}</div>
<div><span>期望</span> ${esc(r.expected_city)||'-'} / ${esc(r.expected_salary)||'-'}</div>
<div><span>打招呼</span> ${r.greeted?'是 ('+esc(r.greeted_at)+')':'否'}</div>
<div><span>账号</span> ${esc(r.account)||'-'}</div>
<div><span>活跃度</span> ${esc(r.activity)||'-'}</div>
<div><span>抓取</span> ${esc(r.scraped_at)}</div>
<div><span>门槛</span> ${esc(r.notes)||'-'}</div>
</div>
${detHtml}
<h3 style="font-size:13px">简历全文</h3><div class="resume">${body}</div>`;
$('detailModal').classList.remove('hidden');
}
async function openUserModal(){
$('userModal').classList.remove('hidden');
loadUsers().catch(()=>toast('用户列表加载失败'));
}
async function loadUsers(){
const us=await api('/api/users');
$('utable').innerHTML=us.map(u=>`<tr>
<td>${u.id}</td><td><b>${esc(u.username)}</b></td><td>${u.role==='super'?'超管':'HR'}</td>
<td>${u.disabled?'<span class="tag irr">已停用</span>':'<span class="tag rec">正常</span>'}</td>
<td>${u.created_at||''}</td><td>${u.last_login_at||'-'}</td>
<td>${u.role==='super'?'-':`<button class="btn small" style="${u.disabled?'':'background:var(--warn)'}" onclick="toggleUser(${u.id},'${esc(u.username)}',${u.disabled})">${u.disabled?'启用':'停用'}</button>
<button class="btn sub small" onclick="resetPwd(${u.id},'${esc(u.username)}')">重置密码</button>`}</td></tr>`).join('');
}
async function addUser(){
try{await api('/api/users',{method:'POST',body:JSON.stringify({username:$('nu_user').value,password:$('nu_pass').value})});toast('已创建');$('nu_user').value=$('nu_pass').value='';loadUsers()}
catch(e){toast(e.message)}
}
async function openRulesModal(){
$('rulesModal').classList.remove('hidden');
if($('rulesBody').dataset.loaded)return;
try{
const md=await (await fetch('/api/rules')).text();
$('rulesBody').innerHTML=(typeof marked!=='undefined')?marked.parse(md):'<pre>'+esc(md)+'</pre>';
$('rulesBody').dataset.loaded='1';
}catch(e){$('rulesBody').textContent='加载失败: '+e.message}
}
async function openAccessModal(){
$('accessModal').classList.remove('hidden');
loadAccessLog().catch(()=>toast('访问记录加载失败'));
}
async function loadAccessLog(){
const list=await api('/api/access-log');
const actNames={login_ok:['登录成功','rec'],login_fail:['登录失败','irr'],logout:['退出','no'],register_super:['初始化超管','yes'],user_create:['创建账号','yes'],user_toggle:['停用/启用','maybe'],user_resetpwd:['重置密码','maybe'],log_clear:['清空记录','maybe']};
$('alogBody').innerHTML=list.length?list.map(l=>`<tr>
<td>${esc(l.created_at)}</td><td><b>${esc(l.username)||'-'}</b></td>
<td><span class="tag ${(actNames[l.action]||['','no'])[1]}">${(actNames[l.action]||[l.action,'no'])[0]}</span></td>
<td>${esc(l.detail)||'-'}</td><td style="color:var(--sub)">${esc(l.ip)||'-'}</td></tr>`).join('')
:'<tr><td colspan="5" style="text-align:center;color:var(--sub)">暂无记录</td></tr>';
}
async function clearAccessLog(){
if(!confirm('确定清空全部访问记录吗?此操作不可恢复'))return;
try{await api('/api/access-log/clear',{method:'POST'});toast('已清空');loadAccessLog()}catch(e){toast(e.message)}
}
async function toggleUser(id,name,disabled){
const verb=disabled?'启用':'停用';
if(!confirm(`确定${verb}账号「${name}」吗?`))return;
try{await api(`/api/users/${id}/toggle`,{method:'POST'});toast('已'+verb);loadUsers()}
catch(e){toast(e.message)}
}
async function resetPwd(id,name){
const p=prompt('为 '+name+' 设置新密码(至少6位)');if(!p)return;
try{await api(`/api/users/${id}/resetpwd`,{method:'POST',body:JSON.stringify({password:p})});toast('已重置')}
catch(e){toast(e.message)}
}
init();
</script>
</body>
</html>