v1.0 招聘数据后台: 首账号超管+HR账号+登录, 看板/候选人筛选/详情/CSV导出/账号管理, 只读live库, Docker部署
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
data/
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
FROM node:20-bookworm-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install --omit=dev --no-audit --no-fund
|
||||||
|
COPY server.js ./
|
||||||
|
COPY public ./public
|
||||||
|
ENV PORT=8300
|
||||||
|
EXPOSE 8300
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# zhaopin-admin — 招聘数据简单后台
|
||||||
|
|
||||||
|
只读展示生产库(`recruitment-live.db`)的轻量后台,无复杂角色体系。
|
||||||
|
|
||||||
|
## 权限模型
|
||||||
|
|
||||||
|
- **首个注册账号 = 超管**(系统无账号时注册开放,创建后注册永久关闭)
|
||||||
|
- 超管可创建/停用/重置 HR 账号
|
||||||
|
- 超管和 HR 都能看数据、导出 CSV;账号管理仅超管可见
|
||||||
|
- 登录态:HttpOnly 签名 Cookie,12 小时;密码 scrypt 加盐存储
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- 看板:累计/今日新增/打招呼数/各级别分布/近7天趋势(含打招呼)
|
||||||
|
- 候选人列表:级别、打招呼、账号、日期区间、关键词(姓名/学校/门槛备注)筛选 + 分页
|
||||||
|
- 详情:完整简历全文、评分明细、门槛命中原因
|
||||||
|
- 导出:当前筛选结果 CSV(上限1万行)
|
||||||
|
- 账号管理(超管):创建 HR、停用/启用、重置密码
|
||||||
|
|
||||||
|
## 部署(服务器 /www/projects/hs_zhaopin)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
docker compose up -d --build
|
||||||
|
# 访问 http://192.168.100.133:8300 首次打开即注册超管
|
||||||
|
```
|
||||||
|
|
||||||
|
- 数据源:宿主机 `/opt/hermes/data/recruitment-live.db` **只读**挂载,后台不会写生产库
|
||||||
|
- 自身状态(账号库 users.db、会话密钥)持久化在 `./data/`
|
||||||
|
- 端口 8300 仅内网;公网访问需另配 OpenResty 反代(勿直接路由器转发)
|
||||||
|
|
||||||
|
## 更新
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /www/projects/hs_zhaopin && git pull && docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 概览
|
||||||
|
|
||||||
|
| 接口 | 说明 | 权限 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET /api/bootstrap | 是否需要初始化超管 | 公开 |
|
||||||
|
| POST /api/register | 注册(仅无账号时) | 公开 |
|
||||||
|
| POST /api/login / logout / GET /api/me | 登录会话 | 公开/登录 |
|
||||||
|
| GET /api/stats | 看板统计 | 登录 |
|
||||||
|
| GET /api/candidates[?level&greeted&account&date_from&date_to&q&page&size] | 候选人列表 | 登录 |
|
||||||
|
| GET /api/candidates/:id | 候选人详情(含简历全文) | 登录 |
|
||||||
|
| GET /api/export.csv | 按当前筛选导出 | 登录 |
|
||||||
|
| GET/POST /api/users* | 账号管理 | 超管 |
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
services:
|
||||||
|
zhaopin-admin:
|
||||||
|
build: .
|
||||||
|
container_name: zhaopin-admin
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8300:8300" # 仅内网访问; 公网需经OpenResty反代,勿直接转发
|
||||||
|
volumes:
|
||||||
|
# 生产库只读挂载(宿主机路径)
|
||||||
|
- /opt/hermes/data/recruitment-data/recruitment-live.db:/data/recruitment.db:ro
|
||||||
|
# 自身状态(users.db/secret)持久化
|
||||||
|
- ./data:/data/admin
|
||||||
|
environment:
|
||||||
|
- RECRUITMENT_DB=/data/recruitment.db
|
||||||
|
- ADMIN_DIR=/data/admin
|
||||||
Generated
+1255
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "zhaopin-admin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "招聘数据简单后台(看板/候选人查询/CSV导出/账号管理)",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": { "start": "node server.js" },
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^12.2.0",
|
||||||
|
"express": "^4.19.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
<!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}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar">
|
||||||
|
<b>📋 招聘数据后台</b><span id="whoami" style="color:#94a3b8;font-size:12px"></span>
|
||||||
|
<span class="sp"></span>
|
||||||
|
<button 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')" style="position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:8;display:flex;align-items:center;justify-content:center" 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');
|
||||||
|
$('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>
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
// zhaopin-admin — 招聘数据简单后台
|
||||||
|
// 模型: 首个注册账号=超管(super), 之后注册关闭, 超管创建HR账号; 全部接口需登录
|
||||||
|
// 数据: 只读挂载生产库(live.db); 自身状态存 /data/admin/(users.db + secret)
|
||||||
|
'use strict';
|
||||||
|
const express = require('express');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 8300;
|
||||||
|
const ADMIN_DIR = process.env.ADMIN_DIR || '/data/admin';
|
||||||
|
const RECRUITMENT_DB = process.env.RECRUITMENT_DB || '/data/recruitment.db';
|
||||||
|
fs.mkdirSync(ADMIN_DIR, { recursive: true });
|
||||||
|
|
||||||
|
// ---------- 会话密钥(首次启动生成,持久化) ----------
|
||||||
|
const SECRET_FILE = path.join(ADMIN_DIR, 'secret');
|
||||||
|
if (!fs.existsSync(SECRET_FILE)) fs.writeFileSync(SECRET_FILE, crypto.randomBytes(32));
|
||||||
|
const SECRET = fs.readFileSync(SECRET_FILE, 'utf8').trim();
|
||||||
|
|
||||||
|
// ---------- 密码: scrypt ----------
|
||||||
|
function hashPassword(pwd) {
|
||||||
|
const salt = crypto.randomBytes(16).toString('hex');
|
||||||
|
const hash = crypto.scryptSync(pwd, salt, 32).toString('hex');
|
||||||
|
return `${salt}:${hash}`;
|
||||||
|
}
|
||||||
|
function verifyPassword(pwd, stored) {
|
||||||
|
const [salt, hash] = String(stored).split(':');
|
||||||
|
if (!salt || !hash) return false;
|
||||||
|
const calc = crypto.scryptSync(pwd, salt, 32).toString('hex');
|
||||||
|
return crypto.timingSafeEqual(Buffer.from(calc), Buffer.from(hash));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 用户库 ----------
|
||||||
|
const udb = new Database(path.join(ADMIN_DIR, 'users.db'));
|
||||||
|
udb.pragma('journal_mode = WAL');
|
||||||
|
udb.exec(`CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'hr',
|
||||||
|
disabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||||
|
last_login_at TEXT
|
||||||
|
)`);
|
||||||
|
const userCount = () => udb.prepare('SELECT COUNT(*) c FROM users').get().c;
|
||||||
|
|
||||||
|
// ---------- 招聘库(只读) ----------
|
||||||
|
const rdb = new Database(RECRUITMENT_DB, { readonly: true });
|
||||||
|
|
||||||
|
// ---------- 会话(内存态,重启需重登) ----------
|
||||||
|
const sessions = new Map(); // sid -> { uid, role, expires }
|
||||||
|
const SESSION_HOURS = 12;
|
||||||
|
function newSession(uid, role) {
|
||||||
|
const sid = crypto.randomBytes(24).toString('hex');
|
||||||
|
sessions.set(sid, { uid, role, expires: Date.now() + SESSION_HOURS * 3600e3 });
|
||||||
|
return sid;
|
||||||
|
}
|
||||||
|
function signSid(sid) { return sid + '.' + crypto.createHmac('sha256', SECRET).update(sid).digest('hex').slice(0, 16); }
|
||||||
|
function parseSid(cookie) {
|
||||||
|
const m = /session=([a-f0-9]+\.[a-f0-9]+)/.exec(cookie || '');
|
||||||
|
if (!m) return null;
|
||||||
|
const [sid, sig] = m[1].split('.');
|
||||||
|
if (crypto.createHmac('sha256', SECRET).update(sid).digest('hex').slice(0, 16) !== sig) return null;
|
||||||
|
return sid;
|
||||||
|
}
|
||||||
|
function authUser(req) {
|
||||||
|
const sid = parseSid(req.headers.cookie);
|
||||||
|
if (!sid) return null;
|
||||||
|
const s = sessions.get(sid);
|
||||||
|
if (!s || s.expires < Date.now()) { sessions.delete(sid); return null; }
|
||||||
|
return udb.prepare('SELECT id, username, role FROM users WHERE id = ? AND disabled = 0').get(s.uid) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
// ---------- 认证接口 ----------
|
||||||
|
app.get('/api/bootstrap', (req, res) => res.json({ needsInit: userCount() === 0 }));
|
||||||
|
app.post('/api/register', (req, res) => { // 仅无任何账号时开放, 首个=超管
|
||||||
|
if (userCount() > 0) return res.status(403).json({ error: '注册已关闭,请联系管理员创建账号' });
|
||||||
|
const { username, password } = req.body || {};
|
||||||
|
if (!username || !password || String(username).length < 2 || String(password).length < 6)
|
||||||
|
return res.status(400).json({ error: '用户名至少2位,密码至少6位' });
|
||||||
|
const info = udb.prepare('INSERT INTO users (username, password, role) VALUES (?, ?, ?)')
|
||||||
|
.run(String(username).trim(), hashPassword(String(password)), 'super');
|
||||||
|
res.json({ ok: true, role: 'super', id: info.lastInsertRowid });
|
||||||
|
});
|
||||||
|
app.post('/api/login', (req, res) => {
|
||||||
|
const { username, password } = req.body || {};
|
||||||
|
const u = udb.prepare('SELECT * FROM users WHERE username = ?').get(String(username || '').trim());
|
||||||
|
if (!u || !verifyPassword(String(password || ''), u.password)) return res.status(401).json({ error: '用户名或密码错误' });
|
||||||
|
if (u.disabled) return res.status(403).json({ error: '账号已停用' });
|
||||||
|
udb.prepare("UPDATE users SET last_login_at = datetime('now','localtime') WHERE id = ?").run(u.id);
|
||||||
|
res.setHeader('Set-Cookie', `session=${signSid(newSession(u.id, u.role))}; HttpOnly; Path=/; SameSite=Lax; Max-Age=${SESSION_HOURS * 3600}`);
|
||||||
|
res.json({ ok: true, username: u.username, role: u.role });
|
||||||
|
});
|
||||||
|
app.post('/api/logout', (req, res) => {
|
||||||
|
const sid = parseSid(req.headers.cookie);
|
||||||
|
if (sid) sessions.delete(sid);
|
||||||
|
res.setHeader('Set-Cookie', 'session=; HttpOnly; Path=/; Max-Age=0');
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
app.get('/api/me', (req, res) => {
|
||||||
|
const u = authUser(req);
|
||||||
|
if (!u) return res.status(401).json({ error: '未登录' });
|
||||||
|
res.json({ username: u.username, role: u.role });
|
||||||
|
});
|
||||||
|
|
||||||
|
function requireAuth(req, res, next) {
|
||||||
|
const u = authUser(req);
|
||||||
|
if (!u) return res.status(401).json({ error: '未登录' });
|
||||||
|
req.user = u; next();
|
||||||
|
}
|
||||||
|
function requireSuper(req, res, next) {
|
||||||
|
if (req.user.role !== 'super') return res.status(403).json({ error: '需要超管权限' });
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 数据接口 ----------
|
||||||
|
app.get('/api/stats', requireAuth, (req, res) => {
|
||||||
|
const today = new Date(Date.now() + 8 * 3600e3).toISOString().slice(0, 10);
|
||||||
|
res.json({
|
||||||
|
total: rdb.prepare('SELECT COUNT(*) c FROM candidates').get().c,
|
||||||
|
today: rdb.prepare("SELECT COUNT(*) c FROM candidates WHERE scraped_at >= ?").get(today + 'T00:00').c,
|
||||||
|
greetedTotal: rdb.prepare('SELECT COUNT(*) c FROM candidates WHERE greeted = 1').get().c,
|
||||||
|
byLevel: rdb.prepare("SELECT COALESCE(score_level,'?') k, COUNT(*) c FROM candidates GROUP BY score_level").all(),
|
||||||
|
last7: rdb.prepare(`SELECT substr(scraped_at,1,10) d, COUNT(*) c,
|
||||||
|
SUM(CASE WHEN score_level='推荐' THEN 1 ELSE 0 END) rec,
|
||||||
|
SUM(CASE WHEN greeted=1 THEN 1 ELSE 0 END) greeted
|
||||||
|
FROM candidates WHERE scraped_at >= date('now','localtime','-6 days','start of day')
|
||||||
|
GROUP BY substr(scraped_at,1,10) ORDER BY d`).all(),
|
||||||
|
lastBatch: rdb.prepare('SELECT created_at, mode, new_count, greet_count FROM scrape_log ORDER BY id DESC LIMIT 1').get() || null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/candidates', requireAuth, (req, res) => {
|
||||||
|
const { level, greeted, account, date_from, date_to, q } = req.query;
|
||||||
|
const where = []; const params = [];
|
||||||
|
if (level) { where.push('score_level = ?'); params.push(level); }
|
||||||
|
if (greeted === '1' || greeted === '0') { where.push('greeted = ?'); params.push(greeted); }
|
||||||
|
if (account) { where.push('account = ?'); params.push(account); }
|
||||||
|
if (date_from) { where.push('scraped_at >= ?'); params.push(date_from + 'T00:00'); }
|
||||||
|
if (date_to) { where.push('scraped_at <= ?'); params.push(date_to + 'T23:59'); }
|
||||||
|
if (q) { where.push('(name LIKE ? OR school LIKE ? OR notes LIKE ?)'); const like = `%${q}%`; params.push(like, like, like); }
|
||||||
|
const w = where.length ? 'WHERE ' + where.join(' AND ') : '';
|
||||||
|
const page = Math.max(1, parseInt(req.query.page) || 1);
|
||||||
|
const size = Math.min(100, Math.max(10, parseInt(req.query.size) || 20));
|
||||||
|
const total = rdb.prepare(`SELECT COUNT(*) c FROM candidates ${w}`).get(...params).c;
|
||||||
|
const rows = rdb.prepare(`SELECT id, name, age, education, experience, school, expected_city, expected_salary,
|
||||||
|
score, score_level, role_tag, greeted, account, substr(scraped_at,1,10) scraped_at, notes
|
||||||
|
FROM candidates ${w} ORDER BY scraped_at DESC, rowid DESC LIMIT ? OFFSET ?`).all(...params, size, (page - 1) * size);
|
||||||
|
res.json({ total, page, size, rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/candidates/:id', requireAuth, (req, res) => {
|
||||||
|
const row = rdb.prepare(`SELECT id, name, age, education, experience, exp_years, school, expected_city,
|
||||||
|
expected_salary, score, score_level, role_tag, greeted, greeted_at, account, activity,
|
||||||
|
raw_text, education_detail, work_detail, cert_detail, score_details, notes, scraped_at, updated_at
|
||||||
|
FROM candidates WHERE id = ?`).get(req.params.id);
|
||||||
|
if (!row) return res.status(404).json({ error: '不存在' });
|
||||||
|
res.json(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/export.csv', requireAuth, (req, res) => {
|
||||||
|
const { level, greeted, date_from, date_to, q } = req.query;
|
||||||
|
const where = []; const params = [];
|
||||||
|
if (level) { where.push('score_level = ?'); params.push(level); }
|
||||||
|
if (greeted === '1' || greeted === '0') { where.push('greeted = ?'); params.push(greeted); }
|
||||||
|
if (date_from) { where.push('scraped_at >= ?'); params.push(date_from + 'T00:00'); }
|
||||||
|
if (date_to) { where.push('scraped_at <= ?'); params.push(date_to + 'T23:59'); }
|
||||||
|
if (q) { where.push('(name LIKE ? OR school LIKE ? OR notes LIKE ?)'); const like = `%${q}%`; params.push(like, like, like); }
|
||||||
|
const w = where.length ? 'WHERE ' + where.join(' AND ') : '';
|
||||||
|
const rows = rdb.prepare(`SELECT name, age, education, experience, school, expected_city, expected_salary,
|
||||||
|
score, score_level, role_tag, greeted, account, substr(scraped_at,1,10) scraped_at, substr(notes,1,80) notes
|
||||||
|
FROM candidates ${w} ORDER BY scraped_at DESC LIMIT 10000`).all(...params);
|
||||||
|
const esc = (v) => '"' + String(v ?? '').replace(/"/g, '""') + '"';
|
||||||
|
const header = '姓名,年龄,学历,经验,学校,期望城市,期望薪资,评分,级别,岗位标签,已打招呼,账号,抓取日期,门槛备注';
|
||||||
|
const csv = '\ufeff' + [header, ...rows.map(r2 => [r2.name, r2.age, r2.education, r2.experience, r2.school, r2.expected_city, r2.expected_salary, r2.score, r2.score_level, r2.role_tag, r2.greeted ? '是' : '否', r2.account, r2.scraped_at, r2.notes].map(esc).join(','))].join('\r\n');
|
||||||
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="candidates-${Date.now()}.csv"`);
|
||||||
|
res.send(csv);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 账号管理(超管) ----------
|
||||||
|
app.get('/api/users', requireAuth, requireSuper, (req, res) => {
|
||||||
|
res.json(udb.prepare('SELECT id, username, role, disabled, created_at, last_login_at FROM users ORDER BY id').all());
|
||||||
|
});
|
||||||
|
app.post('/api/users', requireAuth, requireSuper, (req, res) => {
|
||||||
|
const { username, password } = req.body || {};
|
||||||
|
if (!username || !password || String(password).length < 6) return res.status(400).json({ error: '用户名必填,密码至少6位' });
|
||||||
|
try {
|
||||||
|
const info = udb.prepare("INSERT INTO users (username, password, role) VALUES (?, ?, 'hr')")
|
||||||
|
.run(String(username).trim(), hashPassword(String(password)));
|
||||||
|
res.json({ ok: true, id: info.lastInsertRowid });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(400).json({ error: '用户名已存在' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.post('/api/users/:id/toggle', requireAuth, requireSuper, (req, res) => {
|
||||||
|
const u = udb.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
|
||||||
|
if (!u) return res.status(404).json({ error: '不存在' });
|
||||||
|
if (u.role === 'super' && !u.disabled) return res.status(400).json({ error: '不能停用自己的超管账号' });
|
||||||
|
udb.prepare('UPDATE users SET disabled = ? WHERE id = ?').run(u.disabled ? 0 : 1, u.id);
|
||||||
|
res.json({ ok: true, disabled: u.disabled ? 0 : 1 });
|
||||||
|
});
|
||||||
|
app.post('/api/users/:id/resetpwd', requireAuth, requireSuper, (req, res) => {
|
||||||
|
const { password } = req.body || {};
|
||||||
|
if (!password || String(password).length < 6) return res.status(400).json({ error: '密码至少6位' });
|
||||||
|
const info = udb.prepare('UPDATE users SET password = ? WHERE id = ?').run(hashPassword(String(password)), req.params.id);
|
||||||
|
if (!info.changes) return res.status(404).json({ error: '不存在' });
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, '0.0.0.0', () => console.log(`[zhaopin-admin] listening on :${PORT}, db=${RECRUITMENT_DB}, users=${userCount()}`));
|
||||||
Reference in New Issue
Block a user