356 lines
22 KiB
HTML
356 lines
22 KiB
HTML
<!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}
|
||
.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}
|
||
.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}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="topbar">
|
||
<b>📋 招聘数据后台</b><span id="whoami" style="color:#94a3b8;font-size:12px"></span>
|
||
<span class="sp"></span>
|
||
<button id="userMgrBtn" class="hidden" onclick="openUserModal()">账号管理</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>期望城市 TOP8</h3><div id="chCity" 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="quickTabs">
|
||
<button class="tab on" data-q="" onclick="quickTab('')">全部</button>
|
||
<button class="tab" data-q="today" onclick="quickTab('today')">☀️ 今日</button>
|
||
<button class="tab" data-q="valid" onclick="quickTab('valid')">✅ 有效人员</button>
|
||
<button class="tab" data-q="rec" onclick="quickTab('rec')">推荐</button>
|
||
<button class="tab" data-q="maybe" onclick="quickTab('maybe')">待定</button>
|
||
<button class="tab" data-q="greeted" onclick="quickTab('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>
|
||
<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);
|
||
const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[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'}`;
|
||
if(m.role==='super'){$('userMgrBtn').classList.remove('hidden')}
|
||
loadStats().catch(e=>dataErr(e));loadList(1).catch(e=>dataErr(e));
|
||
}
|
||
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','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()));
|
||
let qToday=0;
|
||
function quickTab(which){
|
||
qToday=which==='today'?1:0;
|
||
$('q_level').value=which==='valid'?'有效':(which==='rec'?'推荐':(which==='maybe'?'待定':''));
|
||
$('q_greeted').value=which==='greeted'?'1':'';
|
||
if(which!==''){$('q_from').value='';$('q_to').value=''}
|
||
loadList(1);
|
||
}
|
||
function syncTabs(){
|
||
const which=qToday?'today':($('q_level').value==='有效'?'valid':$('q_level').value==='推荐'?'rec':$('q_level').value==='待定'?'maybe':($('q_greeted').value==='1'?'greeted':''));
|
||
document.querySelectorAll('#quickTabs .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 det='';try{const j=JSON.parse(r.score_details);det=Object.entries(j).map(([k,v])=>`${k}=${Array.isArray(v)?JSON.stringify(v):v}`).join(' ')}catch{}
|
||
$('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>
|
||
${det?`<div style="margin-bottom:10px;color:var(--sub)">${esc(det)}</div>`:''}
|
||
<h3 style="font-size:13px">简历全文</h3><pre>${esc(r.raw_text)}</pre>`;
|
||
$('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 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>
|