// 监控页:设备状态表(排序/多选/截图/异常汇总/任务运行概况)
// ================== 设备列表排序与多选 ==================
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=>
''
).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 新建
';
}