Files
zhaopin-admin/public/index.html
T

356 lines
22 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}
.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=>({'&':'&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'}`;
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>