// ================== 通用工具 ================== 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]));} // ===== CSRF ===== let _csrfToken=''; // 页面加载后调用:获取 CSRF token,非 GET 请求需携带 async function initCsrf(){ try{ const r=await fetch('/api/csrf'); const d=await r.json(); _csrfToken=d.token||''; }catch(e){} } function _csrfHeaders(){ return _csrfToken?{'X-CSRF-Token':_csrfToken}:{}; } // ===== 当前用户与权限 ===== // _me 来自 /api/me:{username, is_admin, perms:[tasks/devices/apks/logs]} // 管理员 perms 恒为全部。无权限的功能入口(tab/按钮)用 data-perm 标记或 _can() 判断隐藏。 let _me={is_admin:false,perms:[],username:''}; async function loadMe(){ try{ const r=await apiGet('/api/me'); if(r&&r.ok)_me=r.user||{is_admin:false,perms:[],username:''}; const un=document.getElementById('me-username'); if(un)un.textContent=_me.username||''; // 隐藏无权限的 tab 和按钮(data-perm="admin" 表示仅管理员) document.querySelectorAll('[data-perm]').forEach(function(el){ const p=el.dataset.perm; const ok=(p==='admin')?_me.is_admin:(_me.perms||[]).includes(p); if(!ok)el.style.display='none'; }); }catch(e){} } function _can(perm){ return _me.is_admin||(_me.perms||[]).includes(perm); } const PERM_LABELS={tasks:'任务管理',devices:'设备控制',apks:'应用管理',logs:'日志查看'}; 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',..._csrfHeaders()},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',..._csrfHeaders()},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); } // 清除单台设备的异常状态(error/failed → 未启动) async function clearDeviceError(serial){ if(!confirm('确认清除设备 '+serial+' 的异常状态?'))return; const r=await apiPost('/api/device/clear_error',{serial:serial}); handleResult(r,()=>loadMonitor()); } // 一键清除所有异常/失败设备的错误状态(运行中的任务不受影响) async function clearAllErrors(){ if(!confirm('确认清除所有设备的异常状态?(运行中/等待重试的设备会跳过)'))return; const r=await apiPost('/api/device/clear_all_errors'); handleResult(r,()=>loadMonitor()); } // ================== 设备画面查看 ================== let _shotTimer=null; // 自动刷新定时器 let _shotSerial=''; // 当前查看的 serial let _shotLoading=false; // 防止并发请求 // 打开截图弹窗 function openScreenshot(serial){ _shotSerial=serial; _shotLoading=false; // 渲染弹窗 document.getElementById('modal-title').textContent='设备画面 - '+serial; document.getElementById('modal-body').innerHTML= '
正在获取画面...
'+ '
'+ ''+ ''+ ''+ '
'; document.getElementById('modal-footer').innerHTML=''; // 切换为截图弹窗样式(自适应大小) document.getElementById('modal-box').classList.add('shot-modal'); showModal(); // 立即取一次 refreshShot(); // 开启自动刷新 startShotTimer(); } // 关闭截图弹窗(停掉定时器 + 关闭 modal + 还原样式) function closeShotModal(){ stopShotTimer(); _shotSerial=''; document.getElementById('modal-box').classList.remove('shot-modal'); closeModal(); } function toggleShotAuto(){ const cb=document.getElementById('shot-auto'); if(cb&&cb.checked){startShotTimer();}else{stopShotTimer();} } function startShotTimer(){ stopShotTimer(); _shotTimer=setInterval(refreshShot,3000); } function stopShotTimer(){ // 用 typeof 容错:变量未声明时不报错(避免 closeModal 调用时抛异常导致弹窗关不掉) if(typeof _shotTimer!=='undefined'&&_shotTimer){clearInterval(_shotTimer);_shotTimer=null;} } // 取一次截图 async function refreshShot(){ if(!_shotSerial)return; if(_shotLoading)return; // 上一次还没回来,跳过这次 _shotLoading=true; const el=document.getElementById('shot-content'); if(!el){_shotLoading=false;return;} // 首次或出错后显示 loading if(!el.querySelector('img')){ el.className='shot-loading'; el.textContent='正在获取画面...'; } const url='/api/device/screenshot?serial='+encodeURIComponent(_shotSerial)+'&t='+Date.now(); try{ const r=await fetch(url); if(r.status===401){window.location='/login';return;} if(!r.ok){ const j=await r.json().catch(()=>({})); throw new Error(j.error||('HTTP '+r.status)); } const blob=await r.blob(); const objUrl=URL.createObjectURL(blob); el.className=''; el.innerHTML='设备画面'; // 更新时间 const tEl=document.getElementById('shot-time'); if(tEl)tEl.textContent='更新于 '+new Date().toLocaleTimeString(); // 释放上一个 objUrl(避免内存泄漏) if(el._lastObjUrl)URL.revokeObjectURL(el._lastObjUrl); el._lastObjUrl=objUrl; }catch(e){ el.className='shot-err'; el.textContent='截图失败:'+e.message; }finally{ _shotLoading=false; } } async function apiDelete(url){ const r=await fetch(url,{method:'DELETE',headers:_csrfHeaders()}); 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','maintenance','tools'].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'){showSubTab('tasks',_activeSubs.tasks);loadTasks();loadCustomActions();} if(name==='apps'){showSubTab('apps',_activeSubs.apps);loadApks();} if(name==='maintenance'){showSubTab('maintenance',_activeSubs.maintenance);loadAdbDevices();loadTailscaleDevices();} if(name==='tools'){showSubTab('tools',_activeSubs.tools);loadToolsDevices();} if(name==='groups')loadGroups(); if(name==='logs'){loadLogs();if(document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);} if(name==='users')loadUsers(); } // ================== 页内子分栏(任务/应用/维护/工具 通用) ================== // 每个带子分栏的 Tab 记住上次选中的子分栏,切走再切回来保持原位 let _activeSubs = {tasks: 'plan', apps: 'apks', maintenance: 'stf', tools: 'clipboard'}; function showSubTab(tabId, name){ _activeSubs[tabId] = name; const tab = document.getElementById('tab-'+tabId); tab.querySelectorAll('.sub-tab').forEach(b=>b.classList.toggle('active', b.dataset.sub===name)); tab.querySelectorAll('.sub-panel').forEach(p=>p.classList.toggle('active', p.id===tabId+'-sub-'+name)); } // ================== 工具:剪贴板注入 ================== let _clipDevices = []; // [{serial, state}],来源 /api/adb/devices(本地 adb + STF 池) let _clipSelected = new Set(); function _escHtml(s){ return String(s==null?'':s).replace(/[&<>"']/g, c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } async function loadToolsDevices(){ const r = await apiGet('/api/adb/devices'); const status = document.getElementById('clip-status'); if(!r||!r.ok){status.textContent='加载失败';showToast('加载设备失败: '+(r&&r.error||''),'error');return;} _clipDevices = r.devices||[]; const wrap = document.getElementById('clip-devices'); if(!_clipDevices.length){ wrap.innerHTML='
无可用设备(本地 adb 或 STF 池均无)
'; status.textContent=''; return; } wrap.innerHTML = _clipDevices.map(d=>{ const st = d.state==='stf' ? 'STF池' : (d.state==='device' ? '已连接' : d.state); const stCls = (d.state==='device'||d.state==='stf') ? 'label-success' : 'label-warning'; return ''; }).join(''); status.textContent = '共 '+_clipDevices.length+' 台设备'; } function toggleClipDevice(cb){ if(cb.checked)_clipSelected.add(cb.value); else _clipSelected.delete(cb.value); } async function injectClipboard(){ const text = document.getElementById('clip-text').value.trim(); if(!text){showToast('请输入要注入的文字','error');return;} if(!_clipSelected.size){showToast('请先勾选目标设备','error');return;} const btn = document.querySelector('#tools-sub-clipboard .btn-primary'); btn.disabled = true; const old = btn.textContent; btn.textContent = '注入中...'; try{ const r = await apiPost('/api/tools/clipboard/set', {serials:[..._clipSelected], text}); if(!r||!r.ok){showToast('注入失败: '+(r&&r.error||''),'error');return;} const rows = Object.entries(r.results).map(([s,v])=> ''+_escHtml(s)+''+(v.ok?'成功':'失败 '+_escHtml(v.msg))+'').join(''); document.getElementById('clip-result').innerHTML = rows; document.getElementById('clip-result-wrap').style.display = 'block'; showToast('注入完成:成功 '+r.ok_count+' 台,失败 '+r.fail_count+' 台', r.fail_count?'error':'success'); } finally { btn.disabled = false; btn.textContent = old; } } // ================== 通用列表:搜索 + 分页 ================== // 用法: // 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(); // 加载异常汇总 loadSummary(); }catch(e){console.error(e);} } // ===== 异常设备汇总 ===== async function loadSummary(){ try{ const r=await apiGet('/api/summary'); if(!r||!r.ok)return; const counts=r.counts||{}; const errors=r.errors||[]; const errCount=(counts.error||0)+(counts.failed||0); const badge=document.getElementById('summary-badge'); const panel=document.getElementById('summary-panel'); const empty=document.getElementById('summary-empty'); if(badge)badge.textContent='· 异常 '+errCount+' · 运行 '+counts.running+' · 完成 '+counts.done; if(!panel||!empty)return; if(!errors.length){ panel.style.display='none'; empty.style.display='block'; return; } panel.style.display='block'; empty.style.display='none'; const tb=document.getElementById('tb-summary'); tb.innerHTML=errors.map(e=>{ const st=e.status==='failed'?'失败':'异常'; const up=e.updated?new Date(e.updated*1000).toLocaleTimeString():''; return ''+ ''+esc(e.serial)+''+(e.model?' '+esc(e.model)+'':'')+''+ ''+st+''+ ''+esc(e.task||'-')+''+ ''+(e.attempt>0?e.attempt+'次':'-')+''+ ''+esc(e.last_error||'-')+''+ ''; }).join(''); }catch(e){console.error(e);} } // 渲染设备表格(带排序+搜索+分页,复选状态保持) let _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()+'
':''; // 选择器健康警告(某步骤连续未找到元素,可能 App 改版选择器失效) const warnInfo=dev.last_warning?'
⚠ '+esc(dev.last_warning)+'
':''; // 设备操作按钮需要"设备控制"权限(无权限只保留截图查看) const canDev=_can('devices'); const stopBtn=canDev&&(dev.worker_status==='running'||dev.worker_status==='connecting')?'':''; // 截图按钮(任何状态都可看,离线时会报错) const shotBtn=''; // 异常/失败设备提供"清除异常"(把状态恢复为未启动) const clearBtn=canDev&&(dev.worker_status==='error'||dev.worker_status==='failed') ?'':''; const btns='
'+shotBtn+clearBtn+stopBtn+'
'; const devName=dev.device_name||dev.model||''; const devLabel=devName?''+esc(dev.serial)+'
'+esc(devName)+'':''+esc(dev.serial)+''; // 复选框列 const checked=_selectedSerials.has(dev.serial)?'checked':''; const rowCls=_selectedSerials.has(dev.serial)?'row-checked':''; const cb=''; return ''+cb+''+devLabel+''+esc(dev.model)+''+stf+offline+''+wb+''+fg+''+prog+''+tname+retry+endInfo+''+esc(dev.current_action||'-')+warnInfo+''+esc(dev.last_error||'-')+''+btns+''; } async function loadJobsForMonitor(){ try{ const r=await apiGet('/api/jobs');if(!r||!r.ok)return; _jobsData=r.jobs||[]; renderJobCards(); }catch(e){console.error(e);} } // 通用进度展示:解析 progress = {done, total, unit, action_counts, elapsed} // 任意 app 都能用:显示进度条 + "done/total unit" + 运行时长 + 操作计数徽章 // 下次运行时间格式化:"2026-08-12 09:00:00" → "08-12 09:00" function fmtNextRun(iso){ if(!iso)return ''; const s=String(iso).replace('T',' '); return s.length>=16?s.slice(5,16):s; } function fmtProgress(progress, workerStatus){ if(!progress||!Object.keys(progress).length){ // 空闲/未运行 → 显示"-" return '-'; } const done=progress.done||0; const total=progress.total||0; const unit=progress.unit||''; const elapsed=progress.elapsed||0; const counts=progress.action_counts||{}; // 进度条(百分比) let barHtml=''; if(total>0){ const pct=Math.min(100,Math.round(done*100/total)); barHtml='
'; } // 计数文本:"5/80 视频" 或 "5 视频"(无 total 时) let countHtml=''; if(total>0){ countHtml=''+done+'/'+total+(unit?' '+esc(unit):'')+''; }else if(done>0){ countHtml=''+done+(unit?' '+esc(unit):'')+''; } // 运行时长(elapsed>0 才显示) let timeHtml=''; if(elapsed>0){ const m=Math.floor(elapsed/60), s=elapsed%60; timeHtml=' '+m+'m'+s+'s'; } // 操作计数徽章(按数量降序,最多显示6个,超出的折叠为 +N) const countEntries=Object.entries(counts).filter(([_,v])=>v>0).sort((a,b)=>b[1]-a[1]); const MAX_COUNTS=6; const shown=countEntries.slice(0,MAX_COUNTS); const more=countEntries.length-MAX_COUNTS; let countsHtml=''; if(shown.length){ countsHtml='
'+shown.map(([k,v])=> ''+(ACTION_LABELS[k]||esc(k))+' '+v+'' ).join('')+(more>0?' +'+more+'':'')+'
'; } // 空闲/完成但无进度数据 → 灰显 if(done===0&&total===0&&elapsed===0&&!countParts.length){ return '-'; } return '
'+barHtml+countHtml+timeHtml+'
'+countsHtml; } function fmtParams(params){ if(!params||!Object.keys(params).length)return ''; const parts=[]; for(const [k,v] of Object.entries(params)){ if(k==='actions'){ if(v&&Object.keys(v).length){ const on=Object.entries(v).filter(([_,c])=>c.enabled).map(([a])=>ACTION_LABELS[a]||a); if(on.length)parts.push('操作:'+on.join('/')+''); } continue; } const val=typeof v==='boolean'?(v?'开':'关'):(Array.isArray(v)?v.length+'条':v); parts.push(''+k+'='+val+''); } return parts.join(''); } function renderJobCards(){ const jobs=_jobsData||[]; const running={}; if(_statusData)for(const dev of _statusData.devices){ if(dev.task_job)running[dev.task_job]=(running[dev.task_job]||0)+1; } document.getElementById('jobs-grid').innerHTML=jobs.length?jobs.map(j=>{ const runCount=running[j.name]||0; const isRunning=runCount>0; const tgt=TARGET_MAP[j.target.mode]||j.target.mode; const tgtDetail=j.target.mode==='group'?(j.target.group_name||''):(j.target.mode==='serial'?(j.target.serial||''):''); const sched=j.schedule.mode==='cron_stop'?('启停 '+j.schedule.cron+'→'+j.schedule.stop_cron): j.schedule.mode==='cron'?('定时 '+j.schedule.cron):'手动'; const retry=j.retry.max_attempts>1?(j.retry.max_attempts+'次/间隔'+j.retry.delay+'s'):'不重试'; const en=j.enabled?'启用':'停用'; const runBadge=isRunning?'运行中 '+runCount+'台':'空闲'; return '
'+ '
'+esc(j.name)+'
'+ '
'+esc(j.task_type)+'
'+ '
'+en+' '+runBadge+'
'+ '
'+ '
目标:'+esc(tgt)+(tgtDetail?'('+esc(tgtDetail)+')':'')+''+ '调度:'+esc(sched)+''+ '重试:'+esc(retry)+'
'+ '
'+fmtParams(j.params)+'
'+ '
'+ '
'+ ''+ ''+ ''+ ''+ '
'+ '
'; }).join(''):'
暂无任务,到"任务"Tab 新建
'; } // ================== 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'?('启停 '+cronToText(j.schedule.cron||'')+' → '+cronToText(j.schedule.stop_cron||'')): j.schedule.mode==='cron'?('定时 '+cronToText(j.schedule.cron||'')):'手动'; const schedTxt=sched+((j.schedule.window&&j.schedule.window.start&&j.schedule.window.end) ?' [窗口 '+esc(j.schedule.window.start)+'-'+esc(j.schedule.window.end)+']':''); const schedOut=''+schedTxt+''; // 下次运行时间(后端已按运行窗口跳过窗口外触发点) const nextRun=fmtNextRun(j.next_run); const nextOut=''+(nextRun||'-')+''; 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)+')':'')+''+ schedOut+ nextOut+ ''+retry+''+ ''+ (_can('tasks')?( ''+ ''+ ''+ '' ):'只读')+ ''+ ''; } 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||''):''; // 运行窗口(每天时间段,窗口外不启动) const win=j.schedule.window||null; const winStart=win?(win.start||'09:00'):'09:00'; const winEnd=win?(win.end||'18:00'):'18:00'; // 简单调度:把已有 cron 解析回"频率+时间",编辑时回填;解析不了落到自定义 const sc=parseCronToFreq(startCron); const stopF=parseCronToFreq(stopCron).time; const weekdayOpts=[0,1,2,3,4,5,6].map(w=> '').join(''); document.getElementById('modal-body').innerHTML= '
'+ '
'+ '
'+ '
'+ '
'+ '
'+ ''+ '
'+ ''+ '
'+ '
'+ ''+ '
'+ '
'+ '
'+ '
'+ ''+ '
'+ ''+ ''+ ''+ ''+ ''+ ''+ '
'+ '
'+ '
'+ '
'+ '
'+ '
'+ '
窗口外任务不会启动(定时触发和手动执行都受约束)。跨午夜示例:21:00-09:00 = 晚 9 点到次日早 9 点允许运行
'+ '
'+ '
'+ '
'+ '
'+ '
可留空使用默认参数。抖音任务可设 max_duration(秒,如 1800=30分钟)限制单设备运行时长
'+ '
'+ '
步骤编辑器用步骤卡片上的 ☑ 选中多个步骤,可打包成自定义动作'+ (_can('tasks')?'':'')+ '
'+ '
'+ '
操作库
'+ '
'+ '
'+ '
'; // 初始化步骤编辑器(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'; } // ===== 简单调度:cron(5段) ↔ 频率+时间 互转 ===== const WEEKDAY_NAMES=['周日','周一','周二','周三','周四','周五','周六']; // cron → 简单设置 {freq: daily|hourly|everyN|weekly|custom, time:"HH:MM", interval, weekday(0-6)} function parseCronToFreq(cron){ const r={freq:'custom',time:'',interval:2,weekday:1}; const p=(cron||'').trim().split(/\s+/); if(p.length!==5)return r; const [mi,h,d,m,w]=p; if(mi==='0'&&h==='*'&&d==='*'&&m==='*'&&w==='*'){r.freq='hourly';return r;} const n=(h.match(/^\*\/(\d+)$/)||[])[1]; // 每隔N小时:时位是 */N if(n&&mi==='0'&&d==='*'&&m==='*'&&w==='*'){r.freq='everyN';r.interval=parseInt(n)||2;return r;} if(/^\d{1,2}$/.test(mi)&&/^\d{1,2}$/.test(h)&&d==='*'&&m==='*'&&w==='*'){ r.freq='daily';r.time=h.padStart(2,'0')+':'+mi.padStart(2,'0');return r; } if(/^\d{1,2}$/.test(mi)&&/^\d{1,2}$/.test(h)&&d==='*'&&m==='*'&&/^[0-7]$/.test(w)){ r.freq='weekly';r.time=h.padStart(2,'0')+':'+mi.padStart(2,'0');r.weekday=parseInt(w)%7;return r; } return r; // 解析不了 → 落到自定义 } // 表单 → cron(5段) function buildCronFromFreq(){ const freq=document.getElementById('f-schedule_freq').value; if(freq==='custom')return document.getElementById('f-schedule_cron').value.trim(); if(freq==='hourly')return '0 * * * *'; if(freq==='everyN'){ const n=parseInt(document.getElementById('f-schedule_interval').value)||2; return '0 */'+Math.min(Math.max(n,1),12)+' * * *'; } const t=(document.getElementById('f-schedule_time').value||'09:00').split(':'); const mi=parseInt(t[1])||0, h=parseInt(t[0])||0; if(freq==='weekly'){ const w=parseInt(document.getElementById('f-schedule_weekday').value)||0; return mi+' '+h+' * * '+w; } return mi+' '+h+' * * *'; // daily } // cron(5段) → 中文描述(任务列表显示) function cronToText(cron){ const r=parseCronToFreq(cron||''); if(r.freq==='hourly')return '每小时'; if(r.freq==='everyN')return '每'+r.interval+'小时'; if(r.freq==='daily')return '每天 '+r.time; if(r.freq==='weekly')return WEEKDAY_NAMES[r.weekday]+' '+r.time; return 'cron: '+(cron||'-'); } function toggleScheduleFields(){ const mode=document.getElementById('f-schedule_mode').value; const showSched=(mode==='cron'||mode==='cron_stop'); document.getElementById('f-freq_wrap').style.display=showSched?'block':'none'; document.getElementById('f-stop_wrap').style.display=mode==='cron_stop'?'block':'none'; if(showSched)showFreqFields(); } function toggleWindowFields(){ const on=document.getElementById('f-window_enable').checked; document.getElementById('f-window_wrap').style.display=on?'flex':'none'; document.getElementById('f-window_help').style.display=on?'block':'none'; } function showFreqFields(){ const freq=document.getElementById('f-schedule_freq').value; document.getElementById('f-time_wrap').style.display=(freq==='daily'||freq==='weekly')?'block':'none'; document.getElementById('f-interval_wrap').style.display=freq==='everyN'?'block':'none'; document.getElementById('f-weekday_wrap').style.display=freq==='weekly'?'block':'none'; document.getElementById('f-cron_wrap').style.display=freq==='custom'?'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:'stop_app',label:'结束App',icon:'⏹',params:{package:''}}, {type:'screen_on',label:'亮屏',icon:'💡',params:{}}, {type:'screen_off',label:'息屏',icon:'🌙',params:{}}, {type:'keep_screen',label:'保持亮屏',icon:'🔆',params:{mode:'on'}}, {type:'key_event',label:'按键',icon:'⌨️',params:{key:'back'}}, {type:'swipe',label:'滑动',icon:'↕',params:{direction:'up',duration_min:0.25,duration_max:0.50}}, {type:'swipe_until',label:'滑动直到元素',icon:'🔍',params:{selector_type:'xpath',selector_value:'',direction:'up',max_swipes:8,click_when_found:true}}, {type:'click_xy',label:'点击坐标',icon:'🎯',params:{x:50,y:50}}, {type:'long_click',label:'长按元素',icon:'👆',params:{selector_type:'xpath',selector_value:'',duration:1.0,wait_timeout:2}}, {type:'wait_el',label:'等待元素',icon:'⏳',params:{selector_type:'xpath',selector_value:'',timeout:10}}, {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;} // 递归去掉步骤 id(保存到自定义动作库/拖入画布时重新生成,避免重复拖入后 id 冲突导致多选错乱) function _stripIds(steps){ steps.forEach(function(s){ delete s.id; if(s.params&&s.params.children&&Array.isArray(s.params.children))_stripIds(s.params.children); }); } function _findLib(type){return STEP_LIB.find(function(s){return s.type===type;});} // 选择器组件(类型下拉 + 值输入 + 抓取元素 + 测试按钮),click/long_click/swipe_until/wait_el 复用 function _selRowHtml(p, extra){ var opts=['xpath','description','text','resourceId','descriptionContains','className'].map(function(d){ return '';}).join(''); var pick=_can('devices')?'':''; var test=_can('devices')?'':''; return '
'+ '
'+ ''+pick+test+ '
'+(extra||''); } // 加载自定义动作库 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._updatePackBtns(); 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||''; card.dataset.stepType=step.type; 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==='stop_app'){ h+='
'; h+='
强制结束该 App(am force-stop),清除后台进程,下次打开为冷启动。常配合"打开App"使用:结束→重新打开
'; }else if(step.type==='screen_on'){ h+='
亮屏(息屏时唤醒并滑动解锁)。任务执行前用——息屏状态下 u2 无法点击操作
'; }else if(step.type==='screen_off'){ h+='
息屏。任务结束后用,避免长时间亮屏导致烧屏/发热
'; }else if(step.type==='keep_screen'){ h+='
'; h+='
长任务(如养号看视频几小时)放"保持亮屏"防屏幕超时熄灭;任务结束前放"恢复自动息屏"
'; }else if(step.type==='swipe'){ h+='
'; h+='
'; h+='
'; }else if(step.type==='click'){ h+=_selRowHtml(p,'
'); }else if(step.type==='long_click'){ h+=_selRowHtml(p,'
'+ '
'); }else if(step.type==='swipe_until'){ h+=_selRowHtml(p,'
'+ '
'+ '
'+ '
上滑/下滑直到目标元素出现(每次滑动后检查一次),找不到则跳过继续
'); }else if(step.type==='wait_el'){ h+=_selRowHtml(p,'
'+ '
等待目标元素出现(替代固定秒数等待),超时则继续
'); }else if(step.type==='click_xy'){ h+='
'+ '
'+ '
屏幕百分比坐标,50/50=屏幕中心。无选择器时的兜底点击
'; }else if(step.type==='key_event'){ h+='
'; h+='
返回键用于退出评论/返回上一页,比结束App轻量
'; }else if(step.type==='wait'){ h+='
'; h+='
'; }else if(step.type==='input_text'){ const im=p.mode==='fixed'?'fixed':'random'; h+='
'; 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'); // 子卡片都会 stopPropagation,能冒泡到这里的 drop 一定落在空白区 // (含 .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]; var srcArr=this._resolveArr(fromPath); if(srcArr){ if(srcArr===arr){ // 同层级排序 if(fromIdx===idx)return; this._syncFromDom(); var moved=arr.splice(fromIdx,1)[0]; if(idx<0)arr.push(moved); else if(fromIdx=arr.length)arr.push(moved2); else arr.splice(idx,0,moved2); rerender(); } } } }else if(data.startsWith('action:')){ // 从自定义动作库拖入 → 展开为 group 步骤 var actionId=data.substring(7); var action=_customActions.find(function(a){return a.id===actionId;}); if(action&&action.steps&&action.steps.length){ 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)); _stripIds(ns.params.children); // 重新生成 id,避免同一动作拖入多次后 id 冲突 this._ensureIds(ns.params.children); if(idx<0||idx>=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))}; }, // 测试此步骤:在选中设备上试执行当前步骤,验证选择器 _testStep:function(btn){ var card=btn.closest('.step-card'); if(!card)return; var type=card.dataset.stepType||''; var params={}; card.querySelectorAll('[data-key]').forEach(function(inp){ if(inp.type==='checkbox')params[inp.dataset.key]=inp.checked; else if(inp.type==='number')params[inp.dataset.key]=parseFloat(inp.value)||0; else params[inp.dataset.key]=inp.value; }); this._pendingTest={type:type,label:'测试',params:params}; this._showTestDevicePicker(); }, _showTestDevicePicker:function(){ document.getElementById('test-modal-title').textContent='测试步骤 - 选择设备'; document.getElementById('test-modal-body').innerHTML= '
选择在哪个设备上试执行
加载中...
'; document.getElementById('test-overlay').classList.add('show'); var self=this; apiGet('/api/uiauto/devices').then(function(r){ var list=document.getElementById('test-device-list'); if(!list)return; if(!r||!r.ok){list.innerHTML='
'+esc((r&&r.error)||'获取设备失败')+'
';return;} list.innerHTML=(r.devices||[]).map(function(d){ return '
'+ '
📱
'+ '
'+esc(d.serial)+'
'+ '
'+esc(d.model||d.product||'')+'
'; }).join('')||'
无可用设备
'; }); }, _runTestStep:async function(serial){ var step=this._pendingTest; var body=document.getElementById('test-modal-body'); if(!step||!body)return; body.innerHTML='
正在 '+esc(serial)+' 上执行(adb + u2 连接约需几秒)...
请稍候,期间不要操作该设备
'; var r=await apiPost('/api/steps/test',{serial:serial,step:step}); if(!r)return; var icon=r.result==='命中'?'✅':(r.result==='未找到'?'❌':'▶'); body.innerHTML='
'+icon+' '+esc(r.result||'已执行')+''+ '
'+esc(r.msg||'')+'
'+ '
'; }, 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)); _stripIds(ns.params.children); // 重新生成 id,避免同一动作拖入多次后 id 冲突 this._ensureIds(ns.params.children); this._steps.push(ns); this._renderCanvas(); }, delCustomAction:function(actionId){ if(!confirm('确定删除这个自定义动作?'))return; apiDelete('/api/custom_actions/'+actionId).then(function(res){ if(!res)return; if(res.ok){_loadCustomActions();showToast('已删除','success');} else showToast(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(); this._updatePackBtns(); }, // 同步所有"打包选中步骤"按钮的可用状态(任务编辑器 + 动作编辑器) _updatePackBtns:function(){ var n=this._selected&&this._selected.length; ['se-pack-btn','se-pack-btn2'].forEach(function(id){ var bar=document.getElementById(id); if(bar)bar.disabled=n<1; }); }, saveAsCustomAction:function(stepId){ // 从 group 步骤的"存为自定义动作"按钮调用时传入该 group 的 id: // 优先收集该 group 的子步骤;否则收集 ☑ 选中的步骤 var stepsToSave=[]; if(stepId){ var group=this._findStepById(this._steps,stepId); if(group&&group.params&&group.params.children&&group.params.children.length){ stepsToSave=JSON.parse(JSON.stringify(group.params.children)); _stripIds(stepsToSave); } } if(!stepsToSave.length&&this._selected&&this._selected.length){ // 从选中步骤收集 stepsToSave=this._collectByIds(this._steps,this._selected); } if(!stepsToSave.length){ showToast('请先用 ☑ 选中要打包的步骤','error'); return; } var name=prompt('输入动作名称:','我的动作'); if(!name)return; var self=this; apiPost('/api/custom_actions',{name:name,steps:stepsToSave}) .then(function(res){ if(!res)return; if(res.ok){ showToast('已保存到自定义动作库','success'); self._selected=[]; _loadCustomActions(); self._renderCanvas(); self._updatePackBtns(); }else showToast(res.error||'保存失败','error'); }); }, // 在嵌套结构中按 id 查找步骤(group/loop 的子步骤也找得到) _findStepById:function(steps,id){ for(var i=0;i=0){ var copy=JSON.parse(JSON.stringify(s)); _stripIds([copy]); result.push(copy); // 容器选中时子步骤已包含在拷贝里,不再单独收集 }else if(s.params&&s.params.children&&Array.isArray(s.params.children)){ result=result.concat(self._collectByIds(s.params.children,ids)); } }); 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]; }, // 判断 step 的子孙数组中是否包含 arr(防循环块套进自己导致渲染无限递归) _containsArr:function(step,arr){ if(!step||!step.params||!Array.isArray(step.params.children))return false; if(step.params.children===arr)return true; for(var i=0;i=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==='long_click'||s.type==='swipe_until'||s.type==='wait_el'){ if(!s.params.selector_value){ warnings.push(name+' "'+label+'": 选择器值为空,该步骤会跳过。请用"抓取元素"或"测试"按钮选择目标。'); } }else if(s.type==='click_xy'){ const x=parseFloat(s.params.x), y=parseFloat(s.params.y); if(isNaN(x)||isNaN(y)||x<0||x>100||y<0||y>100){ warnings.push(name+' "'+label+'": 坐标需在 0-100 之间(屏幕百分比)。'); } }else if(s.type==='open_app'||s.type==='stop_app'){ if(!s.params.package){ warnings.push(name+' "'+label+'": 包名为空,无法'+(s.type==='open_app'?'启动':'结束')+'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= ''+ ''; // 加载截图和元素树(_refreshShot 内部同时刷新两者) self._refreshShot(); }, // 拉取元素树(进入页面和刷新时共用;带时间戳防缓存) _loadElData:function(){ var serial=this._elSerial; if(!serial)return; var listEl=document.getElementById('el-list'); if(listEl)listEl.innerHTML='
加载元素树...
'; var self=this; fetch('/api/uiauto/elements?serial='+encodeURIComponent(serial)+'&_='+Date.now()) .then(function(r){return r.json();}) .then(function(data){ var le=document.getElementById('el-list'); if(!le)return; if(!data.ok){ le.innerHTML='
'+esc(data.error||'抓取失败')+'
'; return; } self._elData=data.elements||[]; self._renderEl(self._elData); // 刷新后保持搜索过滤状态 var search=document.getElementById('el-search'); if(search&&search.value)self._filterEl(search.value); self._renderOverlays(); }) .catch(function(e){ var le=document.getElementById('el-list'); if(le)le.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(); // 同时重新拉取元素树——只刷截图会导致"截图是新的、元素框是旧的"错位 this._loadElData(); }, // 在截图上渲染元素边界框(可点击) _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'}); } // 信息栏显示将填入的选择器,确认是否精确命中目标 var infoEl=document.getElementById('el-shot-info'); if(infoEl&&this._elData&&this._elData[i]){ var sg=this._elData[i].suggested||{}; infoEl.textContent='已选中: '+(sg.type||'')+' '+(sg.value||''); } }, _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)); var sg=el.suggested||{}; return '
'+ ''+(el.depth||0)+''+ '
'+indent+esc(el.name||el.class||'')+'
'+ '
'+attrs.join('')+'
'+ ''+esc(sg.type)+''+ (sg.indexed?' #'+sg.occ+'/'+sg.total+'':'')+ (sg.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:buildCronFromFreq()}; }else if(schedMode==='cron_stop'){ const st=(document.getElementById('f-schedule_stop_time').value||'18:00').split(':'); schedule={mode:'cron_stop', cron:buildCronFromFreq(), stop_cron:(parseInt(st[1])||0)+' '+(parseInt(st[0])||0)+' * * *'}; } // 运行窗口:窗口外(定时触发 + 手动执行)不启动 if(document.getElementById('f-window_enable').checked){ schedule.window={ start:document.getElementById('f-window_start').value||'09:00', end:document.getElementById('f-window_end').value||'18:00', }; } 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||'-')+''+ ''+ (_can('tasks')?( ' '+ '' ):'只读')+ ''+ ''; } 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= '
'+ '
'+ '
'+ '
'+ '
用下方步骤编辑器编排动作内容,保存后可拖入任务步骤编辑器复用
'+ '
'+ '
动作步骤支持循环/分组/概率触发/拖拽排序'+ (_can('tasks')?'':'')+'
'+ '
'+ '
操作库
'+ '
'+ '
'+ '
'; 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(); _stripIds(steps); // 入库前去掉 id,避免重复使用后 id 冲突 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(', '):'-')+''+ ''+ (_can('tasks')?( ''+ '' ):'只读')+ ''+ ''; } 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?'管理员':'普通'; const perms=(u.perms||[]).map(p=>''+(PERM_LABELS[p]||p)+'').join(' ') ||'-'; return ''+ ''+u.id+''+ ''+esc(u.username)+''+ ''+admin+''+ ''+perms+''+ ''+ ''+ (u.username==='admin'?'':'')+ ''+ ''; } function togglePermBoxes(){ // 勾选"管理员权限"时:权限位全选并禁用(管理员拥有全部权限) const isAdmin=document.getElementById('u-is_admin').checked; document.querySelectorAll('.u-perm').forEach(cb=>{ cb.disabled=isAdmin; if(isAdmin)cb.checked=true; }); } function openUserModal(uid){ const isEdit=!!uid; document.getElementById('modal-title').textContent=isEdit?'编辑用户':'新建用户'; const permBoxes=Object.keys(PERM_LABELS).map(p=> '' ).join(''); document.getElementById('modal-body').innerHTML= '
'+ '
'+ '
'+ '
'+ '
'+permBoxes+'
'+ '
任务管理=任务/自定义动作/分组;设备控制=停止/释放/清异常/扫描/抓元素;应用管理=APK上传安装;日志查看=日志页
'+ '
'; document.getElementById('modal-footer').innerHTML= ''+ ''; if(isEdit){ // 预填充(从表格数据获取) apiGet('/api/users').then(r=>{ const u=(r&&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; document.querySelectorAll('.u-perm').forEach(cb=>{ cb.checked=(u.perms||[]).includes(cb.value); }); togglePermBoxes(); } }); }else{ togglePermBoxes(); } 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; const perms=[...document.querySelectorAll('.u-perm:checked')].map(cb=>cb.value); if(uid){ const data={is_admin:is_admin,perms:perms}; 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,perms:perms}); handleResult(r,()=>{closeModal();loadUsers();}); } } async function deleteUser(uid){ if(!confirm('确认删除此用户吗?'))return; const r=await apiDelete('/api/users/'+uid); handleResult(r,()=>loadUsers()); } // ================== Tab: 维护(仅管理员) ================== // 服务器类命令(不需要 -s 目标设备) const _NO_DEVICE_CMDS=['devices','version','help','start-server','connect','kill-server','disconnect']; function _selectedSerial(){ const sel=document.getElementById('adb-device'); return sel?sel.value:''; } // 选中目标设备时自动附加 -s ;服务器类命令保持原样 function _attachDevice(cmd){ const serial=_selectedSerial(); if(!serial)return cmd; if(/^-s\s/.test(cmd))return cmd; // 已手写 -s 不再重复附加 const first=(cmd.split(/\s+/)[0]||'').toLowerCase(); if(_NO_DEVICE_CMDS.indexOf(first)>=0)return cmd; return '-s '+serial+' '+cmd; } async function loadAdbDevices(){ const sel=document.getElementById('adb-device'); if(!sel)return; const r=await apiGet('/api/adb/devices'); if(!r||!r.ok)return; const cur=sel.value; const stateTxt={device:'在线',offline:'离线',stf:'STF池(未连接)'}; const opts=[''].concat( (r.devices||[]).map(d=> '' ) ); sel.innerHTML=opts.join(''); if(cur)sel.value=cur; } function fillAdbCmd(cmd){ document.getElementById('adb-cmd-input').value=cmd; } async function _runAdbFull(full){ const out=document.getElementById('adb-output'); out.textContent='$ '+full+'\n执行中...'; const r=await apiPost('/api/adb/cmd',{cmd:full}); if(!r)return; if(!r.ok){out.textContent='$ '+full+'\n⚠ '+r.error;return;} out.textContent='$ '+full+'\n\n'+(r.stdout||'')+(r.stderr?'\n[stderr] '+r.stderr:'')+'\n\n[exit '+r.code+']'; out.scrollTop=out.scrollHeight; } async function runAdbCmd(){ const input=document.getElementById('adb-cmd-input'); let cmd=(input.value||'').trim(); if(!cmd){showToast('请输入 adb 命令','error');return;} cmd=cmd.replace(/^adb\s+/i,''); // 兼容手输/快捷按钮带的 "adb " 前缀,避免拼成 "adb adb devices" cmd=_attachDevice(cmd); await _runAdbFull('adb '+cmd); } function uninstallStfAgent(){ const serial=_selectedSerial(); if(!serial){showToast('请先选择目标设备','error');return;} if(!confirm('确定卸载 '+serial+' 上的 STF agent(jp.co.cyberagent.stf)?\n\n卸载后 STF 会在设备重连时自动重新推送安装。'))return; _runAdbFull('adb -s '+serial+' uninstall jp.co.cyberagent.stf'); } function reconnectAdbDevice(){ const serial=_selectedSerial(); if(!serial){showToast('请先选择目标设备','error');return;} _runAdbFull('adb connect '+serial); } function checkStfAgent(){ const serial=_selectedSerial(); if(!serial){showToast('请先选择目标设备','error');return;} _runAdbFull('adb -s '+serial+' shell pm list packages | grep cyberagent'); } async function restartStf(){ if(!confirm('确定重启 STF 容器?\n\n影响:STF 约 30 秒不可用,期间所有设备占用状态重置,正在执行的任务会受影响。\n\n确认继续吗?'))return; const st=document.getElementById('stf-restart-status'); st.textContent='正在重启 STF 容器(SSH 执行中,最长 60s)...'; const r=await apiPost('/api/stf/restart',{}); if(!r)return; if(r.ok){st.textContent='✅ '+r.msg+(r.detail?' ['+r.detail+']':'');showToast(r.msg,'success');} else{st.textContent='❌ '+r.error;showToast(r.error,'error');} } // 测试此步骤弹窗关闭 function closeTestModal(){ const ov=document.getElementById('test-overlay'); if(ov)ov.classList.remove('show'); } // ================== Tab: 维护 - Tailscale 管理 ================== // 与其他表格共用 setListPager/applyListPager 统一逻辑(搜索 + 分页) let _tsDevices=[]; async function loadTailscaleDevices(){ const tb=document.getElementById('tb-tailscale'); const st=document.getElementById('ts-status'); const hint=document.getElementById('ts-hint'); if(!tb)return; tb.innerHTML='加载中...'; const r=await apiGet('/api/tailscale/devices'); if(!r)return; if(!r.ok){ tb.innerHTML=''+esc(r.error||'获取失败')+''; if(st)st.textContent=''; const s=await apiGet('/api/tailscale/status'); if(s&&s.ok&&!s.configured&&hint)hint.textContent='⚠ 未配置:'+esc(s.hint||'')+'(写入 .env 后重启服务生效)'; return; } if(hint)hint.textContent=''; _tsDevices=r.devices||[]; setListPager('tailscale',{ data:()=>_tsDevices, filter:(d,kw)=>((d.hostname||'').toLowerCase().includes(kw)||(d.name||'').toLowerCase().includes(kw)||(d.addresses||[]).join(',').includes(kw)), render:renderTsRow, tbody:'tb-tailscale',pager:'pager-tailscale',pageSize:10, empty:'tailnet 暂无设备' }); applyListPager('tailscale'); initListSearch('tailscale','search-tailscale'); if(st)st.textContent='共 '+_tsDevices.length+' 台设备'; } function renderTsRow(d){ const ip=(d.addresses||[])[0]||'-'; return ''+ ''+esc(d.hostname||'-')+''+ ''+esc(d.name||'-')+''+ ''+esc(ip)+''+ ''+esc(d.os||'-')+''+ ''+((d.online===true)?'在线':(d.online===false?'离线':'最近在线'))+''+ ''+(d.authorized?'已授权':'未授权')+''+ ''+(d.key_expiry_disabled?'永不过期':'会过期')+''+ ''+ ' '+ ' '+ ' '+ ' '+ ''+ ''; } function renameTailscaleDevice(id, current){ const name=prompt('修改显示名(只改名称,不影响主机名):', current||''); if(name===null)return; apiPost('/api/tailscale/devices/'+id,{name:name}).then(r=>{ if(!r)return; if(r.ok){showToast(r.msg,'success');loadTailscaleDevices();} else showToast(r.error,'error'); }); } function setTsIp(id, current){ const ip=prompt('设置新的 Tailscale IPv4 地址(如 100.100.10.16):\n\n⚠ 改 IP 会断开设备当前 tailscale 连接,且平台设备池(STF serial)随之变化,改完记得同步 STF 设备池/分组/任务目标。', (current||'').split(':')[0]||''); if(ip===null)return; const v=ip.trim(); if(!v){showToast('IP 不能为空','error');return;} apiPost('/api/tailscale/devices/'+id+'/ip',{ipv4:v}).then(r=>{ if(!r)return; if(r.ok){showToast(r.msg,'success');loadTailscaleDevices();} else showToast(r.error,'error'); }); } function toggleTsAuth(id, authorized){ apiPost('/api/tailscale/devices/'+id,{authorized:authorized}).then(r=>{ if(!r)return; if(r.ok){showToast(authorized?'已授权':'已取消授权','success');loadTailscaleDevices();} else showToast(r.error,'error'); }); } function toggleTsKeyExpiry(id, disabled){ apiPost('/api/tailscale/devices/'+id,{key_expiry_disabled:disabled}).then(r=>{ if(!r)return; if(r.ok){showToast(disabled?'已设置密钥永不过期(设备不再被踢下线)':'已恢复密钥定期过期','success');loadTailscaleDevices();} else showToast(r.error,'error'); }); } function deleteTailscaleDevice(id){ if(!confirm('确定从 tailnet 移除该设备?\n设备下次上线需重新授权。'))return; apiDelete('/api/tailscale/devices/'+id).then(r=>{ if(!r)return; if(r.ok){showToast(r.msg,'success');loadTailscaleDevices();} else showToast(r.error,'error'); }); } function openTailscaleAuthKey(){ const desc=prompt('auth key 用途说明(可空,仅支持英文/数字):','auto_control'); if(desc===null)return; const reusable=confirm('允许复用(同一 key 给多台设备用)?\n确定=可复用,取消=一次性'); const secs=prompt('有效期(秒,默认 3600=1 小时):','3600'); if(secs===null)return; const st=document.getElementById('ts-status'); if(st)st.textContent='正在生成 auth key...'; apiPost('/api/tailscale/authkey',{description:desc||'',reusable:reusable,expiry_seconds:parseInt(secs)||3600}).then(r=>{ if(!r)return; if(st)st.textContent=''; if(r.ok){ const ov=document.getElementById('test-overlay'); document.getElementById('test-modal-title').textContent='Tailscale Auth Key(只显示一次)'; document.getElementById('test-modal-body').innerHTML= '
新设备执行 tailscale up --authkey=... 接入(默认已预授权)
'+ ''+ ''+ ' '; ov.classList.add('show'); showToast('Auth Key 已生成','success'); }else{ showToast(r.error,'error'); } }); } function copyTsKey(){ const box=document.getElementById('ts-key-box'); if(!box)return; box.select(); try{document.execCommand('copy');}catch(e){} const s=document.getElementById('ts-key-status'); if(s)s.textContent='已复制'; } // ================== 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'); if(_csrfToken)xhr.setRequestHeader('X-CSRF-Token',_csrfToken); 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 r=await apiGet('/api/apks/install/devices'); const online=(r&&r.ok)?(r.devices||[]):[]; if(!online.length){ showToast('没有可用设备(STF 池或本机 adb 均无)','error');return; } const body= '
'+ '安装应用:'+esc(apkName)+''+ ' 运行中设备将自动跳过'+ '
'+ '
'+ ' '+ ' '+ ''+ '
'+ '
'+ online.map(d=>{ const isUsb=d.source==='usb'; const st=''+(isUsb?'本地USB':(d.source==='stf'?'STF':'adb'))+''; // serial 唯一,显示 IP + 型号便于区分同型号设备 const label=(d.serial||'').replace(/:5555$/,'')+' · '+(d.model||(isUsb?'USB 有线设备':'设备')); 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');} } // ================== 初始化 ================== initCsrf(); // 获取 CSRF token(非 GET 请求需携带) loadMe(); // 获取当前用户权限,隐藏无权限的功能入口 showTab('monitor');