顶栏任务状态灯: hr1/hr2四态(运行中/已收工/异常/今日未运行+聊天剩余), 只读挂载runner状态目录
This commit is contained in:
@@ -8,10 +8,13 @@ services:
|
||||
volumes:
|
||||
# 活库所在目录(供VACUUM INTO生成一致性快照, 不直接读)
|
||||
- /opt/hermes/data/recruitment-data:/src
|
||||
# 任务机状态文件(只读, 任务状态灯用)
|
||||
- /opt/hermes/data/logs/recruitment/state:/state:ro
|
||||
# 自身状态(users.db/secret/快照)持久化
|
||||
- ./data:/data/admin
|
||||
environment:
|
||||
- RECRUITMENT_SRC=/src/recruitment-live.db
|
||||
- RECRUITMENT_DB=/data/admin/snapshot.db
|
||||
- ADMIN_DIR=/data/admin
|
||||
- STATE_DIR=/state
|
||||
- TZ=Asia/Shanghai
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
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}
|
||||
.taskStatus{display:flex;gap:8px;align-items:center}
|
||||
.tchip{display:inline-flex;align-items:center;gap:5px;padding:3px 10px;border-radius:12px;font-size:12px;background:rgba(255,255,255,.08);color:#cbd5e1}
|
||||
.tchip .dot{width:8px;height:8px;border-radius:50%;background:#94a3b8}
|
||||
.tchip.running .dot{background:#22c55e;animation:pulse 1.5s infinite}
|
||||
.tchip.done .dot{background:#10b981}
|
||||
.tchip.error .dot{background:#ef4444;animation:pulse 1s infinite}
|
||||
.tchip.error{color:#fca5a5}
|
||||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
|
||||
.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}
|
||||
@@ -109,6 +117,7 @@ tr.today:hover td{background:#fde68a!important}
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<b>📋 招聘数据后台</b><span id="whoami" style="color:#94a3b8;font-size:12px"></span>
|
||||
<span class="taskStatus" id="taskStatus"></span>
|
||||
<span class="sp"></span>
|
||||
<button id="rulesBtn" class="hidden" onclick="openRulesModal()">筛选规则</button>
|
||||
<button id="userMgrBtn" class="hidden" onclick="openUserModal()">账号管理</button>
|
||||
@@ -275,7 +284,20 @@ async function enter(m){
|
||||
$('rulesBtn').classList.remove('hidden');
|
||||
if(m.role==='super'){$('userMgrBtn').classList.remove('hidden');$('accessLogBtn').classList.remove('hidden')}
|
||||
loadStats().catch(e=>dataErr(e));loadList(1).catch(e=>dataErr(e));
|
||||
refreshTaskStatus();
|
||||
}
|
||||
// ---------- 任务状态灯 ----------
|
||||
async function refreshTaskStatus(){
|
||||
try{
|
||||
const s=await api('/api/task-status');
|
||||
$('taskStatus').innerHTML=['hr1','hr2'].map(a=>{
|
||||
const t=s[a]||{state:'idle',text:'今日未运行'};
|
||||
const extra=t.chatLeft!=null?`·剩${t.chatLeft}次`:'';
|
||||
return `<span class="tchip ${t.state}" title="${a}"><span class="dot"></span>${a} ${t.text}${extra}</span>`;
|
||||
}).join('');
|
||||
}catch(_){}
|
||||
}
|
||||
setInterval(refreshTaskStatus,60000);
|
||||
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);
|
||||
|
||||
@@ -126,6 +126,8 @@ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
await $('#f_user').fill('hr01'); await $('#f_pass').fill('hr123456');
|
||||
await $('#loginBtn').click(); await sleep(900);
|
||||
ok('HR进入主界面', await $('#main').isVisible());
|
||||
const chips = await p.evaluate(() => document.getElementById('taskStatus').innerText);
|
||||
ok(`任务状态灯显示双账号(${chips.replace(/\s+/g,' ').trim().slice(0,30)})`, chips.includes('hr1') && chips.includes('hr2'));
|
||||
ok('HR看不到账号管理入口', await p.evaluate(() => getComputedStyle(document.getElementById('userMgrBtn')).display === 'none'));
|
||||
ok('HR能看到筛选规则入口', await p.evaluate(() => getComputedStyle(document.getElementById('rulesBtn')).display !== 'none'));
|
||||
const rulesForbidden = await p.evaluate(async () => (await fetch('/api/rules')).status);
|
||||
|
||||
@@ -286,6 +286,26 @@ app.post('/api/users/:id/resetpwd', requireAuth, requireSuper, (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ---------- 筛选任务状态(所有登录用户, 读runner状态文件) ----------
|
||||
app.get('/api/task-status', requireAuth, (req, res) => {
|
||||
const stateDir = process.env.STATE_DIR || '/state';
|
||||
const bjToday = new Date(Date.now() + 8 * 3600e3).toISOString().slice(0, 10);
|
||||
const doneSet = new Set(['time_cutoff', 'chat_exhausted', 'done']);
|
||||
const errSet = new Set(['login_expired', 'alert', 'tunnel_failed', 'platform_blocked', 'qr_expired', 'error', 'stopped_by_user']);
|
||||
const out = {};
|
||||
for (const acct of ['hr1', 'hr2']) {
|
||||
try {
|
||||
const s = JSON.parse(fs.readFileSync(path.join(stateDir, `state-${acct}.json`), 'utf8'));
|
||||
if (s.date !== bjToday) { out[acct] = { state: 'idle', text: '今日未运行' }; continue; }
|
||||
if (s.status === 'running') out[acct] = { state: 'running', text: '运行中', chatLeft: s.chatLeft };
|
||||
else if (doneSet.has(s.status)) out[acct] = { state: 'done', text: '已收工', chatLeft: s.chatLeft };
|
||||
else if (errSet.has(s.status)) out[acct] = { state: 'error', text: '异常停止', detail: s.status };
|
||||
else out[acct] = { state: 'done', text: '已收工', chatLeft: s.chatLeft };
|
||||
} catch (_) { out[acct] = { state: 'idle', text: '今日未运行' }; }
|
||||
}
|
||||
res.json(out);
|
||||
});
|
||||
|
||||
// ---------- 筛选规则文档(所有登录用户) ----------
|
||||
app.get('/api/rules', requireAuth, (req, res) => {
|
||||
res.type('text/markdown; charset=utf-8').send(fs.readFileSync(path.join(__dirname, 'public', 'rules.md'), 'utf8'));
|
||||
|
||||
Reference in New Issue
Block a user