diff --git a/static/admin/monitor.js b/static/admin/monitor.js new file mode 100644 index 0000000..738b6a1 --- /dev/null +++ b/static/admin/monitor.js @@ -0,0 +1,2056 @@ +// ================== 通用工具 ================== +const ACTION_LABELS={like:'点赞',comment:'评论',follow:'关注',share:'分享'}; +const STATUS_MAP={idle:{t:'未启动',c:'default'},connecting:{t:'连接中',c:'warning'},running:{t:'运行中',c:'success'},done:{t:'完成',c:'primary'},error:{t:'异常',c:'danger'},released:{t:'已释放',c:'default'},failed:{t:'失败',c:'danger'}}; +const TARGET_MAP={all:'全部空闲',group:'按分组',serial:'指定设备'}; +const APP_NAMES={'com.ss.android.ugc.aweme':'抖音','com.ss.android.ugc.aweme.lite':'抖音极速版','com.smile.gifmaker':'快手','com.kuaishou.nebula':'快手极速版','com.tencent.mm':'微信','com.tencent.mobileqq':'QQ','com.tencent.qqlive':'腾讯视频','com.eg.android.AlipayGphone':'支付宝','com.taobao.taobao':'淘宝','com.tmall.wireless':'天猫','com.jingdong.app.mall':'京东','com.xunmeng.pinduoduo':'拼多多','com.ss.android.article.news':'今日头条','com.zhihu.android':'知乎','com.miui.home':'桌面','com.android.launcher':'桌面','com.android.settings':'设置','com.android.systemui':'系统','com.github.uiautomator':'U2','com.github.uiautomator.test':'U2测试'}; + +function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));} + +async function apiGet(url){ + const r=await fetch(url); + if(r.status===401){window.location='/login';return null;} + return r.json(); +} +async function apiPost(url,body){ + const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):'{}'}); + if(r.status===401){window.location='/login';return null;} + return r.json(); +} +async function apiPut(url,body){ + const r=await fetch(url,{method:'PUT',headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):'{}'}); + if(r.status===401){window.location='/login';return null;} + return r.json(); +} + +// ================== 设备列表排序与多选 ================== +let _sortKey=''; // 当前排序字段 +let _sortDir=''; // '' | 'asc' | 'desc' +let _selectedSerials=new Set(); // 已勾选的 serial 集合 + +// 点击表头排序 +function sortBy(key){ + if(_sortKey===key){ + // 同一列:切换方向;已是降序则取消 + if(_sortDir==='asc'){_sortDir='desc';} + else if(_sortDir==='desc'){_sortKey='';_sortDir='';} + else{_sortDir='asc';} + }else{ + _sortKey=key;_sortDir='asc'; + } + // 更新表头样式 + document.querySelectorAll('.table thead th.sortable').forEach(th=>{ + th.classList.remove('sort-asc','sort-desc'); + if(th.dataset.key===_sortKey){ + th.classList.add(_sortDir==='asc'?'sort-asc':(_sortDir==='desc'?'sort-desc':'')); + } + }); + renderDevices(); +} + +// 排序比较:从设备对象取排序值 +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==='stf_occupied'){ + v=dev.stf_occupied?1:0; + }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); +} + +// ================== 设备画面查看 ================== +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; + } +} +async function apiDelete(url){ + const r=await fetch(url,{method:'DELETE'}); + if(r.status===401){window.location='/login';return null;} + return r.json(); +} + +function showToast(msg,type){ + const t=document.getElementById('toast'); + t.className='toast show toast-'+(type||'success'); + t.textContent=msg; + setTimeout(()=>t.className='toast',3000); +} +function handleResult(r,callback){ + if(!r)return; + if(r.ok){showToast(r.msg||'操作成功','success');if(callback)callback();} + else{showToast(r.error||'操作失败','error');} +} + +// ================== Tab 切换 ================== +let _activeTab='monitor'; +let _monitorTimer=null; +let _logTimer=null; + +function showTab(name){ + _activeTab=name; + document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active')); + document.querySelectorAll('.navbar .tab').forEach(t=>t.classList.remove('active')); + document.getElementById('tab-'+name).classList.add('active'); + document.querySelectorAll('.navbar .tab')[['monitor','tasks','apps','groups','logs','users'].indexOf(name)].classList.add('active'); + // 停止所有定时器 + if(_monitorTimer){clearInterval(_monitorTimer);_monitorTimer=null;} + if(_logTimer){clearInterval(_logTimer);_logTimer=null;} + // 按需加载数据 + if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);} + if(name==='tasks'){loadTasks();loadCustomActions();} + if(name==='apps')loadApks(); + if(name==='groups')loadGroups(); + if(name==='logs'){loadLogs();if(document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);} + if(name==='users')loadUsers(); +} + +// ================== 通用列表:搜索 + 分页 ================== +// 用法: +// setListPager(key,{data:()=>array, filter:(item,kw)=>bool, render:(item)=>html, +// tbody:'tb-id', pager:'pager-id', pageSize:N}); +// applyListPager(key); // 数据变化后重渲染(含搜索/翻页状态) +// initListSearch(key,'search-id');// 搜索框绑定 +var _LIST_PAGERS={}; + +function _lp(key){ return _LIST_PAGERS[key] || (_LIST_PAGERS[key]={page:1,kw:'',cfg:null}); } + +function setListPager(key,cfg){ + // 只更新配置,不重置 page/kw —— 监控列表 5 秒自动刷新时保持用户当前页 + var st=_lp(key); st.cfg=cfg; +} +function initListSearch(key,inputId){ + var input=document.getElementById(inputId); + if(!input||input.dataset.bound)return; + input.dataset.bound='1'; + input.addEventListener('input',function(){ + var st=_lp(key); st.kw=this.value.trim(); st.page=1; applyListPager(key); + }); +} +function applyListPager(key){ + var st=_lp(key); + if(!st.cfg)return; + var cfg=st.cfg; + var all=(cfg.data&&cfg.data())||[]; + var kw=st.kw.toLowerCase(); + var filtered=kw?all.filter(function(x){return cfg.filter(x,kw);}):all; + var pageSize=cfg.pageSize||10; + var pages=Math.max(1,Math.ceil(filtered.length/pageSize)); + if(st.page>pages)st.page=pages; + if(st.page<1)st.page=1; + var start=(st.page-1)*pageSize; + var slice=filtered.slice(start,start+pageSize); + var tb=document.getElementById(cfg.tbody); + if(tb)tb.innerHTML=slice.map(cfg.render).join('')||(cfg.empty||'无数据'); + var pg=document.getElementById(cfg.pager); + if(pg)pg.innerHTML=_lpPagerHtml(key,pages,st.page,filtered.length,pageSize); +} +function _lpPagerHtml(key,pages,cur,total,pageSize){ + if(pages<=1)return total>0?'
共 '+total+' 条
':''; + var h='
共 '+total+' 条'; + h+=''; + for(var p=Math.max(1,cur-2);p<=Math.min(pages,cur+2);p++){ + h+=''; + } + h+=''; + h+='
'; + return h; +} +function _lpGo(key,p){ + var st=_lp(key); st.page=p; applyListPager(key); +} + +// ================== 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 occupied=devices.filter(x=>x.stf_occupied).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:occupied,l:'STF占用',c:'#f59e0b'}, + {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(); + }catch(e){console.error(e);} +} + +// 渲染设备表格(带排序+搜索+分页,复选状态保持) +let _sortedDevices=[]; +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:_sortKey+(_sortDir||'')}); + if(_sig===_devicesSig)return; + _devicesSig=_sig; + let devices=_statusData.devices.slice(); + // 排序 + if(_sortKey&&_sortDir){ + const dir=_sortDir==='asc'?1:-1; + devices.sort((a,b)=>{ + let va=_sortValue(a,_sortKey),vb=_sortValue(b,_sortKey); + if(typeof va==='string'&&typeof vb==='string'){ + return va.localeCompare(vb)*dir; + } + va=Number(va)||0;vb=Number(vb)||0; + return (va-vb)*dir; + }); + } + _sortedDevices=devices; + setListPager('devices',{ + data:()=>_sortedDevices, + 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)), + render:renderDeviceRow, + tbody:'tb-devices',pager:'pager-devices',pageSize:10, + empty:'暂无设备' + }); + applyListPager('devices'); + initListSearch('devices','search-devices'); +} +function renderDeviceRow(dev){ + const stf=dev.stf_occupied?'占用':'空闲'; + 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()+'
':''; + const stopBtn=(dev.worker_status==='running'||dev.worker_status==='connecting')?'':''; + // 截图按钮(任何状态都可看,离线时会报错) + const shotBtn=''; + const btns='
'+shotBtn+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)+''+stf+offline+''+wb+''+fg+''+prog+''+tname+retry+endInfo+''+esc(dev.current_action||'-')+''+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" + 运行时长 + 操作计数徽章 +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 新建
'; +} + +// ================== Tab 2: 任务 ================== +let _taskTypes=[]; +let _groupsList=[]; +let _devicesList=[]; + +async function loadTasks(){ + const [jr,gr,dr,tr]=await Promise.all([ + apiGet('/api/jobs'),apiGet('/api/groups'),apiGet('/api/devices'),apiGet('/api/task_types') + ]); + if(!jr)return; + _jobsData=jr.jobs||[]; + _taskTypes=tr?tr.task_types||[]:[]; + _groupsList=gr?gr.groups||[]:[]; + _devicesList=dr?dr.devices||[]:[]; + renderTaskTable(); +} + +function renderTaskTable(){ + const jobs=_jobsData||[]; + setListPager('tasks',{ + data:()=>jobs, + filter:(j,kw)=>((j.name||'').toLowerCase().includes(kw)||(j.task_type||'').toLowerCase().includes(kw)||(j.id||'').toLowerCase().includes(kw)), + render:renderTaskRow, + tbody:'tb-tasks',pager:'pager-tasks',pageSize:10, + empty:'暂无任务,点击"新建任务"创建' + }); + applyListPager('tasks'); + initListSearch('tasks','search-tasks'); +} +function renderTaskRow(j){ + 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'?('启停 '+esc(j.schedule.cron||'')+'→'+esc(j.schedule.stop_cron||'')): + j.schedule.mode==='cron'?('定时 '+esc(j.schedule.cron||'')):'手动'; + const retry=j.retry.max_attempts>1?(j.retry.max_attempts+'次/'+j.retry.delay+'s'):'不重试'; + const en=j.enabled?'启用':'停用'; + return ''+ + ''+esc(j.id)+''+ + ''+esc(j.name)+''+ + ''+esc(j.task_type)+''+ + ''+en+''+ + ''+esc(tgt)+(tgtDetail?'('+esc(tgtDetail)+')':'')+''+ + ''+sched+''+ + ''+retry+''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''; +} + +function openTaskModal(jobId){ + const job=jobId?(_jobsData||[]).find(j=>j.id===jobId):null; + const isEdit=!!job; + document.getElementById('modal-title').textContent=isEdit?'编辑任务':'新建任务'; + + const j=job||{name:'',task_type:'douyin_nurture',enabled:true,target:{mode:'all'},params:{},schedule:{mode:'once'},retry:{max_attempts:1,delay:60}}; + const tt=_taskTypes.map(t=>'').join(''); + const gl=_groupsList.map(g=>'').join(''); + const dl=_devicesList.map(s=>'').join(''); + const sm=j.schedule.mode; + const startCron=(sm==='cron'||sm==='cron_stop')?(j.schedule.cron||''):''; + const stopCron=sm==='cron_stop'?(j.schedule.stop_cron||''):''; + + document.getElementById('modal-body').innerHTML= + '
'+ + '
'+ + '
'+ + '
'+ + '
'+ + '
'+ + ''+ + '
'+ + ''+ + '
'+ + '
'+ + ''+ + '
'+ + '
'+ + '
'+ + '
'+ + ''+ + '
'+ + '
格式:分 时 日 月 周
'+ + '
到点自动停止所有运行中的 worker
'+ + '
'+ + '
'+ + '
'+ + '
'+ + '
'+ + '
可留空使用默认参数。抖音任务可设 max_duration(秒,如 1800=30分钟)限制单设备运行时长
'+ + '
'+ + '
步骤编辑器用步骤卡片上的 ☑ 选中多个步骤,可打包成自定义动作'+ + ''+ + '
'+ + '
'+ + '
操作库
'+ + '
'+ + '
'+ + '
'; + + // 初始化步骤编辑器(generic_steps 时显示,其他隐藏);jobId 作为草稿 key + _stepEditor.init(j.params&&j.params.steps?j.params.steps:[],j.params&&j.params.max_duration?j.params.max_duration:0,jobId||''); + + document.getElementById('modal-footer').innerHTML= + ''+ + ''; + + toggleTargetFields(); + toggleScheduleFields(); + onTaskTypeChange(); + showModal(); +} + +function toggleTargetFields(){ + const mode=document.getElementById('f-target_mode').value; + document.getElementById('f-target_group_wrap').style.display=mode==='group'?'block':'none'; + document.getElementById('f-target_serial_wrap').style.display=mode==='serial'?'block':'none'; +} + +function toggleScheduleFields(){ + const mode=document.getElementById('f-schedule_mode').value; + document.getElementById('f-cron_wrap').style.display=(mode==='cron'||mode==='cron_stop')?'block':'none'; + document.getElementById('f-stop_cron_wrap').style.display=mode==='cron_stop'?'block':'none'; +} + +function onTaskTypeChange(){ + const tt=document.getElementById('f-task_type').value; + const isGeneric=tt==='generic_steps'; + document.getElementById('step-editor').classList.toggle('show',isGeneric); + document.getElementById('f-params_wrap').style.display=isGeneric?'none':'block'; +} + +// ================== 步骤编辑器 ================== +var STEP_LIB=[ + {type:'open_app',label:'打开App',icon:'▶',params:{package:'',wait_home:false,home_feature:''}}, + {type:'swipe',label:'滑动',icon:'↕',params:{direction:'up',duration_min:0.25,duration_max:0.50}}, + {type:'click',label:'点击元素',icon:'✦',params:{selector_type:'xpath',selector_value:'',wait_timeout:2}}, + {type:'input_text',label:'输入文字',icon:'⌨',params:{mode:'random',texts:'你好\n有趣\n支持',fixed_text:''}}, + {type:'wait',label:'等待',icon:'⏱',params:{min:1.0,max:3.0}}, + {type:'loop',label:'循环块',icon:'↻',params:{loop_mode:'rounds',max_iterations:10,loop_duration:600,children:[]}}, + {type:'group',label:'动作组',icon:'📦',params:{children:[]}} +]; +var _customActions=[]; // 后端加载的自定义动作 +var _stepIdCounter=0; +function _newStepId(){_stepIdCounter++;return 'step_'+Date.now().toString(36)+'_'+_stepIdCounter;} +function _findLib(type){return STEP_LIB.find(function(s){return s.type===type;});} + +// 加载自定义动作库 +function _loadCustomActions(){ + fetch('/api/custom_actions').then(function(r){return r.json();}).then(function(res){ + if(res.ok)_customActions=res.actions||[]; + if(typeof _stepEditor!=='undefined')_stepEditor._renderLib(); + }).catch(function(){}); +} + +var _stepEditor={ + _steps:[], + _selected:[], + _dragSuppressClick:false, + init:function(steps,maxDuration,draftKey,canvasId,libId){ + this._draftKey=draftKey||'new'; + this._selected=[]; + this._dragSuppressClick=false; + // 可指定编辑容器(任务编辑器 vs 动作编辑器),默认任务编辑器 + this._canvas=document.getElementById(canvasId||'se-canvas'); + this._lib=document.getElementById(libId||'se-lib'); + // 嵌套结构数组注册表:arr 唯一 id → 数组引用(修复多层级循环内移动错位) + this._arrCounter=0; + this._arrById={}; + this._arrIdMap=new WeakMap(); + // 恢复上次未保存的草稿(实时保存);没有则用传入的步骤 + var draft=this._loadDraft(); + if(draft&&Array.isArray(draft)){ + this._steps=draft; + }else{ + this._steps=steps.length?JSON.parse(JSON.stringify(steps)):[]; + } + this._ensureIds(this._steps); + this._renderLib(); + this._renderCanvas(); + _loadCustomActions(); + // 实时同步:画布内任何输入(含下拉/勾选)都同步回 _steps 并自动保存 + var canvas=this._canvas; + if(canvas&&!canvas.dataset.seBound){ + canvas.dataset.seBound='1'; + var self=this; + canvas.addEventListener('input',function(){self._syncFromDom();}); + } + if(draft&&Array.isArray(draft)){ + showToast('已恢复上次未保存的步骤编辑','success'); + } + // max_duration 输入框(只在任务编辑器注入,动作编辑不需要) + var head=document.querySelector('#step-editor .se-head'); + if(head&&!document.getElementById('f-max_duration')){ + var md=document.createElement('span'); + md.style.cssText='font-size:11px;display:flex;align-items:center;gap:4px'; + md.innerHTML='运行时长上限(秒,0=不限) '; + head.appendChild(md); + } + }, + _ensureIds:function(steps){ + var self=this; + steps.forEach(function(s){ + if(!s.id)s.id=_newStepId(); + if(s.params&&s.params.children&&Array.isArray(s.params.children)){ + self._ensureIds(s.params.children); + } + }); + }, + _renderLib:function(){ + var lib=this._lib; + if(!lib)return; // 未初始化编辑器(如仅加载动作列表时)跳过 + var items=STEP_LIB.map(function(s){ + return '
'+ + ''+s.icon+''+s.label+'
'; + }).join(''); + // 自定义动作区 + var customHtml=''; + if(_customActions.length){ + customHtml='
自定义动作
'; + customHtml+=_customActions.map(function(a){ + return '
'+ + ''+(a.icon||'📦')+''+esc(a.name)+''+ + '
'; + }).join(''); + customHtml+='
'; + } + lib.innerHTML='
操作库
'+items+customHtml; + var self=this; + // 绑定拖拽 + 点击(系统步骤)—— 用 _dragSuppressClick 防止拖拽后重复触发 click + lib.querySelectorAll('.lib-item[data-type]').forEach(function(item){ + var type=item.dataset.type; + item.addEventListener('dragstart',function(e){ + self._dragSuppressClick=true; + e.dataTransfer.setData('text/plain','type:'+type); + e.dataTransfer.effectAllowed='copy'; + }); + item.addEventListener('click',function(e){ + if(self._dragSuppressClick){self._dragSuppressClick=false;return;} + self.addStep(type); + }); + }); + // 绑定拖拽 + 点击(自定义动作) + lib.querySelectorAll('.lib-item[data-action-id]').forEach(function(item){ + var actionId=item.dataset.actionId; + item.addEventListener('dragstart',function(e){ + self._dragSuppressClick=true; + e.dataTransfer.setData('text/plain','action:'+actionId); + e.dataTransfer.effectAllowed='copy'; + }); + item.addEventListener('click',function(e){ + if(e.target.classList.contains('del-btn'))return; + if(self._dragSuppressClick){self._dragSuppressClick=false;return;} + self.addCustomAction(actionId); + }); + }); + }, + _renderCanvas:function(){ + if(!this._canvas)return; + this._renderStepList(this._canvas,this._steps); + }, + _renderStepList:function(container,steps){ + var self=this; + if(!steps.length){ + container.innerHTML='
拖拽左侧操作到这里
或点击操作库直接添加
'; + this._bindDropZone(container,steps); // 空时也要能接收拖入 + return; + } + container.innerHTML=''; + steps.forEach(function(step,idx){ + container.appendChild(self._renderCard(step,idx,steps)); + }); + this._bindDropZone(container,steps); + }, + _renderCard:function(step,idx,parentArr){ + var self=this; + var lib=_findLib(step.type)||{icon:'?',label:step.type}; + var isContainer=(step.type==='loop'||step.type==='group'); + var card=document.createElement('div'); + card.className='step-card'+(step.type==='loop'?' loop-block':'')+(step.type==='group'?' group-block':''); + card.draggable=true; + card.dataset.idx=idx; + card.dataset.stepId=step.id||''; + if(this._selected&&this._selected.indexOf(step.id)>=0)card.classList.add('selected'); + // head + var head=document.createElement('div'); + head.className='sc-head'; + head.innerHTML=''+lib.icon+''+ + ''+esc(step.label||lib.label)+''+ + ''+step.type+''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''; + head.addEventListener('click',function(e){ + if(e.target.tagName==='BUTTON')return; + self.toggleExpand(head.querySelector('button')); + }); + card.appendChild(head); + // body(参数表单) + var body=document.createElement('div'); + body.className='sc-body'; + body.innerHTML=this._renderParams(step); + card.appendChild(body); + // 拖拽排序 + card.addEventListener('dragstart',function(e){ + e.dataTransfer.setData('text/plain','move:'+idx+':'+self._arrId(parentArr)); + e.dataTransfer.effectAllowed='move'; + card.classList.add('dragging'); + }); + card.addEventListener('dragend',function(){card.classList.remove('dragging');}); + card.addEventListener('dragover',function(e){ + e.preventDefault(); + card.classList.add('drag-over'); + }); + card.addEventListener('dragleave',function(){card.classList.remove('drag-over');}); + card.addEventListener('drop',function(e){ + e.preventDefault(); + e.stopPropagation(); // 关键:阻止冒泡到外层卡片/容器,避免重复添加 + card.classList.remove('drag-over'); + self._handleDrop(e,parentArr,idx); + }); + // loop/group 子步骤 + if(isContainer&&step.params&&step.params.children){ + var childWrap=document.createElement('div'); + childWrap.className='step-children'; + this._renderStepList(childWrap,step.params.children); + card.appendChild(childWrap); + } + return card; + }, + _path:function(arr){ + // 用数组长度作为标识(顶层是 _steps,子层是某 loop 的 children) + // 这里简化:顶层 path='root',子层 path='loop:' + return arr===this._steps?'root':'child'; + }, + _renderParams:function(step){ + var p=step.params||{}; + var h=''; + if(step.type==='open_app'){ + h+='
'; + h+='
'; + h+='
'; + }else if(step.type==='swipe'){ + h+='
'; + h+='
'; + h+='
'; + }else if(step.type==='click'){ + h+='
'; + h+='
'; + h+='
'; + }else if(step.type==='wait'){ + h+='
'; + h+='
'; + }else if(step.type==='input_text'){ + const im=p.mode==='fixed'?'fixed':'random'; + h+='
'; + h+='
'; + h+='
'; + h+='
需先用"点击元素"步骤定位到输入框,本步骤只负责输入文字
'; + }else if(step.type==='loop'){ + const lm=p.loop_mode==='time'?'time':'rounds'; + h+='
'+ + '
'+ + '
'; + h+='
将下方子步骤(拖拽到本卡片内)循环执行指定轮次或时长;按时间时配合"运行时长上限"看预计结束时间
'; + }else if(step.type==='group'){ + h+='
动作组:按顺序执行下方子步骤一次。把多个步骤打包成一个可复用的整体。
'; + h+='
把本组步骤保存到左侧自定义动作库,方便复用
'; + } + // 共享:触发概率(所有步骤通用,0~100) + h+='
0~100,<100 表示按概率执行(如 30=隔几次才触发一次,适合"偶尔点赞")
'; + // 可编辑 label + h+='
'; + return h; + }, + _bindDropZone:function(container,arr){ + // 常驻容器(#se-canvas)多次重渲染会累积 drop 监听 → 拖一次添加多个。 + // 用 dataset 标记只绑定一次;数组引用用 WeakMap 每次更新。 + if(!this._dropArrs)this._dropArrs=new WeakMap(); + this._dropArrs.set(container,arr); + if(container.dataset.dropBound)return; + container.dataset.dropBound='1'; + var self=this; + // 画布空白处也能接收 drop,拖入时高亮提示 + container.addEventListener('dragover',function(e){ + e.preventDefault(); + container.classList.add('drag-over'); + }); + container.addEventListener('dragleave',function(e){ + if(!container.contains(e.relatedTarget))container.classList.remove('drag-over'); + }); + container.addEventListener('drop',function(e){ + e.preventDefault(); + e.stopPropagation(); // 阻止冒泡到外层卡片/容器,避免重复添加 + container.classList.remove('drag-over'); + if(e.target===container||e.target.classList.contains('se-empty')){ + self._handleDrop(e,self._dropArrs.get(container),-1); + } + }); + }, + _handleDrop:function(e,arr,idx){ + var self=this; + var data=e.dataTransfer.getData('text/plain'); + if(!data)return; + // drop 处理中同步重建 DOM 会打断拖拽事件,可能被浏览器重发 drop 导致重复添加。 + // 延迟到下一帧渲染,保证一次拖入只加一次。 + function rerender(){ setTimeout(function(){self._renderCanvas();},0); } + if(data.startsWith('move:')){ + // 内部排序 + var parts=data.split(':'); + var fromIdx=parseInt(parts[1]); + var fromPath=parts[2]; + if(fromPath===this._arrId(arr)&&fromIdx!==idx){ + this._syncFromDom(); + var moved=arr.splice(fromIdx,1)[0]; + if(idx<0)arr.push(moved); + else if(fromIdx=arr.length)arr.push(ns); + else arr.splice(idx,0,ns); + rerender(); + } + }else if(data.startsWith('type:')){ + // 从系统操作库拖入 + var type=data.substring(5); + var lib=_findLib(type); + if(lib){ + var ns2=this._makeStep(lib); + if(idx<0||idx>=arr.length)arr.push(ns2); + else arr.splice(idx,0,ns2); + rerender(); + } + } + }, + _makeStep:function(lib){ + return {id:_newStepId(),type:lib.type,label:lib.label,params:JSON.parse(JSON.stringify(lib.params))}; + }, + addStep:function(type){ + var lib=_findLib(type); + if(!lib)return; + this._syncFromDom(); + this._steps.push(this._makeStep(lib)); + this._renderCanvas(); + }, + addCustomAction:function(actionId){ + var action=_customActions.find(function(a){return a.id===actionId;}); + if(!action||!action.steps||!action.steps.length)return; + this._syncFromDom(); + var ns=this._makeStep({type:'group',label:action.name,icon:action.icon||'📦',params:{children:[]}}); + ns.label=action.name; + ns.params.children=JSON.parse(JSON.stringify(action.steps)); + this._ensureIds(ns.params.children); + this._steps.push(ns); + this._renderCanvas(); + }, + delCustomAction:function(actionId){ + if(!confirm('确定删除这个自定义动作?'))return; + fetch('/api/custom_actions/'+actionId,{method:'DELETE'}).then(function(r){return r.json();}).then(function(res){ + if(res.ok){_loadCustomActions();toast('已删除','success');} + else toast(res.error||'删除失败','error'); + }); + }, + toggleSelect:function(stepId){ + this._syncFromDom(); + if(!this._selected)this._selected=[]; + var i=this._selected.indexOf(stepId); + if(i>=0)this._selected.splice(i,1); + else this._selected.push(stepId); + this._renderCanvas(); + // 更新工具栏按钮状态 + var bar=document.getElementById('se-pack-btn'); + if(bar)bar.disabled=this._selected.length<1; + }, + saveAsCustomAction:function(){ + // 从 group 步骤的"存为自定义动作"按钮调用:收集当前展开的 group 步骤的 children + // 或从选中步骤收集 + var stepsToSave=[]; + if(this._selected&&this._selected.length){ + // 从选中步骤收集 + stepsToSave=this._collectByIds(this._steps,this._selected); + } + if(!stepsToSave.length){ + toast('请先用 ☑ 选中要打包的步骤','error'); + return; + } + var name=prompt('输入动作名称:','我的动作'); + if(!name)return; + var self=this; + fetch('/api/custom_actions',{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({name:name,steps:stepsToSave})}) + .then(function(r){return r.json();}) + .then(function(res){ + if(res.ok){ + toast('已保存到自定义动作库','success'); + self._selected=[]; + _loadCustomActions(); + self._renderCanvas(); + }else toast(res.error||'保存失败','error'); + }); + }, + _collectByIds:function(steps,ids){ + // 收集选中的步骤(深拷贝,去掉 id) + var result=[]; + steps.forEach(function(s){ + if(ids.indexOf(s.id)>=0){ + var copy=JSON.parse(JSON.stringify(s)); + result.push(copy); + } + }); + return result; + }, + toggleExpand:function(btn){ + var card=btn.closest('.step-card'); + card.classList.toggle('expanded'); + }, + _toggleInputMode:function(sel){ + var body=sel.closest('.sc-body'); + var mode=sel.value; + body.querySelectorAll('[data-mode]').forEach(function(g){ + g.style.display=g.dataset.mode===mode?'block':'none'; + }); + }, + _toggleLoopMode:function(sel){ + var body=sel.closest('.sc-body'); + var mode=sel.value; + body.querySelectorAll('[data-lmode]').forEach(function(g){ + g.style.display=g.dataset.lmode===mode?'block':'none'; + }); + }, + // 给数组分配唯一 id,用于 move/duplicate/remove/拖拽定位到正确的父数组 + _arrId:function(arr){ + if(arr===this._steps)return 'root'; + if(!this._arrIdMap)this._arrIdMap=new WeakMap(); + if(!this._arrById)this._arrById={}; + if(!this._arrIdMap.has(arr)){ + var id='arr_'+(++this._arrCounter); + this._arrIdMap.set(arr,id); + this._arrById[id]=arr; + } + return this._arrIdMap.get(arr); + }, + _resolveArr:function(path){ + if(path==='root')return this._steps; + return this._arrById&&this._arrById[path]; + }, + move:function(idx,dir,path){ + this._syncFromDom(); // 先同步 DOM 编辑,避免重渲染丢内容 + var arr=this._resolveArr(path); + if(!arr)return; + var ni=idx+dir; + if(ni<0||ni>=arr.length)return; + var tmp=arr[ni];arr[ni]=arr[idx];arr[idx]=tmp; + this._renderCanvas(); + }, + duplicate:function(idx,path){ + this._syncFromDom(); + var arr=this._resolveArr(path); + if(!arr)return; + var copy=JSON.parse(JSON.stringify(arr[idx])); + copy.id=_newStepId(); + this._ensureIds([copy]); + arr.splice(idx+1,0,copy); + this._renderCanvas(); + }, + remove:function(idx,path){ + this._syncFromDom(); + var arr=this._resolveArr(path); + if(!arr)return; + arr.splice(idx,1); + this._renderCanvas(); + }, + collect:function(){ + // 先把当前 DOM 上的参数值同步回 _steps + var self=this; + this._syncParams(this._canvas,this._steps); + return JSON.parse(JSON.stringify(this._steps)); + }, + validate:function(steps){ + // 校验步骤配置,返回警告列表 + var warnings=[]; + function check(arr,prefix){ + arr.forEach(function(s,i){ + var name=prefix?(prefix+'.'+(i+1)):('步骤'+(i+1)); + var label=s.label||s.type; + if(s.type==='click'){ + if(!s.params.selector_value){ + warnings.push(name+' "'+label+'": 选择器值为空,点击步骤会跳过。请用"抓取元素"按钮选择目标。'); + } + }else if(s.type==='open_app'){ + if(!s.params.package){ + warnings.push(name+' "'+label+'": 包名为空,无法启动App'); + } + }else if(s.type==='input_text'){ + if(s.params.mode==='fixed'&&!s.params.fixed_text){ + warnings.push(name+' "'+label+'": 指定文字为空'); + }else if(s.params.mode==='random'&&!s.params.texts){ + warnings.push(name+' "'+label+'": 候选文字为空'); + } + } + if(s.params&&s.params.children&&s.params.children.length){ + check(s.params.children,name+' 子步骤'); + } + }); + } + check(steps,''); + return warnings; + }, + _syncParams:function(container,steps){ + var self=this; + var cards=container.querySelectorAll(':scope > .step-card'); + cards.forEach(function(card,i){ + if(!steps[i])return; + // 只读本卡片直接子级的 .sc-body,避免把嵌套子步骤的输入误写进本步骤 params + var inputs=card.querySelectorAll(':scope > .sc-body [data-key]'); + inputs.forEach(function(inp){ + var key=inp.dataset.key; + if(inp.type==='checkbox'){ + steps[i].params[key]=inp.checked; + }else if(inp.type==='number'){ + steps[i].params[key]=parseFloat(inp.value)||0; + }else{ + if(key==='_label'){ + steps[i].label=inp.value||steps[i].label; + }else{ + steps[i].params[key]=inp.value; + } + } + }); + // 递归子步骤 + var childWrap=card.querySelector(':scope > .step-children'); + if(childWrap&&steps[i].params&&steps[i].params.children){ + self._syncParams(childWrap,steps[i].params.children); + } + }); + }, + // 把画布 DOM 上的参数同步回 _steps,并自动保存草稿(操作前调用防丢失) + _syncFromDom:function(){ + this._syncParams(this._canvas,this._steps); + this._autosave(); + }, + _autosave:function(){ + try{localStorage.setItem('se_draft_'+this._draftKey,JSON.stringify(this._steps));}catch(e){} + }, + _loadDraft:function(){ + try{ + var d=localStorage.getItem('se_draft_'+this._draftKey); + if(d)return JSON.parse(d); + }catch(e){} + return null; + }, + clearDraft:function(){ + try{localStorage.removeItem('se_draft_'+this._draftKey);}catch(e){} + }, + // ================== 元素抓取 ================== + openPicker:function(btn){ + var input=btn.previousElementSibling; + this._elTargetInput=input; + // 直接弹设备选择页(独立模态框,不影响任务编辑窗口) + this._showDevicePicker(); + }, + // 步骤1:设备选择页 + _showDevicePicker:function(){ + document.getElementById('el-picker-title').textContent='抓取元素 - 选择设备'; + document.getElementById('el-picker-body').innerHTML='
选择要抓取元素的设备
加载中...
'; + document.getElementById('el-picker-footer').innerHTML=''; + document.getElementById('el-picker-overlay').classList.add('show'); + fetch('/api/uiauto/devices').then(function(r){return r.json();}).then(function(res){ + var list=document.getElementById('el-device-list'); + if(!res.ok){list.innerHTML='
'+esc(res.error||'获取设备失败')+'
';return;} + var devices=res.devices||[]; + if(!devices.length){list.innerHTML='
uiauto2 未发现已连接设备
';return;} + list.innerHTML=devices.map(function(d){ + return '
'+ + '
📱
'+ + '
'+esc(d.serial)+'
'+ + '
'+esc(d.model||d.product||'')+'
'+ + ''+esc(d.status||'device')+'
'; + }).join(''); + }).catch(function(e){ + document.getElementById('el-device-list').innerHTML='
请求失败: '+esc(e)+'
'; + }); + }, + // 步骤2:选中设备后,显示截图 + 元素树 + _showElPicker:function(serial){ + var self=this; + self._elSerial=serial; + document.getElementById('el-picker-title').textContent='抓取元素 - '+serial; + document.getElementById('el-picker-body').innerHTML= + '
'+ + '
'+ + '
'+ + ''+ + '加载中...'+ + '
'+ + '
'+ + '
正在获取截图...
'+ + '
'+ + '
'+ + '
'+ + ''+ + '
加载元素树...
'+ + '
'+ + '
'; + document.getElementById('el-picker-footer').innerHTML= + ''+ + ''; + // 加载截图和元素树 + self._refreshShot(); + fetch('/api/uiauto/elements?serial='+encodeURIComponent(serial)) + .then(function(r){return r.json();}) + .then(function(data){ + if(!data.ok){ + document.getElementById('el-list').innerHTML='
'+esc(data.error||'抓取失败')+'
'; + return; + } + self._elData=data.elements||[]; + self._renderEl(self._elData); + self._renderOverlays(); + }) + .catch(function(e){ + document.getElementById('el-list').innerHTML='
请求失败: '+esc(e)+'
'; + }); + }, + // 刷新截图 + _refreshShot:function(){ + var serial=this._elSerial; + if(!serial)return; + var container=document.getElementById('el-shot-container'); + if(!container)return; + container.innerHTML='
正在获取截图...
'; + var info=document.getElementById('el-shot-info'); + if(info)info.textContent='加载中...'; + var self=this; + var img=new Image(); + img.onload=function(){ + container.innerHTML=''; + container.appendChild(img); + var infoEl=document.getElementById('el-shot-info'); + if(infoEl)infoEl.textContent=img.naturalWidth+'x'+img.naturalHeight; + // 截图加载完后重新渲染元素边界框 + self._renderOverlays(); + }; + img.onerror=function(){ + container.innerHTML='
截图加载失败
'; + var infoEl=document.getElementById('el-shot-info'); + if(infoEl)infoEl.textContent='失败'; + }; + img.src='/api/uiauto/screenshot?serial='+encodeURIComponent(serial)+'&_='+Date.now(); + }, + // 在截图上渲染元素边界框(可点击) + _renderOverlays:function(){ + var container=document.getElementById('el-shot-container'); + var img=container.querySelector('img'); + if(!img||!this._elData)return; + var self=this; + function draw(){ + // 清除旧 overlay 和旧点击处理 + container.querySelectorAll('.el-shot-overlay').forEach(function(o){o.remove();}); + container.onclick=null; + var scaleX=img.clientWidth/img.naturalWidth; + var scaleY=img.clientHeight/img.naturalHeight; + // 图片在 container 里的偏移 + var imgOffsetX=img.offsetLeft; + var imgOffsetY=img.offsetTop; + var imgW=img.naturalWidth, imgH=img.naturalHeight; + self._elData.forEach(function(el,i){ + if(!el.bounds)return; + // bounds 格式 "[x1,y1][x2,y2]" + var m=el.bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/); + if(!m)return; + var x1=parseInt(m[1]),y1=parseInt(m[2]),x2=parseInt(m[3]),y2=parseInt(m[4]); + var w=(x2-x1)*scaleX; + var h=(y2-y1)*scaleY; + if(w<4||h<4)return; // 太小不画 + // 跳过占满屏幕的容器:面积>90%屏幕,没有选择价值,还会盖住小元素 + if((x2-x1)*(y2-y1) > imgW*imgH*0.9)return; + var ov=document.createElement('div'); + ov.className='el-shot-overlay'; + ov.setAttribute('data-idx',i); + ov.style.left=(imgOffsetX+x1*scaleX)+'px'; + ov.style.top=(imgOffsetY+y1*scaleY)+'px'; + ov.style.width=w+'px'; + ov.style.height=h+'px'; + ov.title=el.name||el.class||''; + container.appendChild(ov); + }); + // 点击截图:按 bounds 命中测试,选中最小的(最深的)元素, + // 而不是被 DOM 层叠中的大容器/大兄弟挡住 + container.onclick=function(e){ + var im=container.querySelector('img'); + if(!im)return; + var r=im.getBoundingClientRect(); + var px=(e.clientX-r.left)/scaleX; // 换算回原始分辨率坐标 + var py=(e.clientY-r.top)/scaleY; + var best=-1,bestArea=Infinity; + self._elData.forEach(function(el,i){ + var mb=el.bounds&&el.bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/); + if(!mb)return; + var a=parseInt(mb[1]),b=parseInt(mb[2]),c=parseInt(mb[3]),dd=parseInt(mb[4]); + if(px>=a&&px<=c&&py>=b&&py<=dd){ + var area=(c-a)*(dd-b); + if(area=0)self._highlightEl(best); + }; + } + if(img.complete&&img.naturalWidth>0)draw(); + else img.onload=draw; + }, + // 高亮元素(截图框 + 列表项联动) + _highlightEl:function(i){ + // 清除旧高亮(只在 el-picker-overlay 范围内查找) + var picker=document.getElementById('el-picker-overlay'); + picker.querySelectorAll('.el-shot-overlay.active').forEach(function(o){o.classList.remove('active');}); + picker.querySelectorAll('.el-picker-item.active').forEach(function(o){o.classList.remove('active');}); + // 高亮当前(overlay 用 data-idx 查找,渲染时跳过了全屏容器,索引不再一一对应) + var overlay=picker.querySelector('.el-shot-overlay[data-idx="'+i+'"]'); + if(overlay)overlay.classList.add('active'); + // 列表项用 data-idx 查找(过滤后顺序可能不同) + var item=picker.querySelector('.el-picker-item[data-idx="'+i+'"]'); + if(item){ + item.classList.add('active'); + item.scrollIntoView({block:'nearest',behavior:'smooth'}); + } + }, + _renderEl:function(list){ + var self=this; + var html=list.map(function(el){ + var idx=self._elData.indexOf(el); + var attrs=[]; + if(el.resource_id)attrs.push('id:'+esc(el.resource_id)+''); + if(el.text)attrs.push('text:'+esc(el.text)+''); + if(el.description)attrs.push('desc:'+esc(el.description)+''); + var indent=' '.repeat(Math.min(el.depth||0,6)); + return '
'+ + ''+(el.depth||0)+''+ + '
'+indent+esc(el.name||el.class||'')+'
'+ + '
'+attrs.join('')+'
'+ + ''+esc(el.suggested.type)+''+ + (el.suggested&&el.suggested.broad?' ⚠宽泛':'')+ + '
'; + }).join(''); + document.getElementById('el-list').innerHTML=html||'
无元素
'; + }, + _filterEl:function(kw){ + if(!kw){this._renderEl(this._elData);this._renderOverlays();return;} + kw=kw.toLowerCase(); + var f=this._elData.filter(function(el){ + return (el.resource_id&&el.resource_id.toLowerCase().includes(kw))|| + (el.text&&el.text.toLowerCase().includes(kw))|| + (el.description&&el.description.toLowerCase().includes(kw)); + }); + this._renderEl(f); + }, + _pickEl:function(i){ + var el=this._elData[i]; + if(!el||!this._elTargetInput)return; + // 填入推荐选择器值,并切换选择器类型 + this._elTargetInput.value=el.suggested.value; + var sel=this._elTargetInput.closest('.sc-body').querySelector('[data-key="selector_type"]'); + if(sel)sel.value=el.suggested.type; + // 程序赋值不会触发 input 事件,手动同步回 _steps 防丢失 + this._syncFromDom(); + // 只关闭抓取元素弹窗,不影响任务编辑窗口 + closeElPicker(); + showToast('已填入: '+el.suggested.type+'='+el.suggested.value,'success'); + } +}; + +async function saveTask(jobId){ + const name=document.getElementById('f-name').value.trim(); + if(!name){showToast('任务名不能为空','error');return;} + let params={}; + const tt=document.getElementById('f-task_type').value; + if(tt==='generic_steps'){ + // 通用步骤任务:从步骤编辑器收集 + const steps=_stepEditor.collect(); + if(!steps.length){showToast('请至少添加一个步骤','error');return;} + // 校验:click 步骤的 selector_value 不能为空 + var warnings=_stepEditor.validate(steps); + if(warnings.length){ + if(!confirm('步骤配置有问题:\n\n'+warnings.join('\n')+'\n\n确定要保存吗?'))return; + } + const mdEl=document.getElementById('f-max_duration'); + params={max_duration:mdEl?parseInt(mdEl.value)||0:0,steps:steps}; + }else{ + try{params=JSON.parse(document.getElementById('f-params').value||'{}');} + catch(e){showToast('参数 JSON 格式错误','error');return;} + } + + const targetMode=document.getElementById('f-target_mode').value; + const target={mode:targetMode}; + if(targetMode==='group')target.group_name=document.getElementById('f-target_group').value; + if(targetMode==='serial')target.serial=document.getElementById('f-target_serial').value; + + const schedMode=document.getElementById('f-schedule_mode').value; + let schedule={mode:'once'}; + if(schedMode==='cron'){ + schedule={mode:'cron',cron:document.getElementById('f-schedule_cron').value.trim()}; + }else if(schedMode==='cron_stop'){ + schedule={mode:'cron_stop', + cron:document.getElementById('f-schedule_cron').value.trim(), + stop_cron:document.getElementById('f-schedule_stop_cron').value.trim()}; + } + + const data={ + name:name, + task_type:document.getElementById('f-task_type').value, + enabled:document.getElementById('f-enabled').checked, + target:target, + params:params, + schedule:schedule, + retry:{max_attempts:Math.max(1,parseInt(document.getElementById('f-retry_attempts').value)||1), + delay:Math.max(10,parseInt(document.getElementById('f-retry_delay').value)||60)} + }; + + if(jobId){ + const r=await apiPut('/api/jobs/'+jobId,data); + handleResult(r,()=>{_stepEditor.clearDraft();closeModal();loadTasks();}); + }else{ + const r=await apiPost('/api/jobs',data); + handleResult(r,()=>{_stepEditor.clearDraft();closeModal();loadTasks();}); + } +} + +async function deleteTask(jobId){ + if(!confirm('确认删除此任务吗?'))return; + const r=await apiDelete('/api/jobs/'+jobId); + handleResult(r,()=>loadTasks()); +} + +// ================== 自定义动作管理 ================== +async function loadCustomActions(){ + const r=await apiGet('/api/custom_actions'); if(!r||!r.ok)return; + _customActions=r.actions||[]; + setListPager('actions',{ + data:()=>_customActions, + filter:(a,kw)=>((a.name||'').toLowerCase().includes(kw)), + render:renderActionRow, + tbody:'tb-actions',pager:'pager-actions',pageSize:10, + empty:'暂无自定义动作,可点击"新建动作"或用任务编辑器☑选中步骤打包' + }); + applyListPager('actions'); + initListSearch('actions','search-actions'); + _stepEditor._renderLib(); // 刷新步骤编辑器左侧动作库 +} +function renderActionRow(a){ + const n=(a.steps||[]).length; + return ''+ + ''+esc(a.icon||'📦')+''+ + ''+esc(a.name)+''+ + ''+n+''+ + ''+esc(a.created_at||'-')+''+ + ''+ + ' '+ + ''+ + ''+ + ''; +} +function openActionModal(actionId){ + const action=actionId?_customActions.find(a=>a.id===actionId):null; + const isEdit=!!action; + document.getElementById('action-modal-title').textContent=isEdit?'编辑动作':'新建动作'; + document.getElementById('action-modal-body').innerHTML= + '
'+ + '
'+ + '
'+ + '
'+ + '
用下方步骤编辑器编排动作内容,保存后可拖入任务步骤编辑器复用
'+ + '
'+ + '
动作步骤支持循环/分组/概率触发/拖拽排序
'+ + '
'+ + '
操作库
'+ + '
'+ + '
'+ + '
'; + document.getElementById('action-modal-footer').innerHTML= + ''+ + ''; + // 用独立画布初始化步骤编辑器(不与任务编辑器冲突) + _stepEditor.init(action&&action.steps?action.steps:[], 0, 'action:'+(actionId||'new'), 'action-canvas', 'action-lib'); + document.getElementById('action-modal-overlay').classList.add('show'); +} +async function saveAction(actionId){ + const name=document.getElementById('a-name').value.trim(); + if(!name){showToast('动作名不能为空','error');return;} + const icon=document.getElementById('a-icon').value.trim()||'📦'; + const steps=_stepEditor.collect(); + if(!steps.length){showToast('至少需要1个步骤','error');return;} + const data={name:name,icon:icon,steps:steps}; + if(actionId){ + const r=await apiPut('/api/custom_actions/'+actionId,data); + handleResult(r,()=>{_stepEditor.clearDraft();closeActionModal();loadCustomActions();}); + }else{ + const r=await apiPost('/api/custom_actions',data); + handleResult(r,()=>{_stepEditor.clearDraft();closeActionModal();loadCustomActions();}); + } +} +async function deleteAction(id){ + if(!confirm('确认删除此动作?'))return; + const r=await apiDelete('/api/custom_actions/'+id); + handleResult(r,()=>loadCustomActions()); +} +function closeActionModal(){ + document.getElementById('action-modal-overlay').classList.remove('show'); +} + +// ================== Tab 3: 分组 ================== +async function loadGroups(){ + const r=await apiGet('/api/groups');if(!r)return; + _groupsList=r.groups||[]; + const dr=await apiGet('/api/devices'); + _devicesList=dr?dr.devices||[]:[]; + renderGroupTable(); +} + +function renderGroupTable(){ + const groups=_groupsList||[]; + setListPager('groups',{ + data:()=>groups, + filter:(g,kw)=>((g.name||'').toLowerCase().includes(kw)||(g.description||'').toLowerCase().includes(kw)||(g.serials||[]).join(' ').toLowerCase().includes(kw)), + render:renderGroupRow, + tbody:'tb-groups',pager:'pager-groups',pageSize:10, + empty:'暂无分组,点击"新建分组"创建' + }); + applyListPager('groups'); + initListSearch('groups','search-groups'); +} +function renderGroupRow(g){ + const serials=g.serials||[]; + return ''+ + ''+esc(g.name)+''+ + ''+esc(g.description||'-')+''+ + ''+serials.length+''+ + ''+(serials.length?serials.map(esc).join(', '):'-')+''+ + ''+ + ''+ + ''+ + ''+ + ''; +} + +function openGroupModal(name){ + const group=name?(_groupsList||[]).find(g=>g.name===name):null; + const isEdit=!!group; + document.getElementById('modal-title').textContent=isEdit?'编辑分组':'新建分组'; + const g=group||{name:'',description:'',serials:[]}; + const current= new Set(g.serials||[]); + const allSerials=[...new Set([..._devicesList, ...(g.serials||[])])].sort(); + const deviceCheckboxes=allSerials.map(s=> + '' + ).join(''); + + document.getElementById('modal-body').innerHTML= + '
'+ + '
'+ + '
'+ + '
'+(deviceCheckboxes||'
暂无在线设备
')+'
'+ + '
仅显示当前在线设备,已选设备即使离线也会保留
'+ + '
'; + + document.getElementById('modal-footer').innerHTML= + ''+ + ''; + + showModal(); +} + +async function saveGroup(name){ + const newName=document.getElementById('g-name').value.trim(); + if(!newName){showToast('分组名不能为空','error');return;} + const desc=document.getElementById('g-description').value; + const serials=Array.from(document.querySelectorAll('.device-list input:checked')).map(c=>c.value); + const data={name:newName,description:desc,serials:serials}; + + if(name){ + const r=await apiPut('/api/groups/'+encodeURIComponent(name),data); + handleResult(r,()=>{closeModal();loadGroups();}); + }else{ + const r=await apiPost('/api/groups',data); + handleResult(r,()=>{closeModal();loadGroups();}); + } +} + +async function deleteGroup(name){ + if(!confirm('确认删除分组"'+name+'"吗?'))return; + const r=await apiDelete('/api/groups/'+encodeURIComponent(name)); + handleResult(r,()=>loadGroups()); +} + +// ================== Tab 4: 日志 ================== +async function loadLogs(){ + const file=document.getElementById('log-file').value; + const lines=document.getElementById('log-lines').value; + const r=await apiGet('/api/logs?file='+encodeURIComponent(file)+'&lines='+lines); + if(!r||!r.ok)return; + const content=r.content||''; + // 简单着色 + const colored=esc(content) + .replace(/\[ERROR\]/g,'[ERROR]') + .replace(/\[WARNING\]/g,'[WARNING]') + .replace(/\[INFO\]/g,'[INFO]'); + document.getElementById('log-content').innerHTML=colored||'(空)'; + document.getElementById('log-hint').textContent=file+' · '+content.split('\n').length+' 行'; + // 自动滚动到底部 + const el=document.getElementById('log-content'); + el.scrollTop=el.scrollHeight; +} + +function toggleLogAuto(){ + if(_logTimer){clearInterval(_logTimer);_logTimer=null;} + if(document.getElementById('log-auto').checked && _activeTab==='logs'){ + _logTimer=setInterval(loadLogs,3000); + } +} + +// ================== Tab 5: 用户 ================== +async function loadUsers(){ + const r=await apiGet('/api/users');if(!r)return; + const users=r.users||[]; + setListPager('users',{ + data:()=>users, + filter:(u,kw)=>(u.username||'').toLowerCase().includes(kw), + render:renderUserRow, + tbody:'tb-users',pager:'pager-users',pageSize:10, + empty:'暂无用户' + }); + applyListPager('users'); + initListSearch('users','search-users'); +} +function renderUserRow(u){ + const admin=u.is_admin?'管理员':'普通'; + return ''+ + ''+u.id+''+ + ''+esc(u.username)+''+ + ''+admin+''+ + ''+ + ''+ + (u.username==='admin'?'':'')+ + ''+ + ''; +} + +function openUserModal(uid){ + const isEdit=!!uid; + document.getElementById('modal-title').textContent=isEdit?'编辑用户':'新建用户'; + document.getElementById('modal-body').innerHTML= + '
'+ + '
'+ + '
'; + + document.getElementById('modal-footer').innerHTML= + ''+ + ''; + + if(isEdit){ + // 预填充(从表格数据获取) + fetch('/api/users').then(r=>r.json()).then(r=>{ + const u=r.users.find(x=>x.id===uid); + if(u){ + document.getElementById('u-username').value=u.username; + document.getElementById('u-is_admin').checked=u.is_admin; + } + }); + } + showModal(); +} + +async function saveUser(uid){ + const username=document.getElementById('u-username').value.trim(); + const password=document.getElementById('u-password').value; + const is_admin=document.getElementById('u-is_admin').checked; + + if(uid){ + const data={is_admin:is_admin}; + if(password)data.password=password; + const r=await apiPut('/api/users/'+uid,data); + handleResult(r,()=>{closeModal();loadUsers();}); + }else{ + if(!username||!password){showToast('用户名和密码不能为空','error');return;} + const r=await apiPost('/api/users',{username:username,password:password,is_admin:is_admin}); + handleResult(r,()=>{closeModal();loadUsers();}); + } +} + +async function deleteUser(uid){ + if(!confirm('确认删除此用户吗?'))return; + const r=await apiDelete('/api/users/'+uid); + handleResult(r,()=>loadUsers()); +} + +// ================== Tab: 应用 ================== +let _apksCache=[]; +let _installTimer=null; +let _devAppsData=[]; + +function fmtSize(b){ + if(!b)return '0 B'; + if(b<1024)return b+' B'; + if(b<1048576)return (b/1024).toFixed(1)+' KB'; + return (b/1048576).toFixed(1)+' MB'; +} + +async function loadApks(){ + const r=await apiGet('/api/apks');if(!r||!r.ok)return; + const apks=r.apks||[]; + _apksCache=apks; + document.getElementById('apk-hint').textContent='共 '+apks.length+' 个应用'; + setListPager('apks',{ + data:()=>apks, + filter:(a,kw)=>((a.display_name||'').toLowerCase().includes(kw)||(a.package_name||'').toLowerCase().includes(kw)), + render:renderApkRow, + tbody:'tb-apks',pager:'pager-apks',pageSize:10, + empty:'暂无APK,点击"上传APK"上传' + }); + applyListPager('apks'); + initListSearch('apks','search-apks'); + await fillAppsDeviceList(); +} +function renderApkRow(a){ + return ''+ + ''+esc(a.display_name||'(未命名)')+''+ + ''+esc(a.package_name||'-')+''+ + ''+(a.version_name?esc(a.version_name):'-')+''+ + ''+fmtSize(a.size)+''+ + ''+esc(a.upload_time)+''+ + ''+ + ' '+ + ''+ + ''+ + ''; +} + +// 填充"设备已装应用"的设备下拉(保留当前选择) +async function fillAppsDeviceList(){ + const sr=await apiGet('/api/status');if(!sr||!sr.ok)return; + const sel=document.getElementById('apps-device'); + if(!sel)return; + const cur=sel.value; + const devices=(sr.devices||[]).filter(d=>d.present); + sel.innerHTML=''+devices.map(d=> + '' + ).join(''); + if(cur)sel.value=cur; +} + +// 查看指定设备已安装应用 +async function loadDeviceApps(){ + const sel=document.getElementById('apps-device'); + const serial=sel.value; + if(!serial){showToast('请先选择设备','error');return;} + const hint=document.getElementById('apps-hint'); + const tb=document.getElementById('tb-device-apps'); + hint.textContent='加载中...'; + const r=await apiGet('/api/devices/'+encodeURIComponent(serial)+'/apps'); + hint.textContent=''; + if(!r||!r.ok){ + tb.innerHTML=''+(r&&r.error?esc(r.error):'获取失败')+''; + showToast('获取失败','error');return; + } + const apps=r.apps||[]; + if(!apps.length){tb.innerHTML='无应用';return;} + const userApps=apps.filter(a=>/^\/data\//.test(a.path||'')).length; + hint.textContent='共 '+apps.length+' 个应用(用户安装 '+userApps+')'; + _devAppsData=apps; + setListPager('devapps',{ + data:()=>_devAppsData||[], + filter:(a,kw)=>((a.label||a.package||'').toLowerCase().includes(kw)||(a.package||'').toLowerCase().includes(kw)||(a.path||'').toLowerCase().includes(kw)), + render:renderDevAppRow, + tbody:'tb-device-apps',pager:'pager-devapps',pageSize:20, + empty:'无应用' + }); + applyListPager('devapps'); + initListSearch('devapps','search-devapps'); +} +function renderDevAppRow(a){ + return ''+ + ''+esc(a.label||a.package)+''+(a.label?' '+esc(a.package)+'':'')+''+ + ''+(a.version_name?esc(a.version_name):'-')+''+ + ''+(a.version_code!=null?esc(a.version_code):'-')+''+ + ''+esc(a.path||'-')+''+ + ''; +} + +async function uploadApk(input){ + if(!input.files||!input.files[0])return; + const file=input.files[0]; + if(!file.name.toLowerCase().endsWith('.apk')){ + showToast('请选择 .apk 文件','error');return; + } + const hint=document.getElementById('apk-hint'); + const fd=new FormData(); + fd.append('file',file); + // 用 XMLHttpRequest 才能拿到上传进度(fetch 不支持上传进度事件) + const xhr=new XMLHttpRequest(); + xhr.open('POST','/api/apks/upload'); + xhr.upload.onprogress=function(e){ + if(e.lengthComputable){ + const pct=Math.round(e.loaded*100/e.total); + const loaded=(e.loaded/1048576).toFixed(1); + const total=(e.total/1048576).toFixed(1); + hint.innerHTML='正在上传 '+esc(file.name)+' '+pct+'% ('+loaded+'/'+total+' MB)'+ + '
'; + } + }; + xhr.onload=function(){ + if(xhr.status===401){window.location='/login';return;} + try{ + const data=JSON.parse(xhr.responseText); + if(data.ok){ + showToast(data.msg||'上传成功','success'); + loadApks(); + }else{ + showToast(data.error||'上传失败','error'); + hint.textContent=''; + } + }catch(e){ + showToast('解析响应失败','error'); + hint.textContent=''; + } + }; + xhr.onerror=function(){ + showToast('上传失败:网络错误','error'); + hint.textContent=''; + }; + xhr.send(fd); + input.value=''; +} + +async function deleteApk(id){ + if(!confirm('确认删除此APK文件?'))return; + const r=await apiDelete('/api/apks/'+id); + handleResult(r,loadApks); +} + +async function openInstallModal(apkId){ + const apk=_apksCache.find(a=>a.id===apkId); + const apkName=apk?(apk.display_name||'APK'):'APK'; + const sr=await apiGet('/api/status');if(!sr||!sr.ok)return; + const devices=sr.devices||[]; + const online=devices.filter(d=>d.present); + if(!online.length){ + showToast('没有在线设备','error');return; + } + const body= + '
'+ + '安装应用:'+esc(apkName)+''+ + ' 运行中设备将自动跳过'+ + '
'+ + '
'+ + ' '+ + ' '+ + ''+ + '
'+ + '
'+ + online.map(d=>{ + const running=d.worker_status==='running'||d.worker_status==='connecting'; + const st=''+(running?'运行中':'空闲')+''; + // serial 唯一,显示 IP + 型号便于区分同型号设备 + const label=(d.serial||'').replace(/:5555$/,'')+' · '+(d.device_name||d.model||'设备'); + return ''; + }).join('')+ + '
'; + + document.getElementById('modal-title').textContent='安装应用到设备'; + document.getElementById('modal-body').innerHTML=body; + document.getElementById('modal-footer').innerHTML= + ''+ + ''; + document.getElementById('modal-overlay').classList.add('show'); +} + +function selectIdleForInstall(){ + document.querySelectorAll('#install-dev-list label').forEach(lbl=>{ + const cb=lbl.querySelector('input[type=checkbox]'); + const isRunning=lbl.querySelector('.label-success'); + cb.checked=!isRunning; + }); +} + +async function startInstall(apkId){ + const checked=document.querySelectorAll('#install-dev-list input[type=checkbox]:checked'); + const serials=Array.from(checked).map(c=>c.value); + if(!serials.length){ + showToast('请至少选择一台设备','error');return; + } + const r=await apiPost('/api/apks/install',{apk_id:apkId,serials:serials}); + if(!r||!r.ok){showToast(r?r.error:'请求失败','error');return;} + showToast(r.msg||'安装已启动','success'); + showInstallProgress(); +} + +function showInstallProgress(){ + document.getElementById('modal-title').textContent='安装进度'; + document.getElementById('modal-body').innerHTML= + '

加载中...

'; + document.getElementById('modal-footer').innerHTML= + ''; + pollInstallStatus(); + if(_installTimer)clearInterval(_installTimer); + _installTimer=setInterval(pollInstallStatus,3000); +} + +async function pollInstallStatus(){ + const r=await apiGet('/api/apks/install/status'); + if(!r||!r.ok)return; + const st=r.status; + if(!st){ + document.getElementById('install-progress').innerHTML='

无安装任务

'; + if(_installTimer){clearInterval(_installTimer);_installTimer=null;} + return; + } + const SS={pending:{t:'等待',c:'default'},installing:{t:'安装中',c:'warning'},success:{t:'成功',c:'success'},failed:{t:'失败',c:'danger'},skipped:{t:'跳过',c:'default'}}; + const items=st.items||{}; + const itemRows=Object.entries(items).map(([serial,info])=>{ + const s=SS[info.status]||{t:info.status,c:'default'}; + return ''+esc(info.name||serial)+''+ + ''+esc(serial)+''+ + ''+s.t+''+ + ''+esc(info.msg||'')+''; + }).join(''); + + const pct=st.total>0?Math.round((st.success+st.failed+st.skipped)*100/st.total):0; + const summary= + '
'+ + '
'+ + '应用:'+esc(st.apk_name)+''+ + '成功 '+st.success+''+ + '失败 '+st.failed+''+ + '跳过 '+st.skipped+''+ + '安装中 '+st.installing+''+ + '等待 '+st.pending+''+ + '
'+ + '
'+ + '
'; + + document.getElementById('install-progress').innerHTML= + summary+''+itemRows+'
设备Serial状态详情
'; + + if(st.finished){ + if(_installTimer){clearInterval(_installTimer);_installTimer=null;} + document.getElementById('install-close-btn').style.display=''; + showToast('安装完成:成功'+st.success+' 失败'+st.failed+' 跳过'+st.skipped,'success'); + } +} + +function closeInstallModal(){ + if(_installTimer){clearInterval(_installTimer);_installTimer=null;} + closeModal(); +} + + +// ================== 模态框 ================== +function showModal(){ + document.getElementById('modal-overlay').classList.add('show'); +} +function closeModal(){ + // 先关闭弹窗(最重要的一步,确保一定执行) + const ov=document.getElementById('modal-overlay'); + if(ov)ov.classList.remove('show'); + // 再做清理工作,任何异常都不影响弹窗关闭 + try{ + stopShotTimer(); + _shotSerial=''; + const box=document.getElementById('modal-box'); + if(box)box.classList.remove('shot-modal'); + }catch(e){console.warn('closeModal cleanup error:',e);} +} + +// 关闭元素抓取弹窗(不影响任务编辑窗口) +function closeElPicker(){ + var ov=document.getElementById('el-picker-overlay'); + if(ov)ov.classList.remove('show'); + // 清理状态 + try{ _stepEditor._elData=null; _stepEditor._elSerial=''; }catch(e){} +} + +// ================== 行内按钮操作(停止/执行/启停等,自动 toast + 刷新)================== +async function apiAction(url,body){ + const r=await apiPost(url,body); + if(!r)return; + if(r.ok){showToast(r.msg||'操作成功','success');if(_activeTab==='monitor')loadMonitor();if(_activeTab==='tasks')loadTasks();} + else{showToast(r.error||'操作失败','error');} +} + +async function scanForeground(){ + const r=await apiPost('/api/scan_foreground'); + if(!r)return; + if(r.ok){showToast('扫描已启动,请等待几秒...','success');loadMonitor();} + else{showToast(r.error||'扫描失败','error');} +} + +// ================== 初始化 ================== +showTab('monitor'); diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html index f9297b0..980f5df 100644 --- a/templates/admin/monitor.html +++ b/templates/admin/monitor.html @@ -501,2057 +501,6 @@ select.form-control{cursor:pointer}
- +