// ================== 通用工具 ==================
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=
'
正在获取画面...
'+
''+
'刷新 '+
' 自动刷新(3秒) '+
' '+
'
';
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','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==='tools'){showSubTab('tools',_activeSubs.tools);loadToolsDevices();loadAdbDevices();loadTailscaleDevices();loadApks();loadStfDevMgmt();}
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', 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();
// 本地 adb 设备列表共享缓存(剪贴板/终端两个面板同源,10 秒内复用避免重复请求;force 强制刷新)
let _adbDevicesCache = null; // {ts, data}
async function _fetchAdbDevices(force){
const now = Date.now();
if(!force && _adbDevicesCache && now - _adbDevicesCache.ts < 10000){
return _adbDevicesCache.data;
}
const r = await apiGet('/api/adb/devices');
if(!r || !r.ok) return null;
_adbDevicesCache = {ts: now, data: r.devices || []};
return _adbDevicesCache.data;
}
async function loadToolsDevices(force){
const list = await _fetchAdbDevices(force);
const status = document.getElementById('clip-status');
if(list === null){status.textContent='加载失败';showToast('加载设备失败','error');return;}
_clipDevices = list;
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 ''
+' '
+''+esc(d.serial)+' '
+''+st+' ';
}).join('');
status.textContent = '共 '+_clipDevices.length+' 台设备';
}
function toggleClipDevice(cb){
if(cb.checked)_clipSelected.add(cb.value);
else _clipSelected.delete(cb.value);
}
// ================== 工具:应用版本管理 ==================
async function queryAppVers(){
const pkg = document.getElementById('appver-pkg').value.trim();
if(!pkg){showToast('请输入包名','error');return;}
const btn = document.querySelector('#tools-sub-appver .btn-primary');
btn.disabled = true; const old = btn.textContent; btn.textContent = '查询中...';
try{
const r = await apiPost('/api/tools/appver', {pkg});
if(!r || !r.ok){showToast('查询失败: '+(r&&r.error||''),'error');return;}
const rows = Object.entries(r.results||{}).map(([serial,v])=>{
if(v.error)return ''+esc(serial)+' '+
'查询失败 '+esc(v.error)+' ';
if(v.installed)return ''+esc(serial)+' '+
'已安装 '+
''+esc(v.version_name||'')+' '+esc(v.version_code||'')+' ';
return ''+esc(serial)+' '+
'未安装 - - ';
}).join('');
document.getElementById('tb-appver').innerHTML = rows || '无可用设备 ';
document.getElementById('appver-status').textContent = '共 '+(r.total||0)+' 台设备'+(r.fail?','+r.fail+' 台查询失败':'');
if(r.fail)showToast(r.fail+' 台设备查询失败,详见表格','error');
} finally {
btn.disabled = false; btn.textContent = old;
}
}
// ================== 工具:STF 设备管理 ==================
async function loadStfDevMgmt(){
const status = document.getElementById('stfdev-status');
const r = await apiGet('/api/stf/devmgmt/status');
if(!r || !r.ok){status.textContent='加载失败';showToast('加载失败: '+(r&&r.error||''),'error');return;}
const configured = r.configured || []; // 脚本 DEVICES 里的 IP(裸 IP)
const connected = {}; // 裸 IP -> state(220 adb devices 的 serial 带 :5555,归一化)
(r.connected || []).forEach(d=>{
const s = d.serial;
connected[s.endsWith(':5555') ? s.slice(0,-5) : s] = d.state;
});
const ips = Array.from(new Set(configured.concat(Object.keys(connected))));
const tbody = document.getElementById('tb-stfdev');
if(!ips.length){
tbody.innerHTML='无设备(脚本未配置、220 adb 也无连接) ';
status.textContent='';
return;
}
tbody.innerHTML = ips.map(ip=>{
const inCfg = configured.includes(ip);
const st = connected[ip];
const stHtml = st==='device'
? '已连接 '
: (st ? ''+esc(st)+' ' : '未连接 ');
return ''+esc(ip)+' '+
''+(inCfg?'脚本中 ':'未配置 ')+' '+
''+stHtml+' '+
'移除(断开+删脚本) ';
}).join('');
status.textContent = '脚本配置 '+configured.length+' 台,已连接 '+Object.keys(connected).length+' 台';
}
async function addStfDev(){
const ip = document.getElementById('stfdev-ip').value.trim();
if(!ip){showToast('请输入设备 IP','error');return;}
const r = await apiPost('/api/stf/devmgmt/add', {ip:ip});
if(!r)return;
if(r.ok){
showToast((r.msgs||[]).join(';'),'success');
document.getElementById('stfdev-ip').value='';
loadStfDevMgmt();
}else showToast('添加失败: '+(r.error||''),'error');
}
async function removeStfDev(ip){
if(!confirm('确定移除 '+ip+'?\n\n将从 connect_devices.sh 删除并断开 220 的 adb 连接,\n设备会从 OpenSTF 设备池下线(任务不再分配到它)。'))return;
const r = await apiPost('/api/stf/devmgmt/remove', {ip:ip});
if(!r)return;
if(r.ok){
showToast((r.msgs||[]).join(';'),'success');
loadStfDevMgmt();
}else showToast('移除失败: '+(r.error||''),'error');
}
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])=>
''+esc(s)+' '+(v.ok?'成功 ':'失败 '+esc(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?'':'';
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();
// 加载异常汇总
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)+'
'+
'
'+
'
'+
'立即执行 '+
''+(j.enabled?'停用':'启用')+' '+
'编辑 '+
'删除 '+
'
'+
'
';
}).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')?(
'执行 '+
''+(j.enabled?'停用':'启用')+' '+
'编辑 '+
'删除 '
):'只读 ')+
' '+
' ';
}
function openTaskModal(jobId){
const job=jobId?(_jobsData||[]).find(j=>j.id===jobId):null;
const isEdit=!!job;
document.getElementById('modal-title').textContent=isEdit?'编辑任务':'新建任务';
// 任务编辑器含步骤编辑,加宽弹窗;关闭时在 closeModal 移除
document.getElementById('modal-box').classList.add('wide-modal');
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=>''+esc(t.name)+' ').join('');
const gl=_groupsList.map(g=>''+esc(g.name)+' ').join('');
const dl=_devicesList.map(s=>''+esc(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=>
''+WEEKDAY_NAMES[w]+' ').join('');
document.getElementById('modal-body').innerHTML=
''+
'启用此任务
'+
''+
''+
'启用运行窗口(只在指定时间段内运行)
'+
''+
'窗口外任务不会启动(定时触发和手动执行都受约束)。跨午夜示例:21:00-09:00 = 晚 9 点到次日早 9 点允许运行
'+
''+
''+
''+
'
步骤编辑器 用步骤卡片上的 ☑ 选中多个步骤,可打包成自定义动作 '+
(_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=
'取消 '+
''+(isEdit?'保存':'创建')+' ';
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=[
// cat:interact=交互操作 / screen=屏幕与App / flow=流程控制(操作库按此分组展示)
{type:'click',label:'点击元素',icon:'✦',cat:'interact',params:{selector_type:'xpath',selector_value:'',wait_timeout:2}},
{type:'click_xy',label:'点击坐标',icon:'🎯',cat:'interact',params:{x:50,y:50}},
{type:'long_click',label:'长按元素',icon:'👆',cat:'interact',params:{selector_type:'xpath',selector_value:'',duration:1.0,wait_timeout:2}},
{type:'swipe',label:'滑动',icon:'↕',cat:'interact',params:{direction:'up',duration_min:0.25,duration_max:0.50}},
{type:'swipe_until',label:'滑动直到元素',icon:'🔍',cat:'interact',params:{selector_type:'xpath',selector_value:'',direction:'up',max_swipes:8,click_when_found:true}},
{type:'input_text',label:'输入文字',icon:'⌨',cat:'interact',params:{mode:'random',texts:'你好\n有趣\n支持',fixed_text:'',clear_first:true}},
{type:'key_event',label:'按键',icon:'⌨️',cat:'interact',params:{key:'back'}},
{type:'open_app',label:'打开App',icon:'▶',cat:'screen',params:{package:'',wait_home:false,home_feature:''}},
{type:'stop_app',label:'结束App',icon:'⏹',cat:'screen',params:{package:''}},
{type:'screen_on',label:'亮屏',icon:'💡',cat:'screen',params:{}},
{type:'screen_off',label:'息屏',icon:'🌙',cat:'screen',params:{}},
{type:'keep_screen',label:'保持亮屏',icon:'🔆',cat:'screen',params:{mode:'on'}},
{type:'wait',label:'等待',icon:'⏱',cat:'flow',params:{min:1.0,max:3.0}},
{type:'wait_el',label:'等待元素',icon:'⏳',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:10}},
{type:'loop',label:'循环块',icon:'↻',cat:'flow',params:{loop_mode:'rounds',max_iterations:10,loop_duration:600,children:[]}},
{type:'group',label:'动作组',icon:'📦',cat:'flow',params:{children:[]}},
{type:'if_el',label:'条件判断',icon:'❓',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:3,ocr_click:false,then:[],else:[]}}
];
var STEP_LIB_CATS=[['interact','交互操作'],['screen','屏幕与App'],['flow','流程控制']];
var _customActions=[]; // 后端加载的自定义动作
var _stepIdCounter=0;
function _newStepId(){_stepIdCounter++;return 'step_'+Date.now().toString(36)+'_'+_stepIdCounter;}
// 步骤的所有子步骤数组(loop/group 的 children、if_el 的 then/else 分支)
function _childArrs(s){
var arrs=[];
if(s&&s.params){
if(Array.isArray(s.params.children))arrs.push(s.params.children);
if(Array.isArray(s.params.then))arrs.push(s.params.then);
if(Array.isArray(s.params.else))arrs.push(s.params.else);
}
return arrs;
}
// 递归去掉步骤 id(保存到自定义动作库/拖入画布时重新生成,避免重复拖入后 id 冲突导致多选错乱)
function _stripIds(steps){
steps.forEach(function(s){
delete s.id;
_childArrs(s).forEach(_stripIds);
});
}
function _findLib(type){return STEP_LIB.find(function(s){return s.type===type;});}
// 选择器组件(类型下拉 + 值输入 + 抓取元素 + 测试按钮),click/long_click/swipe_until/wait_el/if_el 复用
// withOcr=true 时额外提供 OCR识别 选项(图片/画布里的文字,仅条件判断支持)
function _selRowHtml(p, extra, withOcr){
var types=withOcr?['xpath','description','text','resourceId','descriptionContains','className','ocr']:
['xpath','description','text','resourceId','descriptionContains','className'];
var labels={xpath:'xpath',description:'description',text:'text',resourceId:'resourceId',
descriptionContains:'descriptionContains',className:'className',ocr:'OCR识别'};
var opts=types.map(function(d){
return ''+labels[d]+' ';}).join('');
var toggle=withOcr?' onchange="_stepEditor._toggleOcrMode(this)"':'';
var pick=_can('devices')?'抓取元素 ':'';
var test=_can('devices')?'测试 ':'';
return ''+(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();
_childArrs(s).forEach(function(a){self._ensureIds(a);});
});
},
_renderLib:function(){
var lib=this._lib;
if(!lib)return; // 未初始化编辑器(如仅加载动作列表时)跳过
// 按分类分组展示(STEP_LIB_CATS 定义分类顺序)
var items=STEP_LIB_CATS.map(function(c){
var list=STEP_LIB.filter(function(s){return s.cat===c[0];}).map(function(s){
return ''+
''+s.icon+' '+s.label+'
';
}).join('');
return '';
}).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 card=document.createElement('div');
card.className='step-card'+(step.type==='loop'?' loop-block':'')+(step.type==='group'?' group-block':'')+(step.type==='if_el'?' if-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(step.type==='loop'||step.type==='group'){
if(step.params&&step.params.children){
var childWrap=document.createElement('div');
childWrap.className='step-children';
this._renderStepList(childWrap,step.params.children);
card.appendChild(childWrap);
}
}
// if_el 双分支:找到时(then) / 未找到时(else),各自独立容器接收拖入
if(step.type==='if_el'&&step.params){
[['then','✅ 找到时执行','if-then'],['else','❌ 未找到时执行','if-else']].forEach(function(b){
var branch=document.createElement('div');
branch.className='if-branch';
var title=document.createElement('div');
title.className='branch-title '+b[0];
title.textContent=b[1];
var wrap=document.createElement('div');
wrap.className='step-children '+b[2];
self._renderStepList(wrap,step.params[b[0]]||[]);
branch.appendChild(title);
branch.appendChild(wrap);
card.appendChild(branch);
});
}
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==='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+='';
}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+='按键 '+
[['back','返回键'],['home','Home键'],['enter','回车键'],['menu','菜单键'],['recent','最近任务'],['delete','删除键']].map(function(k){
return ''+k[1]+' ';
}).join('')+'
';
h+='返回键用于退出评论/返回上一页,比结束App轻量
';
}else if(step.type==='wait'){
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+='存为自定义动作 把本组步骤保存到左侧自定义动作库,方便复用
';
}else if(step.type==='if_el'){
h+=_selRowHtml(p,
'查找超时(秒)
'+
''+
'OCR 命中后自动点击该文字
'+
'超时内找到元素 → 执行下方"✅ 找到时"分支;未找到 → 执行"❌ 未找到时"分支。两分支可放任意步骤,也可再嵌套条件判断/循环;'+
'OCR识别 可匹配图片/画布里的文字(截屏识别,跨平台)
', true);
}
// 共享:触发概率(所有步骤通用,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');
// 子卡片都会 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 的子步骤、if_el 的分支步骤也找得到)
_findStepById:function(steps,id){
var self=this;
for(var i=0;i=0){
var copy=JSON.parse(JSON.stringify(s));
_stripIds([copy]);
result.push(copy); // 容器选中时子步骤已包含在拷贝里,不再单独收集
}else{
_childArrs(s).forEach(function(a){
result=result.concat(self._collectByIds(a,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';
});
},
// 条件判断:选择器类型切到 OCR 时显示"命中后点击"开关
_toggleOcrMode:function(sel){
var card=sel.closest('.step-card');
var f=card&&card.querySelector('[data-ocr-field]');
if(f)f.style.display=sel.value==='ocr'?'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){
var arrs=_childArrs(step);
if(!arrs.length)return false;
if(arrs.indexOf(arr)>=0)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+'": 候选文字为空');
}
}else if(s.type==='if_el'){
if(!s.params.selector_value){
warnings.push(name+' "'+label+'": 条件判断的选择器为空,会直接走"未找到"分支。');
}
}
_childArrs(s).forEach(function(a){
if(a.length)check(a,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;
}
}
});
// 递归子步骤:loop/group 的 children
var childWrap=card.querySelector(':scope > .step-children');
if(childWrap&&steps[i].params&&steps[i].params.children){
self._syncParams(childWrap,steps[i].params.children);
}
// if_el 双分支(then/else 在 .if-branch 内,不会与上面的直接子级选择器冲突)
var thenWrap=card.querySelector(':scope > .if-branch .step-children.if-then');
if(thenWrap&&steps[i].params&&Array.isArray(steps[i].params.then)){
self._syncParams(thenWrap,steps[i].params.then);
}
var elseWrap=card.querySelector(':scope > .if-branch .step-children.if-else');
if(elseWrap&&steps[i].params&&Array.isArray(steps[i].params.else)){
self._syncParams(elseWrap,steps[i].params.else);
}
});
},
// 把画布 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=>
' '+esc(s)+' '
).join('');
document.getElementById('modal-body').innerHTML=
'分组名
'+
'描述
'+
'';
document.getElementById('modal-footer').innerHTML=
'取消 '+
''+(isEdit?'保存':'创建')+' ';
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=>
' '+PERM_LABELS[p]+' '
).join('');
document.getElementById('modal-body').innerHTML=
'用户名
'+
'密码'+(isEdit?'(留空不修改)':'')+'
'+
'管理员权限(拥有全部权限)
'+
'';
document.getElementById('modal-footer').innerHTML=
'取消 '+
''+(isEdit?'保存':'创建')+' ';
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(force){
const sel=document.getElementById('adb-device');
if(!sel)return;
const list=await _fetchAdbDevices(force);
if(list===null)return;
const cur=sel.value;
const stateTxt={device:'在线',offline:'离线',stf:'STF池(未连接)'};
const opts=['(不指定设备) '].concat(
list.map(d=>
''+esc(d.serial)+' ('+(stateTxt[d.state]||d.state)+') '
)
);
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?'永不过期 ':'会过期 ')+' '+
''+
'改名 '+
'设置IP '+
''+(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=>
''+esc((d.serial||'').replace(/:5555$/,'')+' ('+(d.device_name||d.model||'未知设备')+')')+' '
).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 ' '+esc(label)+st+' ';
}).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');box.classList.remove('wide-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');