Files

485 lines
30 KiB
HTML
Raw Permalink Normal View History

<!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.waiting .dot{background:#f59e0b;animation:pulse 1.5s infinite}
.tchip.waiting{color:#fcd34d}
.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{font-size:14px;background:#f9fafb;border:1px solid var(--line);border-radius:8px;padding:0;max-height:520px;overflow:auto;color:#374151}
.resume .zr-wrap{background:transparent}
.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.state==='running'?`·剩${t.chatLeft}次`:'';
const tip=t.detail?`(${t.detail})`:'';
return `<span class="tchip ${t.state}" title="${a}${tip}"><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" id="resumeBox">${body}</div>`;
$('detailModal').classList.remove('hidden');
loadResume(id);
}
async function loadResume(id){
try{
const html=await (await fetch('/api/candidates/'+encodeURIComponent(id)+'/resume-html')).text();
if(html && html.includes('zr-wrap')) $('resumeBox').innerHTML=html; // 渲染失败保留纯文本兜底
}catch(_){}
}
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>