Files
zhaopin-admin/public/index.html
T

220 lines
13 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}
.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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>