220 lines
13 KiB
HTML
220 lines
13 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}
|
||
#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}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="topbar">
|
||
<b>📋 招聘数据后台</b><span id="whoami" style="color:#94a3b8;font-size:12px"></span>
|
||
<span class="sp"></span>
|
||
<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>
|
||
<input id="f_user" placeholder="用户名" autocomplete="username">
|
||
<input id="f_pass" type="password" placeholder="密码(至少6位)" autocomplete="current-password">
|
||
<button class="btn" id="loginBtn" onclick="doLoginOrInit()">登录</button>
|
||
<div class="note" id="loginNote"></div>
|
||
</div>
|
||
</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>候选人</h3>
|
||
<div class="filters">
|
||
<select id="q_level"><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"><span style="color:var(--sub)">至</span><input type="date" id="q_to">
|
||
<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>
|
||
<div class="panel hidden" id="userPanel">
|
||
<h3>账号管理(仅超管)</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>
|
||
</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>
|
||
let me=null,pageCur=1,pageTotal=1;
|
||
const $=id=>document.getElementById(id);
|
||
const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||
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'){$('userPanel').classList.remove('hidden');loadUsers()}
|
||
loadStats();loadList(1);
|
||
}
|
||
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('')}`;
|
||
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('');
|
||
}
|
||
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;
|
||
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 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}</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/'+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 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 sub small" onclick="toggleUser(${u.id})">${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){await api(`/api/users/${id}/toggle`,{method:'POST'});loadUsers()}
|
||
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>
|