// 监控页:设备状态表(排序/多选/截图/异常汇总/任务运行概况) // ================== 设备列表排序与多选 ================== let _selectedSerials=new Set(); // 已勾选的 serial 集合 let _locatingSerials=new Set(); // 定位中的设备(按钮显示"结束定位") // 排序比较:从设备对象取排序值(统一排序用,见 setListPager/sortValue) function _sortValue(dev,key){ let v; if(key==='progress'){ const p=dev.progress||{}; v=(p.done||0)+(((p.total||0)>0?(p.done||0)/(p.total):0)*10000); }else if(key==='worker_status'){ // 按状态优先级排序:running>connecting>idle>done>error>released const order={running:0,connecting:1,idle:2,done:3,released:4,error:5,failed:6}; v=order[dev.worker_status]||99; }else{ v=dev[key]||''; } return v; } // 全选/取消全选 function toggleAllDevices(checked){ if(checked){ _statusData.devices.forEach(d=>_selectedSerials.add(d.serial)); }else{ _selectedSerials.clear(); } renderDevices(); updateSelCount(); } // 单个勾选 function toggleDevice(serial,checked){ if(checked){_selectedSerials.add(serial);}else{_selectedSerials.delete(serial);} updateSelCount(); // 同步全选框状态 const all=document.getElementById('cb-all'); if(all){ const total=_statusData?(_statusData.devices||[]).length:0; all.checked=total>0&&_selectedSerials.size===total; } } function updateSelCount(){ const n=_selectedSerials.size; const el=document.getElementById('sel-count'); if(el)el.textContent=n>0?('已选 '+n+' 台'):'未选中'; const btn=document.getElementById('btn-stop-selected'); if(btn)btn.disabled=n===0; } // 批量停止选中的运行中设备 async function stopSelectedDevices(){ const serials=Array.from(_selectedSerials); if(serials.length===0){showToast('请先勾选设备','error');return;} // 只停止运行中/连接中的 const toStop=serials.filter(s=>{ const d=(_statusData.devices||[]).find(x=>x.serial===s); return d&&(d.worker_status==='running'||d.worker_status==='connecting'); }); if(toStop.length===0){showToast('选中的设备没有运行中的任务','error');return;} if(!confirm('确定停止选中的 '+toStop.length+' 台设备的任务吗?'))return; let ok=0,fail=0; for(const s of toStop){ const r=await apiPost('/api/stop_device',{serial:s}); if(r&&r.ok){ok++;}else{fail++;} } showToast('已停止 '+ok+' 台'+(fail>0?(',失败 '+fail+' 台'):''),ok>0?'success':'error'); setTimeout(loadMonitor,500); } // 定位设备:点亮屏幕 + 打开大字定位页(显示设备 IP),便于在机架上找到目标 // 注意:打开定位页会切换设备前台,任务运行中慎用 async function locateDevice(serial){ if(!confirm('点亮 '+serial+' 屏幕并打开大字定位页?\n\n(会切换设备前台显示,任务运行中慎用)'))return; const r=await apiPost('/api/device/locate',{serial:serial,show:true}); if(!r)return; if(r.ok){ _locatingSerials.add(serial); showToast(r.msg||'已定位','success'); renderDevices(); }else showToast(r.error||'定位失败','error'); } // 结束定位:关闭设备上的定位页(浏览器),按钮恢复为"定位" async function stopLocate(serial){ const r=await apiPost('/api/device/locate/stop',{serial:serial}); if(!r)return; _locatingSerials.delete(serial); renderDevices(); if(r.ok)showToast(r.msg||'已结束定位','success'); else showToast(r.error||'结束定位失败','error'); } // 批量亮屏/息屏所有在线设备(息屏会中断运行中任务,前端已确认) async function screenAllDevices(mode){ if(mode==='off'&&!confirm('确定让所有在线设备息屏?\n\n运行中的任务会因屏幕熄灭而中断,请谨慎操作。'))return; const r=await apiPost('/api/device/screen_all',{mode:mode}); if(!r)return; if(r.ok)showToast((mode==='off'?'已息屏':'已亮屏')+' '+r.success+'/'+r.total+' 台设备','success'); else showToast(r.error||'操作失败','error'); } // 清除单台设备的异常状态(error/failed → 未启动) async function clearDeviceError(serial){ if(!confirm('确认清除设备 '+serial+' 的异常状态?'))return; const r=await apiPost('/api/device/clear_error',{serial:serial}); handleResult(r,()=>loadMonitor()); } // 一键清除所有异常/失败设备的错误状态(运行中的任务不受影响) async function clearAllErrors(){ if(!confirm('确认清除所有设备的异常状态?(运行中/等待重试的设备会跳过)'))return; const r=await apiPost('/api/device/clear_all_errors'); handleResult(r,()=>loadMonitor()); } // ================== 设备画面查看 ================== let _shotTimer=null; // 自动刷新定时器 let _shotSerial=''; // 当前查看的 serial let _shotLoading=false; // 防止并发请求 // 打开截图弹窗 function openScreenshot(serial){ _shotSerial=serial; _shotLoading=false; // 渲染弹窗 document.getElementById('modal-title').textContent='设备画面 - '+serial; document.getElementById('modal-body').innerHTML= '
正在获取画面...
'+ '
'+ ''+ ''+ ''+ '
'; document.getElementById('modal-footer').innerHTML=''; // 切换为截图弹窗样式(自适应大小) document.getElementById('modal-box').classList.add('shot-modal'); showModal(); // 立即取一次 refreshShot(); // 开启自动刷新 startShotTimer(); } // 关闭截图弹窗(停掉定时器 + 关闭 modal + 还原样式) function closeShotModal(){ stopShotTimer(); _shotSerial=''; document.getElementById('modal-box').classList.remove('shot-modal'); closeModal(); } function toggleShotAuto(){ const cb=document.getElementById('shot-auto'); if(cb&&cb.checked){startShotTimer();}else{stopShotTimer();} } function startShotTimer(){ stopShotTimer(); _shotTimer=setInterval(refreshShot,3000); } function stopShotTimer(){ // 用 typeof 容错:变量未声明时不报错(避免 closeModal 调用时抛异常导致弹窗关不掉) if(typeof _shotTimer!=='undefined'&&_shotTimer){clearInterval(_shotTimer);_shotTimer=null;} } // 取一次截图 async function refreshShot(){ if(!_shotSerial)return; if(_shotLoading)return; // 上一次还没回来,跳过这次 _shotLoading=true; const el=document.getElementById('shot-content'); if(!el){_shotLoading=false;return;} // 首次或出错后显示 loading if(!el.querySelector('img')){ el.className='shot-loading'; el.textContent='正在获取画面...'; } const url='/api/device/screenshot?serial='+encodeURIComponent(_shotSerial)+'&t='+Date.now(); try{ const r=await fetch(url); if(r.status===401){window.location='/login';return;} if(!r.ok){ const j=await r.json().catch(()=>({})); throw new Error(j.error||('HTTP '+r.status)); } const blob=await r.blob(); const objUrl=URL.createObjectURL(blob); el.className=''; el.innerHTML='设备画面'; // 更新时间 const tEl=document.getElementById('shot-time'); if(tEl)tEl.textContent='更新于 '+new Date().toLocaleTimeString(); // 释放上一个 objUrl(避免内存泄漏) if(el._lastObjUrl)URL.revokeObjectURL(el._lastObjUrl); el._lastObjUrl=objUrl; }catch(e){ el.className='shot-err'; el.textContent='截图失败:'+e.message; }finally{ _shotLoading=false; } } // ================== Tab 1: 监控 ================== let _statusData=null; let _jobsData=null; async function loadMonitor(){ try{ const r=await apiGet('/api/status');if(!r||!r.ok)return; _statusData=r; document.getElementById('srvTime').textContent=new Date(r.server_time*1000).toLocaleTimeString(); // 前台 App 扫描状态 const fgStatus=document.getElementById('fg-status'); const scanBtn=document.getElementById('btn-scan-fg'); if(r.fg_scanning){ fgStatus.innerHTML='扫描中...'; scanBtn.disabled=true;scanBtn.textContent='扫描中...'; }else{ scanBtn.disabled=false;scanBtn.textContent='扫描前台App'; if(r.fg_last_scan){ fgStatus.innerHTML='上次扫描: '+new Date(r.fg_last_scan*1000).toLocaleTimeString(); }else{ fgStatus.innerHTML='未扫描'; } } const devices=r.devices||[]; const running=devices.filter(x=>x.worker_status==='running').length; const idle=devices.filter(x=>x.worker_status==='idle').length; const error=devices.filter(x=>['error','failed'].includes(x.worker_status)).length; const total=devices.length; const online=devices.filter(x=>x.present).length; const cards=[ {n:total,l:'设备总数',c:'#3b82f6'}, {n:online,l:'在线',c:'#10b981'}, {n:running,l:'运行中',c:'#10b981'}, {n:idle,l:'空闲',c:'#94a3b8'}, {n:error,l:'异常',c:'#ef4444'}, ]; document.getElementById('stats').innerHTML=cards.map(c=> '
'+c.n+'
'+c.l+'
' ).join(''); // 渲染设备表格(含排序+多选) renderDevices(); // 同步全选框状态 const all=document.getElementById('cb-all'); if(all){ all.checked=devices.length>0&&_selectedSerials.size===devices.length; } updateSelCount(); // 加载任务卡片 loadJobsForMonitor(); // 加载异常汇总 loadSummary(); }catch(e){console.error(e);} } // ===== 异常设备汇总 ===== async function loadSummary(){ try{ const r=await apiGet('/api/summary'); if(!r||!r.ok)return; const counts=r.counts||{}; const errors=r.errors||[]; const errCount=(counts.error||0)+(counts.failed||0); const badge=document.getElementById('summary-badge'); const panel=document.getElementById('summary-panel'); const empty=document.getElementById('summary-empty'); if(badge)badge.textContent='· 异常 '+errCount+' · 运行 '+counts.running+' · 完成 '+counts.done; if(!panel||!empty)return; if(!errors.length){ panel.style.display='none'; empty.style.display='block'; return; } panel.style.display='block'; empty.style.display='none'; const tb=document.getElementById('tb-summary'); tb.innerHTML=errors.map(e=>{ const st=e.status==='failed'?'失败':'异常'; const up=e.updated?new Date(e.updated*1000).toLocaleTimeString():''; return ''+ ''+esc(e.serial)+''+(e.model?' '+esc(e.model)+'':'')+''+ ''+st+''+ ''+esc(e.task||'-')+''+ ''+(e.attempt>0?e.attempt+'次':'-')+''+ ''+esc(e.last_error||'-')+''+ ''; }).join(''); }catch(e){console.error(e);} } // 渲染设备表格(统一排序+搜索+分页,复选状态保持) let _devicesSig=''; function renderDevices(){ if(!_statusData||!_statusData.devices){return;} // 5秒自动刷新时若设备数据/分页/搜索/排序/定位状态都没变化,跳过整表重渲染(避免DOM抖动) const _st=_lp('devices'); const _sig=JSON.stringify({d:_statusData.devices,p:_st.page,k:_st.kw,s:_st.sort, l:[..._locatingSerials].sort().join(',')}); if(_sig===_devicesSig)return; _devicesSig=_sig; // 排序由 applyListPager 统一处理(sortValue + _lpSort) setListPager('devices',{ data:()=>_statusData.devices||[], filter:(dev,kw)=>((dev.serial||'').toLowerCase().includes(kw)||(dev.model||'').toLowerCase().includes(kw)||(dev.foreground_app||'').toLowerCase().includes(kw)||(dev.task_job||'').toLowerCase().includes(kw)||(dev.current_action||'').toLowerCase().includes(kw)), sortValue:_sortValue, render:renderDeviceRow, tbody:'tb-devices',pager:'pager-devices',pageSize:10, empty:'暂无设备' }); applyListPager('devices'); initListSearch('devices','search-devices'); } function renderDeviceRow(dev){ const offline=dev.present?'':' 离线'; const st=STATUS_MAP[dev.worker_status]||{t:dev.worker_status,c:'default'}; const wb=''+st.t+''; const fgApp=dev.foreground_app; const fg=fgApp?( fgApp==='(他人占用)'?'他人占用': fgApp==='(不可达)'?'不可达': ''+esc(APP_NAMES[fgApp]||fgApp)+'' ):'-'; const prog=fmtProgress(dev.progress, dev.worker_status); const tname=dev.task_job?''+esc(dev.task_job)+'':'-'; const retry=dev.attempt>0?' '+dev.attempt+'次':''; // 预计结束时间(设置了运行时长上限时显示) const endInfo=(dev.worker_status==='running'&&dev.end_time)?'
预计结束 '+new Date(dev.end_time*1000).toLocaleTimeString()+'
':''; // 选择器健康警告(某步骤连续未找到元素,可能 App 改版选择器失效) const warnInfo=dev.last_warning?'
⚠ '+esc(dev.last_warning)+'
':''; // 设备操作按钮需要"设备控制"权限(无权限只保留截图查看) const canDev=_can('devices'); const stopBtn=canDev&&(dev.worker_status==='running'||dev.worker_status==='connecting')?'':''; // 截图按钮(任何状态都可看,离线时会报错) const shotBtn=''; // 定位按钮:定位中显示"结束定位"(关闭定位页),否则显示"定位"(点亮+大字页) const locating=_locatingSerials.has(dev.serial); const locateBtn=canDev?(locating ?'' :''):''; // 异常/失败设备提供"清除异常"(把状态恢复为未启动) const clearBtn=canDev&&(dev.worker_status==='error'||dev.worker_status==='failed') ?'':''; const btns='
'+shotBtn+locateBtn+clearBtn+stopBtn+'
'; const devName=dev.device_name||dev.model||''; const devLabel=devName?''+esc(dev.serial)+'
'+esc(devName)+'':''+esc(dev.serial)+''; // 复选框列 const checked=_selectedSerials.has(dev.serial)?'checked':''; const rowCls=_selectedSerials.has(dev.serial)?'row-checked':''; const cb=''; return ''+cb+''+devLabel+''+esc(dev.model)+''+offline+''+wb+''+fg+''+prog+''+tname+retry+endInfo+''+esc(dev.current_action||'-')+warnInfo+''+esc(dev.last_error||'-')+''+btns+''; } async function loadJobsForMonitor(){ try{ const r=await apiGet('/api/jobs');if(!r||!r.ok)return; _jobsData=r.jobs||[]; renderJobCards(); }catch(e){console.error(e);} } // 通用进度展示:解析 progress = {done, total, unit, action_counts, elapsed} // 任意 app 都能用:显示进度条 + "done/total unit" + 运行时长 + 操作计数徽章 // 下次运行时间格式化:"2026-08-12 09:00:00" → "08-12 09:00" function fmtNextRun(iso){ if(!iso)return ''; const s=String(iso).replace('T',' '); return s.length>=16?s.slice(5,16):s; } function fmtProgress(progress, workerStatus){ if(!progress||!Object.keys(progress).length){ // 空闲/未运行 → 显示"-" return '-'; } const done=progress.done||0; const total=progress.total||0; const unit=progress.unit||''; const elapsed=progress.elapsed||0; const counts=progress.action_counts||{}; // 进度条(百分比) let barHtml=''; if(total>0){ const pct=Math.min(100,Math.round(done*100/total)); barHtml='
'; } // 计数文本:"5/80 视频" 或 "5 视频"(无 total 时) let countHtml=''; if(total>0){ countHtml=''+done+'/'+total+(unit?' '+esc(unit):'')+''; }else if(done>0){ countHtml=''+done+(unit?' '+esc(unit):'')+''; } // 运行时长(elapsed>0 才显示) let timeHtml=''; if(elapsed>0){ const m=Math.floor(elapsed/60), s=elapsed%60; timeHtml=' '+m+'m'+s+'s'; } // 操作计数徽章(按数量降序,最多显示6个,超出的折叠为 +N) const countEntries=Object.entries(counts).filter(([_,v])=>v>0).sort((a,b)=>b[1]-a[1]); const MAX_COUNTS=6; const shown=countEntries.slice(0,MAX_COUNTS); const more=countEntries.length-MAX_COUNTS; let countsHtml=''; if(shown.length){ countsHtml='
'+shown.map(([k,v])=> ''+(ACTION_LABELS[k]||esc(k))+' '+v+'' ).join('')+(more>0?' +'+more+'':'')+'
'; } // 空闲/完成但无进度数据 → 灰显 if(done===0&&total===0&&elapsed===0&&!countParts.length){ return '-'; } return '
'+barHtml+countHtml+timeHtml+'
'+countsHtml; } function fmtParams(params){ if(!params||!Object.keys(params).length)return ''; const parts=[]; for(const [k,v] of Object.entries(params)){ if(k==='actions'){ if(v&&Object.keys(v).length){ const on=Object.entries(v).filter(([_,c])=>c.enabled).map(([a])=>ACTION_LABELS[a]||a); if(on.length)parts.push('操作:'+on.join('/')+''); } continue; } const val=typeof v==='boolean'?(v?'开':'关'):(Array.isArray(v)?v.length+'条':v); parts.push(''+k+'='+val+''); } return parts.join(''); } function renderJobCards(){ const jobs=_jobsData||[]; const running={}; if(_statusData)for(const dev of _statusData.devices){ if(dev.task_job)running[dev.task_job]=(running[dev.task_job]||0)+1; } document.getElementById('jobs-grid').innerHTML=jobs.length?jobs.map(j=>{ const runCount=running[j.name]||0; const isRunning=runCount>0; const tgt=TARGET_MAP[j.target.mode]||j.target.mode; const tgtDetail=j.target.mode==='group'?(j.target.group_name||''):(j.target.mode==='serial'?(j.target.serial||''):''); const sched=j.schedule.mode==='cron_stop'?('启停 '+j.schedule.cron+'→'+j.schedule.stop_cron): j.schedule.mode==='cron'?('定时 '+j.schedule.cron):'手动'; const retry=j.retry.max_attempts>1?(j.retry.max_attempts+'次/间隔'+j.retry.delay+'s'):'不重试'; const en=j.enabled?'启用':'停用'; const runBadge=isRunning?'运行中 '+runCount+'台':'空闲'; return '
'+ '
'+esc(j.name)+'
'+ '
'+esc(j.task_type)+'
'+ '
'+en+' '+runBadge+'
'+ '
'+ '
目标:'+esc(tgt)+(tgtDetail?'('+esc(tgtDetail)+')':'')+''+ '调度:'+esc(sched)+''+ '重试:'+esc(retry)+'
'+ '
'+fmtParams(j.params)+'
'+ '
'+ '
'+ ''+ ''+ ''+ ''+ '
'+ '
'; }).join(''):'
暂无任务,到"任务"Tab 新建
'; }