diff --git a/docker-compose.yml b/docker-compose.yml index 40b5a0e..1b12be6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/public/index.html b/public/index.html index 9e5c6d1..102899b 100644 --- a/public/index.html +++ b/public/index.html @@ -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}
📋 招聘数据后台 + @@ -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 `${a} ${t.text}${extra}`; + }).join(''); + }catch(_){} +} +setInterval(refreshTaskStatus,60000); function dataErr(e){ $('statCards').innerHTML=`
⚠ 数据加载失败
${esc(e.message)}
`; toast('数据加载失败: '+e.message); diff --git a/scripts/smoke.js b/scripts/smoke.js index 1a0b720..0d70260 100644 --- a/scripts/smoke.js +++ b/scripts/smoke.js @@ -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); diff --git a/server.js b/server.js index fd989b3..6248001 100644 --- a/server.js +++ b/server.js @@ -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'));