From 47262abdcd2bf38da48f3b27117479767e0e2b83 Mon Sep 17 00:00:00 2001
From: butubb <1422726308@qq.com>
Date: Sat, 8 Aug 2026 20:55:53 +0800
Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=9E=B6=E6=9E=84=E4=BC=98?=
=?UTF-8?q?=E5=8C=96-=E5=89=8D=E7=AB=AF=EF=BC=88JS=20=E6=8B=86=E5=88=86?=
=?UTF-8?q?=E5=A4=96=E9=83=A8=E6=96=87=E4=BB=B6=E3=80=81=E7=9B=91=E6=8E=A7?=
=?UTF-8?q?=E5=AE=9A=E5=90=91=E6=9B=B4=E6=96=B0=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 前端 JS 从 monitor.html 内联移到 static/admin/monitor.js(HTML/JS 分离,便于维护)
- 监控页 5 秒刷新时设备数据/分页/排序未变则跳过整表重渲染,减少 DOM 抖动
---
static/admin/monitor.js | 2056 ++++++++++++++++++++++++++++++++++
templates/admin/monitor.html | 2053 +--------------------------------
2 files changed, 2057 insertions(+), 2052 deletions(-)
create mode 100644 static/admin/monitor.js
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?'':'';
+ var 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=>
+ ''
+ ).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=
+ ''+
+ ''+
+ ''+
+ ''+
+ ''+
+ ''+
+ ''+
+ '
步骤编辑器用步骤卡片上的 ☑ 选中多个步骤,可打包成自定义动作'+
+ ''+
+ '
'+
+ '
'+
+ '
';
+
+ // 初始化步骤编辑器(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+='';
+ }else if(step.type==='swipe'){
+ h+='';
+ }else if(step.type==='click'){
+ h+='';
+ h+='';
+ }else if(step.type==='wait'){
+ 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+='';
+ // 可编辑 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=
+ ''+
+ ''+
+ '';
+
+ 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+'';
+
+ 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}
-
+