Files
auto_control/static/admin/monitor.js
T

2846 lines
139 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ================== 通用工具 ==================
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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=
'<div id="shot-content" class="shot-loading">正在获取画面...</div>'+
'<div class="shot-toolbar">'+
'<button class="btn btn-primary btn-sm" onclick="refreshShot()">刷新</button>'+
'<label class="auto-label"><input type="checkbox" id="shot-auto" checked onchange="toggleShotAuto()"> 自动刷新(3秒)</label>'+
'<span class="shot-info" id="shot-time"></span>'+
'</div>';
document.getElementById('modal-footer').innerHTML='<button class="btn" onclick="closeShotModal()">关闭</button>';
// 切换为截图弹窗样式(自适应大小)
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='<img class="shot-img" src="'+objUrl+'" alt="设备画面">';
// 更新时间
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();}
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='<div class="empty" style="padding:20px">无可用设备(本地 adb 或 STF 池均无)</div>';
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 '<label data-serial="'+esc(d.serial)+'">'
+'<input type="checkbox" value="'+esc(d.serial)+'"'+( _clipSelected.has(d.serial)?' checked':'')+' onchange="toggleClipDevice(this)">'
+'<span style="font-family:monospace;flex:1;overflow:hidden;text-overflow:ellipsis">'+esc(d.serial)+'</span>'
+'<span class="label '+stCls+'">'+st+'</span></label>';
}).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])=>
'<tr><td style="font-family:monospace">'+esc(s)+'</td><td>'+(v.ok?'<span class="label label-success">成功</span>':'<span class="label label-danger">失败</span> '+esc(v.msg))+'</td></tr>').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||'<tr><td colspan="20" class="empty">无数据</td></tr>');
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?'<div class="list-pager"><span class="lp-total">共 '+total+' 条</span></div>':'';
var h='<div class="list-pager"><span class="lp-total">共 '+total+' 条</span>';
h+='<button class="btn btn-xs" onclick="_lpGo(\''+key+'\','+(cur-1)+')"'+(cur<=1?' disabled':'')+'>‹ 上一页</button>';
for(var p=Math.max(1,cur-2);p<=Math.min(pages,cur+2);p++){
h+='<button class="btn btn-xs'+(p===cur?' btn-primary':'')+'" onclick="_lpGo(\''+key+'\','+p+')">'+p+'</button>';
}
h+='<button class="btn btn-xs" onclick="_lpGo(\''+key+'\','+(cur+1)+')"'+(cur>=pages?' disabled':'')+'>下一页 ›</button>';
h+='</div>';
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='<span style="color:#f59e0b">扫描中...</span>';
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='<span style="color:#94a3b8">未扫描</span>';
}
}
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=>
'<div class="stat-card" style="--accent:'+c.c+'"><div class="stat-num" style="color:'+c.c+'">'+c.n+'</div><div class="stat-lbl">'+c.l+'</div></div>'
).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'?'<span class="label label-danger">失败</span>':'<span class="label label-warning">异常</span>';
const up=e.updated?new Date(e.updated*1000).toLocaleTimeString():'';
return '<tr>'+
'<td><strong>'+esc(e.serial)+'</strong>'+(e.model?' <span style="font-size:11px;color:#94a3b8">'+esc(e.model)+'</span>':'')+'</td>'+
'<td>'+st+'</td>'+
'<td>'+esc(e.task||'-')+'</td>'+
'<td>'+(e.attempt>0?e.attempt+'次':'<span class="text-muted">-</span>')+'</td>'+
'<td class="err-text" title="'+esc(e.last_error||'')+'">'+esc(e.last_error||'-')+'</td>'+
'</tr>';
}).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:'<tr><td colspan="11" class="empty">暂无设备</td></tr>'
});
applyListPager('devices');
initListSearch('devices','search-devices');
}
function renderDeviceRow(dev){
const stf=dev.stf_occupied?'<span class="label label-success">占用</span>':'<span class="label label-default">空闲</span>';
const offline=dev.present?'':' <span class="label label-danger">离线</span>';
const st=STATUS_MAP[dev.worker_status]||{t:dev.worker_status,c:'default'};
const wb='<span class="label label-'+st.c+'">'+st.t+'</span>';
const fgApp=dev.foreground_app;
const fg=fgApp?(
fgApp==='(他人占用)'?'<span class="label label-warning">他人占用</span>':
fgApp==='(不可达)'?'<span class="label label-default">不可达</span>':
'<span class="label label-primary">'+esc(APP_NAMES[fgApp]||fgApp)+'</span>'
):'<span class="text-muted">-</span>';
const prog=fmtProgress(dev.progress, dev.worker_status);
const tname=dev.task_job?'<span class="label label-info">'+esc(dev.task_job)+'</span>':'<span class="text-muted">-</span>';
const retry=dev.attempt>0?' <span class="text-muted">'+dev.attempt+'次</span>':'';
// 预计结束时间(设置了运行时长上限时显示)
const endInfo=(dev.worker_status==='running'&&dev.end_time)?'<div style="font-size:11px;color:#94a3b8">预计结束 '+new Date(dev.end_time*1000).toLocaleTimeString()+'</div>':'';
// 选择器健康警告(某步骤连续未找到元素,可能 App 改版选择器失效)
const warnInfo=dev.last_warning?'<div style="font-size:11px;color:#f59e0b" title="'+esc(dev.last_warning)+'">⚠ '+esc(dev.last_warning)+'</div>':'';
// 设备操作按钮需要"设备控制"权限(无权限只保留截图查看)
const canDev=_can('devices');
const stopBtn=canDev&&(dev.worker_status==='running'||dev.worker_status==='connecting')?'<button class="btn btn-danger btn-xs" onclick="apiAction(\'/api/stop_device\',{serial:\''+esc(dev.serial)+'\'})">停止</button>':'';
// 截图按钮(任何状态都可看,离线时会报错)
const shotBtn='<button class="btn btn-xs" onclick="openScreenshot(\''+esc(dev.serial)+'\')" title="查看设备画面(不影响任务)">截图</button>';
// 异常/失败设备提供"清除异常"(把状态恢复为未启动)
const clearBtn=canDev&&(dev.worker_status==='error'||dev.worker_status==='failed')
?'<button class="btn btn-xs" onclick="clearDeviceError(\''+esc(dev.serial)+'\')" title="清除该设备的异常状态,恢复为未启动">清除异常</button>':'';
const btns='<div style="display:flex;gap:4px;flex-wrap:wrap">'+shotBtn+clearBtn+stopBtn+'</div>';
const devName=dev.device_name||dev.model||'';
const devLabel=devName?'<strong>'+esc(dev.serial)+'</strong><br><span style="font-size:11px;color:#94a3b8">'+esc(devName)+'</span>':'<strong>'+esc(dev.serial)+'</strong>';
// 复选框列
const checked=_selectedSerials.has(dev.serial)?'checked':'';
const rowCls=_selectedSerials.has(dev.serial)?'row-checked':'';
const cb='<input type="checkbox" '+checked+' onclick="toggleDevice(\''+esc(dev.serial)+'\',this.checked)">';
return '<tr class="'+rowCls+'"><td class="col-cb">'+cb+'</td><td>'+devLabel+'</td><td>'+esc(dev.model)+'</td><td>'+stf+offline+'</td><td>'+wb+'</td><td>'+fg+'</td><td>'+prog+'</td><td>'+tname+retry+endInfo+'</td><td class="text-muted">'+esc(dev.current_action||'-')+warnInfo+'</td><td class="err-text" title="'+esc(dev.last_error||'')+'">'+esc(dev.last_error||'-')+'</td><td>'+btns+'</td></tr>';
}
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 '<span class="text-muted">-</span>';
}
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='<div class="progress"><div class="progress-bar" style="width:'+pct+'%"></div></div> ';
}
// 计数文本:"5/80 视频" 或 "5 视频"(无 total 时)
let countHtml='';
if(total>0){
countHtml='<span style="font-size:12px;color:#64748b">'+done+'/'+total+(unit?' '+esc(unit):'')+'</span>';
}else if(done>0){
countHtml='<span style="font-size:12px;color:#64748b">'+done+(unit?' '+esc(unit):'')+'</span>';
}
// 运行时长(elapsed>0 才显示)
let timeHtml='';
if(elapsed>0){
const m=Math.floor(elapsed/60), s=elapsed%60;
timeHtml=' <span style="font-size:11px;color:#94a3b8">'+m+'m'+s+'s</span>';
}
// 操作计数徽章(按数量降序,最多显示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='<div style="margin-top:2px">'+shown.map(([k,v])=>
'<span class="label label-info" style="margin-left:4px;font-size:11px">'+(ACTION_LABELS[k]||esc(k))+' '+v+'</span>'
).join('')+(more>0?' <span style="font-size:11px;color:#94a3b8">+'+more+'</span>':'')+'</div>';
}
// 空闲/完成但无进度数据 → 灰显
if(done===0&&total===0&&elapsed===0&&!countParts.length){
return '<span class="text-muted">-</span>';
}
return '<div>'+barHtml+countHtml+timeHtml+'</div>'+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('<span class="kv">操作:'+on.join('/')+'</span>');
}
continue;
}
const val=typeof v==='boolean'?(v?'开':'关'):(Array.isArray(v)?v.length+'条':v);
parts.push('<span class="kv">'+k+'='+val+'</span>');
}
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?'<span class="label label-success">启用</span>':'<span class="label label-default">停用</span>';
const runBadge=isRunning?'<span class="label label-success pulse">运行中 '+runCount+'台</span>':'<span class="label label-default">空闲</span>';
return '<div class="job-card'+(isRunning?' running':'')+'">'+
'<div class="job-head"><div><div class="job-name">'+esc(j.name)+'</div>'+
'<div class="job-type">'+esc(j.task_type)+'</div></div>'+
'<div>'+en+' '+runBadge+'</div></div>'+
'<div class="job-body">'+
'<div><span class="kv">目标:'+esc(tgt)+(tgtDetail?'('+esc(tgtDetail)+')':'')+'</span>'+
'<span class="kv">调度:'+esc(sched)+'</span>'+
'<span class="kv">重试:'+esc(retry)+'</span></div>'+
'<div style="margin-top:4px">'+fmtParams(j.params)+'</div>'+
'</div>'+
'<div class="job-actions">'+
'<button class="btn btn-primary btn-xs" onclick="apiAction(\'/api/jobs/'+j.id+'/run\')">立即执行</button>'+
'<button class="btn btn-xs" onclick="apiAction(\'/api/jobs/'+j.id+'/toggle\',{enabled:'+(!j.enabled)+'})">'+(j.enabled?'停用':'启用')+'</button>'+
'<button class="btn btn-xs" onclick="openTaskModal(\''+j.id+'\')">编辑</button>'+
'<button class="btn btn-danger btn-xs" onclick="deleteTask(\''+j.id+'\')">删除</button>'+
'</div>'+
'</div>';
}).join(''):'<div class="empty">暂无任务,到"任务"Tab 新建</div>';
}
// ================== 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:'<tr><td colspan="9" class="empty">暂无任务,点击"新建任务"创建</td></tr>'
});
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='<td>'+schedTxt+'</td>';
// 下次运行时间(后端已按运行窗口跳过窗口外触发点)
const nextRun=fmtNextRun(j.next_run);
const nextOut='<td>'+(nextRun||'<span class="text-muted">-</span>')+'</td>';
const retry=j.retry.max_attempts>1?(j.retry.max_attempts+'次/'+j.retry.delay+'s'):'不重试';
const en=j.enabled?'<span class="label label-success">启用</span>':'<span class="label label-default">停用</span>';
return '<tr>'+
'<td><code>'+esc(j.id)+'</code></td>'+
'<td><strong>'+esc(j.name)+'</strong></td>'+
'<td>'+esc(j.task_type)+'</td>'+
'<td>'+en+'</td>'+
'<td>'+esc(tgt)+(tgtDetail?'('+esc(tgtDetail)+')':'')+'</td>'+
schedOut+
nextOut+
'<td>'+retry+'</td>'+
'<td>'+
(_can('tasks')?(
'<button class="btn btn-primary btn-xs" onclick="apiAction(\'/api/jobs/'+j.id+'/run\')">执行</button>'+
'<button class="btn btn-xs" onclick="apiAction(\'/api/jobs/'+j.id+'/toggle\',{enabled:'+(!j.enabled)+'})">'+(j.enabled?'停用':'启用')+'</button>'+
'<button class="btn btn-xs" onclick="openTaskModal(\''+j.id+'\')">编辑</button>'+
'<button class="btn btn-danger btn-xs" onclick="deleteTask(\''+j.id+'\')">删除</button>'
):'<span class="text-muted">只读</span>')+
'</td>'+
'</tr>';
}
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=>'<option value="'+t.task_type+'"'+(t.task_type===j.task_type?' selected':'')+'>'+esc(t.name)+'</option>').join('');
const gl=_groupsList.map(g=>'<option value="'+g.name+'"'+(g.name===j.target.group_name?' selected':'')+'>'+esc(g.name)+'</option>').join('');
const dl=_devicesList.map(s=>'<option value="'+s+'"'+(s===j.target.serial?' selected':'')+'>'+esc(s)+'</option>').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=>
'<option value="'+w+'"'+(sc.weekday===w?' selected':'')+'>'+WEEKDAY_NAMES[w]+'</option>').join('');
document.getElementById('modal-body').innerHTML=
'<div class="form-row">'+
'<div class="form-group"><label>任务名</label><input type="text" class="form-control" id="f-name" value="'+esc(j.name)+'" placeholder="如:抖音养号-上午"></div>'+
'<div class="form-group"><label>任务类型</label><select class="form-control" id="f-task_type" onchange="onTaskTypeChange()">'+tt+'</select></div>'+
'</div>'+
'<div class="form-group form-check"><input type="checkbox" id="f-enabled" '+(j.enabled?'checked':'')+'><label for="f-enabled">启用此任务</label></div>'+
'<div class="form-group"><label>目标设备</label>'+
'<select class="form-control" id="f-target_mode" onchange="toggleTargetFields()">'+
'<option value="all"'+(j.target.mode==='all'?' selected':'')+'>全部空闲设备</option>'+
'<option value="group"'+(j.target.mode==='group'?' selected':'')+'>按分组</option>'+
'<option value="serial"'+(j.target.mode==='serial'?' selected':'')+'>指定设备</option>'+
'</select>'+
'<div id="f-target_group_wrap" class="form-group" style="margin-top:8px;margin-bottom:0">'+
'<select class="form-control" id="f-target_group"><option value="">选择分组...</option>'+gl+'</select>'+
'</div>'+
'<div id="f-target_serial_wrap" class="form-group" style="margin-top:8px;margin-bottom:0">'+
'<select class="form-control" id="f-target_serial"><option value="">选择设备...</option>'+dl+'</select>'+
'</div>'+
'</div>'+
'<div class="form-row">'+
'<div class="form-group"><label>调度方式</label>'+
'<select class="form-control" id="f-schedule_mode" onchange="toggleScheduleFields()">'+
'<option value="once"'+(sm==='once'?' selected':'')+'>手动(不自动启动)</option>'+
'<option value="cron"'+(sm==='cron'?' selected':'')+'>定时启动</option>'+
'<option value="cron_stop"'+(sm==='cron_stop'?' selected':'')+'>定时启动+停止</option>'+
'</select>'+
'</div>'+
'<div class="form-group" id="f-freq_wrap" style="display:none"><label>频率</label>'+
'<select class="form-control" id="f-schedule_freq" onchange="toggleScheduleFields()">'+
'<option value="daily"'+((sc.freq==='daily')?' selected':'')+'>每天</option>'+
'<option value="hourly"'+((sc.freq==='hourly')?' selected':'')+'>每小时(整点)</option>'+
'<option value="everyN"'+((sc.freq==='everyN')?' selected':'')+'>每隔 N 小时</option>'+
'<option value="weekly"'+((sc.freq==='weekly')?' selected':'')+'>每周</option>'+
'<option value="custom"'+((sc.freq==='custom')?' selected':'')+'>自定义 cron(高级)</option>'+
'</select>'+
'</div>'+
'<div class="form-group" id="f-time_wrap" style="display:none"><label>启动时间</label><input type="time" class="form-control" id="f-schedule_time" value="'+esc(sc.time||'09:00')+'"></div>'+
'<div class="form-group" id="f-interval_wrap" style="display:none"><label>间隔(小时)</label><input type="number" class="form-control" id="f-schedule_interval" value="'+(sc.interval||2)+'" min="1" max="12"><div class="help">从 0 点起算,如 2 = 0/2/4/6...点启动</div></div>'+
'<div class="form-group" id="f-weekday_wrap" style="display:none"><label>星期</label><select class="form-control" id="f-schedule_weekday">'+weekdayOpts+'</select></div>'+
'<div class="form-group" id="f-cron_wrap" style="display:none"><label>启动 Cron 表达式</label><input type="text" class="form-control" id="f-schedule_cron" value="'+esc(startCron)+'" placeholder="如 0 9 * * * = 每天9点启动" style="font-family:monospace"><div class="help">格式:分 时 日 月 周(0 0 * * 0 = 每周日零点)</div></div>'+
'<div class="form-group" id="f-stop_wrap" style="display:none"><label>停止时间(每天)</label><input type="time" class="form-control" id="f-schedule_stop_time" value="'+esc(stopF||'18:00')+'"><div class="help">到点自动停止所有运行中的 worker</div></div>'+
'</div>'+
'<div class="form-group form-check" style="margin-top:4px"><input type="checkbox" id="f-window_enable" '+(win?'checked':'')+' onchange="toggleWindowFields()"><label for="f-window_enable">启用运行窗口(只在指定时间段内运行)</label></div>'+
'<div class="form-row" id="f-window_wrap" style="display:'+(win?'flex':'none')+'">'+
'<div class="form-group"><label>允许运行·开始</label><input type="time" class="form-control" id="f-window_start" value="'+esc(winStart)+'"></div>'+
'<div class="form-group"><label>允许运行·结束</label><input type="time" class="form-control" id="f-window_end" value="'+esc(winEnd)+'"></div>'+
'</div>'+
'<div class="help" id="f-window_help" style="display:'+(win?'block':'none')+'">窗口外任务不会启动(定时触发和手动执行都受约束)。跨午夜示例:21:00-09:00 = 晚 9 点到次日早 9 点允许运行</div>'+
'<div class="form-row">'+
'<div class="form-group"><label>重试次数</label><input type="number" class="form-control" id="f-retry_attempts" value="'+j.retry.max_attempts+'" min="1" max="10"></div>'+
'<div class="form-group"><label>重试间隔(秒)</label><input type="number" class="form-control" id="f-retry_delay" value="'+j.retry.delay+'" min="10" max="3600"></div>'+
'</div>'+
'<div class="form-group" id="f-params_wrap"><label>任务参数 (JSON)</label><textarea class="form-control" id="f-params" rows="6">'+esc(JSON.stringify(j.params||{},null,2))+'</textarea><div class="help">可留空使用默认参数。抖音任务可设 max_duration(秒,如 1800=30分钟)限制单设备运行时长</div></div>'+
'<div class="step-editor" id="step-editor">'+
'<div class="se-head"><span>步骤编辑器</span><span style="font-size:11px;color:var(--text-light);flex:1;margin-left:12px">用步骤卡片上的 ☑ 选中多个步骤,可打包成自定义动作</span>'+
(_can('tasks')?'<button class="btn btn-xs btn-primary" id="se-pack-btn" disabled onclick="_stepEditor.saveAsCustomAction()">打包选中步骤</button>':'')+
'</div>'+
'<div class="se-body">'+
'<div class="se-lib" id="se-lib"><div class="lib-title">操作库</div></div>'+
'<div class="se-canvas" id="se-canvas"></div>'+
'</div>'+
'</div>';
// 初始化步骤编辑器(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=
'<button class="btn" onclick="closeModal()">取消</button>'+
'<button class="btn btn-primary" onclick="saveTask('+(isEdit?'\''+jobId+'\'':'null')+')">'+(isEdit?'保存':'创建')+'</button>';
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 '<option value="'+d+'"'+(p.selector_type===d?' selected':'')+'>'+labels[d]+'</option>';}).join('');
var toggle=withOcr?' onchange="_stepEditor._toggleOcrMode(this)"':'';
var pick=_can('devices')?'<button class="btn btn-xs btn-pick" onclick="_stepEditor.openPicker(this)">抓取元素</button>':'';
var test=_can('devices')?'<button class="btn btn-xs" onclick="_stepEditor._testStep(this)" title="在设备上试执行本步骤,验证选择器是否命中">测试</button>':'';
return '<div class="form-row"><div class="form-group"><label>选择器类型</label><select class="form-control" data-key="selector_type"'+toggle+'>'+opts+'</select></div>'+
'<div class="form-group" style="grid-column:span 2"><label>选择器值</label><div class="sel-picker">'+
'<input type="text" class="form-control" data-key="selector_value" value="'+esc(p.selector_value||'')+'" placeholder="如 点赞">'+pick+test+
'</div></div></div>'+(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=不限) <input type="number" id="f-max_duration" value="'+(maxDuration||0)+'" min="0" style="width:70px;padding:2px 6px;border:1px solid var(--border);border-radius:4px;font-size:12px">';
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 '<div class="lib-item" draggable="true" data-type="'+s.type+'" title="'+esc(s.label)+'">'+
'<span class="ico">'+s.icon+'</span><span>'+s.label+'</span></div>';
}).join('');
return '<div class="lib-section"><div class="lib-title">'+c[1]+'</div>'+list+'</div>';
}).join('');
// 自定义动作区
var customHtml='';
if(_customActions.length){
customHtml='<div class="lib-section"><div class="lib-title">自定义动作</div>';
customHtml+=_customActions.map(function(a){
return '<div class="lib-item custom" draggable="true" data-action-id="'+a.id+'">'+
'<span class="ico">'+(a.icon||'📦')+'</span><span>'+esc(a.name)+'</span>'+
'<button class="del-btn" title="删除" onclick="event.stopPropagation();_stepEditor.delCustomAction(\''+a.id+'\')">✕</button></div>';
}).join('');
customHtml+='</div>';
}
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='<div class="se-empty">拖拽左侧操作到这里<div class="hint">或点击操作库直接添加</div></div>';
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='<span class="sc-ico">'+lib.icon+'</span>'+
'<span class="sc-label">'+esc(step.label||lib.label)+'</span>'+
'<span class="sc-type">'+step.type+'</span>'+
'<span class="sc-actions">'+
'<button title="选中/取消选中(可多选后打包)" onclick="event.stopPropagation();_stepEditor.toggleSelect(\''+(step.id||'')+'\')">☑</button>'+
'<button title="展开/收起参数" onclick="_stepEditor.toggleExpand(this)">⚙</button>'+
'<button title="上移" onclick="_stepEditor.move('+idx+',-1,\''+this._arrId(parentArr)+'\')">↑</button>'+
'<button title="下移" onclick="_stepEditor.move('+idx+',1,\''+this._arrId(parentArr)+'\')">↓</button>'+
'<button title="复制" onclick="_stepEditor.duplicate('+idx+',\''+this._arrId(parentArr)+'\')">⧉</button>'+
'<button title="删除" onclick="_stepEditor.remove('+idx+',\''+this._arrId(parentArr)+'\')">✕</button>'+
'</span>';
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:<idx>'
return arr===this._steps?'root':'child';
},
_renderParams:function(step){
var p=step.params||{};
var h='';
if(step.type==='open_app'){
h+='<div class="form-group"><label>包名</label><input type="text" class="form-control" data-key="package" value="'+esc(p.package||'')+'" placeholder="如 com.ss.android.ugc.aweme"></div>';
h+='<div class="form-row"><div class="form-group form-check"><input type="checkbox" data-key="wait_home" '+(p.wait_home?'checked':'')+'><label>等待进入首页</label></div>';
h+='<div class="form-group"><label>首页特征词</label><input type="text" class="form-control" data-key="home_feature" value="'+esc(p.home_feature||'')+'" placeholder="如 拍摄"></div></div>';
}else if(step.type==='stop_app'){
h+='<div class="form-group"><label>包名</label><input type="text" class="form-control" data-key="package" value="'+esc(p.package||'')+'" placeholder="如 com.ss.android.ugc.aweme"></div>';
h+='<div class="help">强制结束该 App(am force-stop),清除后台进程,下次打开为冷启动。常配合"打开App"使用:结束→重新打开</div>';
}else if(step.type==='screen_on'){
h+='<div class="help">亮屏(息屏时唤醒并滑动解锁)。任务执行前用——息屏状态下 u2 无法点击操作</div>';
}else if(step.type==='screen_off'){
h+='<div class="help">息屏。任务结束后用,避免长时间亮屏导致烧屏/发热</div>';
}else if(step.type==='keep_screen'){
h+='<div class="form-group"><label>模式</label><select class="form-control" data-key="mode">'+
'<option value="on"'+(p.mode!=='off'?' selected':'')+'>保持亮屏(充电时屏幕常亮)</option>'+
'<option value="off"'+(p.mode==='off'?' selected':'')+'>恢复自动息屏</option></select></div>';
h+='<div class="help">长任务(如养号看视频几小时)放"保持亮屏"防屏幕超时熄灭;任务结束前放"恢复自动息屏"</div>';
}else if(step.type==='swipe'){
h+='<div class="form-row"><div class="form-group"><label>方向</label><select class="form-control" data-key="direction">'+
['up','down','left','right'].map(function(d){return '<option value="'+d+'"'+(p.direction===d?' selected':'')+'>'+d+'</option>';}).join('')+'</select></div>';
h+='<div class="form-group"><label>最短时长(秒)</label><input type="number" class="form-control" data-key="duration_min" value="'+(p.duration_min||0.25)+'" step="0.05" min="0.05"></div>';
h+='<div class="form-group"><label>最长时长(秒)</label><input type="number" class="form-control" data-key="duration_max" value="'+(p.duration_max||0.50)+'" step="0.05" min="0.05"></div></div>';
}else if(step.type==='click'){
h+=_selRowHtml(p,'<div class="form-group"><label>等待超时(秒)</label><input type="number" class="form-control" data-key="wait_timeout" value="'+(p.wait_timeout||2)+'" step="0.5" min="0.5"></div>');
}else if(step.type==='long_click'){
h+=_selRowHtml(p,'<div class="form-row"><div class="form-group"><label>长按时长(秒)</label><input type="number" class="form-control" data-key="duration" value="'+(p.duration||1.0)+'" step="0.5" min="0.3"></div>'+
'<div class="form-group"><label>等待超时(秒)</label><input type="number" class="form-control" data-key="wait_timeout" value="'+(p.wait_timeout||2)+'" step="0.5" min="0.5"></div></div>');
}else if(step.type==='swipe_until'){
h+=_selRowHtml(p,'<div class="form-row"><div class="form-group"><label>方向</label><select class="form-control" data-key="direction">'+
'<option value="up"'+(p.direction!=='down'?' selected':'')+'>上滑</option>'+
'<option value="down"'+(p.direction==='down'?' selected':'')+'>下滑</option></select></div>'+
'<div class="form-group"><label>最多滑动次数</label><input type="number" class="form-control" data-key="max_swipes" value="'+(p.max_swipes||8)+'" min="1" max="50"></div></div>'+
'<div class="form-group form-check"><input type="checkbox" data-key="click_when_found" '+(p.click_when_found!==false?'checked':'')+'><label>找到后点击</label></div>'+
'<div class="help">上滑/下滑直到目标元素出现(每次滑动后检查一次),找不到则跳过继续</div>');
}else if(step.type==='wait_el'){
h+=_selRowHtml(p,'<div class="form-group"><label>最长等待(秒)</label><input type="number" class="form-control" data-key="timeout" value="'+(p.timeout||10)+'" min="1" max="120"></div>'+
'<div class="help">等待目标元素出现(替代固定秒数等待),超时则继续</div>');
}else if(step.type==='click_xy'){
h+='<div class="form-row"><div class="form-group"><label>X(屏幕% )</label><input type="number" class="form-control" data-key="x" value="'+(p.x||50)+'" min="0" max="100"></div>'+
'<div class="form-group"><label>Y(屏幕% )</label><input type="number" class="form-control" data-key="y" value="'+(p.y||50)+'" min="0" max="100"></div></div>'+
'<div class="help">屏幕百分比坐标,50/50=屏幕中心。无选择器时的兜底点击</div>';
}else if(step.type==='key_event'){
h+='<div class="form-group"><label>按键</label><select class="form-control" data-key="key">'+
[['back','返回键'],['home','Home键'],['enter','回车键'],['menu','菜单键'],['recent','最近任务'],['delete','删除键']].map(function(k){
return '<option value="'+k[0]+'"'+(p.key===k[0]?' selected':'')+'>'+k[1]+'</option>';
}).join('')+'</select></div>';
h+='<div class="help">返回键用于退出评论/返回上一页,比结束App轻量</div>';
}else if(step.type==='wait'){
h+='<div class="form-row"><div class="form-group"><label>最短(秒)</label><input type="number" class="form-control" data-key="min" value="'+(p.min||1.0)+'" step="0.5" min="0.1"></div>';
h+='<div class="form-group"><label>最长(秒)</label><input type="number" class="form-control" data-key="max" value="'+(p.max||3.0)+'" step="0.5" min="0.1"></div></div>';
}else if(step.type==='input_text'){
const im=p.mode==='fixed'?'fixed':'random';
h+='<div class="form-group"><label>输入模式</label><select class="form-control" data-key="mode" onchange="_stepEditor._toggleInputMode(this)">'+
'<option value="random"'+(im==='random'?' selected':'')+'>随机候选</option>'+
'<option value="fixed"'+(im==='fixed'?' selected':'')+'>指定文字</option></select></div>';
h+='<div class="form-group" data-mode="random" style="display:'+(im==='random'?'block':'none')+'"><label>候选文字(每行一个,随机选一条)</label><textarea class="form-control" data-key="texts" rows="4" style="font-family:monospace;min-height:80px">'+esc(p.texts||'你好\n有趣\n支持')+'</textarea></div>';
h+='<div class="form-group" data-mode="fixed" style="display:'+(im==='fixed'?'block':'none')+'"><label>指定文字</label><input type="text" class="form-control" data-key="fixed_text" value="'+esc(p.fixed_text||'')+'"></div>';
h+='<div class="form-group form-check"><input type="checkbox" data-key="clear_first" '+(p.clear_first!==false?'checked':'')+'><label>输入前先清空</label></div>';
h+='<div class="help">需先用"点击元素"步骤定位到输入框,本步骤只负责输入文字</div>';
}else if(step.type==='loop'){
const lm=p.loop_mode==='time'?'time':'rounds';
h+='<div class="form-row"><div class="form-group"><label>循环方式</label><select class="form-control" data-key="loop_mode" onchange="_stepEditor._toggleLoopMode(this)">'+
'<option value="rounds"'+(lm==='rounds'?' selected':'')+'>按轮次</option>'+
'<option value="time"'+(lm==='time'?' selected':'')+'>按时间</option></select></div>'+
'<div class="form-group" data-lmode="rounds" style="display:'+(lm==='rounds'?'block':'none')+'"><label>最大循环次数</label><input type="number" class="form-control" data-key="max_iterations" value="'+(p.max_iterations||10)+'" min="1" max="100000"></div>'+
'<div class="form-group" data-lmode="time" style="display:'+(lm==='time'?'block':'none')+'"><label>循环时长(秒)</label><input type="number" class="form-control" data-key="loop_duration" value="'+(p.loop_duration||600)+'" min="1"></div></div>';
h+='<div class="help">将下方子步骤(拖拽到本卡片内)循环执行指定轮次或时长;按时间时配合"运行时长上限"看预计结束时间</div>';
}else if(step.type==='group'){
h+='<div class="help">动作组:按顺序执行下方子步骤一次。把多个步骤打包成一个可复用的整体。</div>';
h+='<div class="form-group"><button class="btn btn-xs btn-primary" onclick="_stepEditor.saveAsCustomAction(\''+esc(step.id||'')+'\')">存为自定义动作</button> <span class="help" style="display:inline">把本组步骤保存到左侧自定义动作库,方便复用</span></div>';
}else if(step.type==='if_el'){
h+=_selRowHtml(p,
'<div class="form-group"><label>查找超时(秒)</label><input type="number" class="form-control" data-key="timeout" value="'+(p.timeout||3)+'" min="0.5" max="30" step="0.5"></div>'+
'<div class="form-group form-check" data-ocr-field style="display:'+(p.selector_type==='ocr'?'block':'none')+'">'+
'<input type="checkbox" data-key="ocr_click" '+(p.ocr_click?'checked':'')+'><label>OCR 命中后自动点击该文字</label></div>'+
'<div class="help">超时内找到元素 → 执行下方"✅ 找到时"分支;未找到 → 执行"❌ 未找到时"分支。两分支可放任意步骤,也可再嵌套条件判断/循环;'+
'<b>OCR识别</b>可匹配图片/画布里的文字(截屏识别,跨平台)</div>', true);
}
// 共享:触发概率(所有步骤通用,0~100)
h+='<div class="form-row" style="margin-top:8px"><div class="form-group"><label>触发概率(%)</label><input type="number" class="form-control" data-key="probability" value="'+(p.probability!=null?p.probability:100)+'" min="0" max="100"><div class="help">0~100,&lt;100 表示按概率执行(如 30=隔几次才触发一次,适合"偶尔点赞")</div></div></div>';
// 可编辑 label
h+='<div class="form-group"><label>步骤名称(可选)</label><input type="text" class="form-control" data-key="_label" value="'+esc(step.label||'')+'" placeholder="留空用默认"></div>';
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<idx)arr.splice(idx-1,0,moved);
else arr.splice(idx,0,moved);
rerender();
}else{
// 跨层级移入;禁止移进自己或自己的子孙(否则渲染会无限递归)
var moved2=srcArr[fromIdx];
if(moved2&&!this._containsArr(moved2,arr)){
this._syncFromDom();
srcArr.splice(fromIdx,1);
if(idx<0||idx>=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=
'<div class="el-device-grid"><div class="egd-title">选择在哪个设备上试执行</div><div id="test-device-list">加载中...</div></div>';
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='<div class="se-empty">'+esc((r&&r.error)||'获取设备失败')+'</div>';return;}
list.innerHTML=(r.devices||[]).map(function(d){
return '<div class="el-device-card" onclick="_stepEditor._runTestStep(\''+esc(d.serial)+'\')">'+
'<div class="edc-icon">📱</div>'+
'<div class="edc-info"><div class="edc-serial">'+esc(d.serial)+'</div>'+
'<div class="edc-model">'+esc(d.model||d.product||'')+'</div></div></div>';
}).join('')||'<div class="se-empty">无可用设备</div>';
});
},
_runTestStep:async function(serial){
var step=this._pendingTest;
var body=document.getElementById('test-modal-body');
if(!step||!body)return;
body.innerHTML='<div class="se-empty">正在 '+esc(serial)+' 上执行(adb + u2 连接约需几秒)...<div class="hint">请稍候,期间不要操作该设备</div></div>';
var r=await apiPost('/api/steps/test',{serial:serial,step:step});
if(!r)return;
var icon=r.result==='命中'?'✅':(r.result==='未找到'?'❌':'▶');
body.innerHTML='<div class="se-empty" style="padding:20px">'+icon+' <strong>'+esc(r.result||'已执行')+'</strong>'+
'<div class="hint" style="margin-top:8px">'+esc(r.msg||'')+'</div>'+
'<button class="btn btn-primary" style="margin-top:14px" onclick="closeTestModal()">完成</button></div>';
},
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<steps.length;i++){
var s=steps[i];
if(s.id===id)return s;
var f=null;
_childArrs(s).forEach(function(a){ if(!f)f=self._findStepById(a,id); });
if(f)return f;
}
return null;
},
_collectByIds:function(steps,ids){
// 收集选中的步骤(深拷贝,去掉 id)。
// 递归搜索嵌套子步骤:☑ 也能选中 group/loop/if_el 内部的步骤,只搜顶层会漏
var result=[];
var self=this;
steps.forEach(function(s){
if(ids.indexOf(s.id)>=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<arrs.length;i++){
for(var j=0;j<arrs[i].length;j++){
if(this._containsArr(arrs[i][j],arr))return true;
}
}
return false;
},
move:function(idx,dir,path){
this._syncFromDom(); // 先同步 DOM 编辑,避免重渲染丢内容
var arr=this._resolveArr(path);
if(!arr)return;
var ni=idx+dir;
if(ni<0||ni>=arr.length)return;
var tmp=arr[ni];arr[ni]=arr[idx];arr[idx]=tmp;
this._renderCanvas();
},
duplicate:function(idx,path){
this._syncFromDom();
var arr=this._resolveArr(path);
if(!arr)return;
var copy=JSON.parse(JSON.stringify(arr[idx]));
copy.id=_newStepId();
this._ensureIds([copy]);
arr.splice(idx+1,0,copy);
this._renderCanvas();
},
remove:function(idx,path){
this._syncFromDom();
var arr=this._resolveArr(path);
if(!arr)return;
arr.splice(idx,1);
this._renderCanvas();
},
collect:function(){
// 先把当前 DOM 上的参数值同步回 _steps
var self=this;
this._syncParams(this._canvas,this._steps);
return JSON.parse(JSON.stringify(this._steps));
},
validate:function(steps){
// 校验步骤配置,返回警告列表
var warnings=[];
function check(arr,prefix){
arr.forEach(function(s,i){
var name=prefix?(prefix+'.'+(i+1)):('步骤'+(i+1));
var label=s.label||s.type;
if(s.type==='click'){
if(!s.params.selector_value){
warnings.push(name+' "'+label+'": 选择器值为空,点击步骤会跳过。请用"抓取元素"按钮选择目标。');
}
}else if(s.type==='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='<div class="el-device-grid"><div class="egd-title">选择要抓取元素的设备</div><div id="el-device-list">加载中...</div></div>';
document.getElementById('el-picker-footer').innerHTML='<button class="btn" onclick="closeElPicker()">取消</button>';
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='<div class="se-empty">'+esc(res.error||'获取设备失败')+'<br><button class="btn btn-sm" style="margin-top:10px" onclick="_stepEditor._showDevicePicker()">重试</button></div>';return;}
var devices=res.devices||[];
if(!devices.length){list.innerHTML='<div class="se-empty">uiauto2 未发现已连接设备</div>';return;}
list.innerHTML=devices.map(function(d){
return '<div class="el-device-card" onclick="_stepEditor._showElPicker(\''+esc(d.serial)+'\')">'+
'<div class="edc-icon">📱</div>'+
'<div class="edc-info"><div class="edc-serial">'+esc(d.serial)+'</div>'+
'<div class="edc-model">'+esc(d.model||d.product||'')+'</div></div>'+
'<span class="edc-status">'+esc(d.status||'device')+'</span></div>';
}).join('');
}).catch(function(e){
document.getElementById('el-device-list').innerHTML='<div class="se-empty">请求失败: '+esc(e)+'<br><button class="btn btn-sm" style="margin-top:10px" onclick="_stepEditor._showDevicePicker()">重试</button></div>';
});
},
// 步骤2:选中设备后,显示截图 + 元素树
_showElPicker:function(serial){
var self=this;
self._elSerial=serial;
document.getElementById('el-picker-title').textContent='抓取元素 - '+serial;
document.getElementById('el-picker-body').innerHTML=
'<div class="el-picker-main">'+
'<div class="el-shot-wrap">'+
'<div class="el-shot-toolbar">'+
'<button class="btn" onclick="_stepEditor._refreshShot()" title="重新拉取截图和元素树(界面变化后刷新用)">刷新截图+元素</button>'+
'<span class="el-shot-info" id="el-shot-info">加载中...</span>'+
'</div>'+
'<div class="el-shot-container" id="el-shot-container">'+
'<div class="el-shot-loading">正在获取截图...</div>'+
'</div>'+
'</div>'+
'<div class="el-list-wrap">'+
'<input type="text" class="el-picker-search" id="el-search" placeholder="搜索 resource-id/text/description..." oninput="_stepEditor._filterEl(this.value)">'+
'<div class="el-picker-list" id="el-list"><div class="se-empty">加载元素树...</div></div>'+
'</div>'+
'</div>';
document.getElementById('el-picker-footer').innerHTML=
'<button class="btn" onclick="_stepEditor._showDevicePicker()">← 返回选设备</button>'+
'<button class="btn" onclick="closeElPicker()">取消</button>';
// 加载截图和元素树(_refreshShot 内部同时刷新两者)
self._refreshShot();
},
// 拉取元素树(进入页面和刷新时共用;带时间戳防缓存)
_loadElData:function(){
var serial=this._elSerial;
if(!serial)return;
var listEl=document.getElementById('el-list');
if(listEl)listEl.innerHTML='<div class="se-empty">加载元素树...</div>';
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='<div class="se-empty">'+esc(data.error||'抓取失败')+'</div>';
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='<div class="se-empty">请求失败: '+esc(e)+'</div>';
});
},
// 刷新截图
_refreshShot:function(){
var serial=this._elSerial;
if(!serial)return;
var container=document.getElementById('el-shot-container');
if(!container)return;
container.innerHTML='<div class="el-shot-loading">正在获取截图...</div>';
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='<div class="el-shot-loading">截图加载失败</div>';
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<bestArea){bestArea=area;best=i;}
}
});
if(best>=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('<span>id:'+esc(el.resource_id)+'</span>');
if(el.text)attrs.push('<span>text:'+esc(el.text)+'</span>');
if(el.description)attrs.push('<span>desc:'+esc(el.description)+'</span>');
var indent=' '.repeat(Math.min(el.depth||0,6));
var sg=el.suggested||{};
return '<div class="el-picker-item" data-idx="'+idx+'" title="点击填入: '+esc(sg.value||'')+'" onclick="_stepEditor._pickEl('+idx+')">'+
'<span class="ep-depth">'+(el.depth||0)+'</span>'+
'<div class="ep-info"><div class="ep-name">'+indent+esc(el.name||el.class||'')+'</div>'+
'<div class="ep-attrs">'+attrs.join('')+'</div></div>'+
'<span class="ep-sel">'+esc(sg.type)+'</span>'+
(sg.indexed?' <span class="label label-info" title="界面中同属性元素共 '+sg.total+' 个,按从上到下这是第 '+sg.occ+' 个,选择器已带序号精确定位">#'+sg.occ+'/'+sg.total+'</span>':'')+
(sg.broad?' <span class="label label-warning" title="无唯一属性,选择器可能误匹配其他元素,建议选带文字/id 的元素">⚠宽泛</span>':'')+
'</div>';
}).join('');
document.getElementById('el-list').innerHTML=html||'<div class="se-empty">无元素</div>';
},
_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:'<tr><td colspan="5" class="empty">暂无自定义动作,可点击"新建动作"或用任务编辑器☑选中步骤打包</td></tr>'
});
applyListPager('actions');
initListSearch('actions','search-actions');
_stepEditor._renderLib(); // 刷新步骤编辑器左侧动作库
}
function renderActionRow(a){
const n=(a.steps||[]).length;
return '<tr>'+
'<td style="font-size:20px">'+esc(a.icon||'📦')+'</td>'+
'<td><strong>'+esc(a.name)+'</strong></td>'+
'<td>'+n+'</td>'+
'<td style="font-size:12px;color:#94a3b8">'+esc(a.created_at||'-')+'</td>'+
'<td>'+
(_can('tasks')?(
'<button class="btn btn-xs" onclick="openActionModal(\''+a.id+'\')">编辑</button> '+
'<button class="btn btn-danger btn-xs" onclick="deleteAction(\''+a.id+'\')">删除</button>'
):'<span class="text-muted">只读</span>')+
'</td>'+
'</tr>';
}
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=
'<div class="form-row">'+
'<div class="form-group"><label>动作名称</label><input type="text" class="form-control" id="a-name" value="'+esc(action?action.name:'')+'" placeholder="如:点赞动作"></div>'+
'<div class="form-group"><label>图标</label><input type="text" class="form-control" id="a-icon" value="'+esc(action?(action.icon&&action.icon!=='📦'?action.icon:''):'')+'" placeholder="📦" style="width:80px"></div>'+
'</div>'+
'<div class="help" style="margin-bottom:8px">用下方步骤编辑器编排动作内容,保存后可拖入任务步骤编辑器复用</div>'+
'<div class="step-editor" style="display:block">'+
'<div class="se-head"><span>动作步骤</span><span style="font-size:11px;color:var(--text-light);margin-left:12px">支持循环/分组/概率触发/拖拽排序</span>'+
(_can('tasks')?'<button class="btn btn-xs btn-primary" id="se-pack-btn2" disabled onclick="_stepEditor.saveAsCustomAction()">打包选中步骤</button>':'')+'</div>'+
'<div class="se-body">'+
'<div class="se-lib" id="action-lib"><div class="lib-title">操作库</div></div>'+
'<div class="se-canvas" id="action-canvas"></div>'+
'</div>'+
'</div>';
document.getElementById('action-modal-footer').innerHTML=
'<button class="btn" onclick="closeActionModal()">取消</button>'+
'<button class="btn btn-primary" onclick="saveAction('+(isEdit?'\''+actionId+'\'':'null')+')">保存动作</button>';
// 用独立画布初始化步骤编辑器(不与任务编辑器冲突)
_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:'<tr><td colspan="5" class="empty">暂无分组,点击"新建分组"创建</td></tr>'
});
applyListPager('groups');
initListSearch('groups','search-groups');
}
function renderGroupRow(g){
const serials=g.serials||[];
return '<tr>'+
'<td><strong>'+esc(g.name)+'</strong></td>'+
'<td>'+esc(g.description||'-')+'</td>'+
'<td>'+serials.length+'</td>'+
'<td class="text-muted" style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+(serials.length?serials.map(esc).join(', '):'-')+'</td>'+
'<td>'+
(_can('tasks')?(
'<button class="btn btn-xs" onclick="openGroupModal(\''+esc(g.name)+'\')">编辑</button>'+
'<button class="btn btn-danger btn-xs" onclick="deleteGroup(\''+esc(g.name)+'\')">删除</button>'
):'<span class="text-muted">只读</span>')+
'</td>'+
'</tr>';
}
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=>
'<label><input type="checkbox" value="'+esc(s)+'" '+(current.has(s)?'checked':'')+'>'+esc(s)+'</label>'
).join('');
document.getElementById('modal-body').innerHTML=
'<div class="form-group"><label>分组名</label><input type="text" class="form-control" id="g-name" value="'+esc(g.name)+'" placeholder="如:测试组" '+(isEdit?'readonly':'')+'></div>'+
'<div class="form-group"><label>描述</label><input type="text" class="form-control" id="g-description" value="'+esc(g.description||'')+'" placeholder="可选"></div>'+
'<div class="form-group"><label>设备列表(勾选属于此分组的设备)</label>'+
'<div class="device-list">'+(deviceCheckboxes||'<div class="text-muted">暂无在线设备</div>')+'</div>'+
'<div class="help">仅显示当前在线设备,已选设备即使离线也会保留</div>'+
'</div>';
document.getElementById('modal-footer').innerHTML=
'<button class="btn" onclick="closeModal()">取消</button>'+
'<button class="btn btn-primary" onclick="saveGroup('+(isEdit?'\''+esc(g.name)+'\'':'null')+')">'+(isEdit?'保存':'创建')+'</button>';
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,'<span class="log-err">[ERROR]</span>')
.replace(/\[WARNING\]/g,'<span class="log-warn">[WARNING]</span>')
.replace(/\[INFO\]/g,'<span class="log-info">[INFO]</span>');
document.getElementById('log-content').innerHTML=colored||'<span class="text-muted">(空)</span>';
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:'<tr><td colspan="5" class="empty">暂无用户</td></tr>'
});
applyListPager('users');
initListSearch('users','search-users');
}
function renderUserRow(u){
const admin=u.is_admin?'<span class="label label-primary">管理员</span>':'<span class="label label-default">普通</span>';
const perms=(u.perms||[]).map(p=>'<span class="label label-info">'+(PERM_LABELS[p]||p)+'</span>').join(' ')
||'<span class="text-muted">-</span>';
return '<tr>'+
'<td>'+u.id+'</td>'+
'<td><strong>'+esc(u.username)+'</strong></td>'+
'<td>'+admin+'</td>'+
'<td>'+perms+'</td>'+
'<td>'+
'<button class="btn btn-xs" onclick="openUserModal('+u.id+')">编辑</button>'+
(u.username==='admin'?'':'<button class="btn btn-danger btn-xs" onclick="deleteUser('+u.id+')">删除</button>')+
'</td>'+
'</tr>';
}
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=>
'<label style="margin-right:12px"><input type="checkbox" class="u-perm" value="'+p+'">'+PERM_LABELS[p]+'</label>'
).join('');
document.getElementById('modal-body').innerHTML=
'<div class="form-group"><label>用户名</label><input type="text" class="form-control" id="u-username" '+(isEdit?'disabled':'')+' placeholder="用户名"></div>'+
'<div class="form-group"><label>密码'+(isEdit?'(留空不修改)':'')+'</label><input type="password" class="form-control" id="u-password" placeholder="密码"></div>'+
'<div class="form-group form-check"><input type="checkbox" id="u-is_admin" onchange="togglePermBoxes()" checked><label for="u-is_admin">管理员权限(拥有全部权限)</label></div>'+
'<div class="form-group"><label>业务权限(管理员自动拥有全部,无需勾选)</label>'+
'<div>'+permBoxes+'</div>'+
'<div class="help">任务管理=任务/自定义动作/分组;设备控制=停止/释放/清异常/扫描/抓元素;应用管理=APK上传安装;日志查看=日志页</div>'+
'</div>';
document.getElementById('modal-footer').innerHTML=
'<button class="btn" onclick="closeModal()">取消</button>'+
'<button class="btn btn-primary" onclick="saveUser('+(uid||'null')+')">'+(isEdit?'保存':'创建')+'</button>';
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 <serial>;服务器类命令保持原样
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=['<option value="">(不指定设备)</option>'].concat(
list.map(d=>
'<option value="'+esc(d.serial)+'">'+esc(d.serial)+' ('+(stateTxt[d.state]||d.state)+')</option>'
)
);
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='<tr><td colspan="8" class="empty">加载中...</td></tr>';
const r=await apiGet('/api/tailscale/devices');
if(!r)return;
if(!r.ok){
tb.innerHTML='<tr><td colspan="8" class="empty">'+esc(r.error||'获取失败')+'</td></tr>';
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:'<tr><td colspan="8" class="empty">tailnet 暂无设备</td></tr>'
});
applyListPager('tailscale');
initListSearch('tailscale','search-tailscale');
if(st)st.textContent='共 '+_tsDevices.length+' 台设备';
}
function renderTsRow(d){
const ip=(d.addresses||[])[0]||'-';
return '<tr>'+
'<td><code>'+esc(d.hostname||'-')+'</code></td>'+
'<td>'+esc(d.name||'-')+'</td>'+
'<td style="font-family:monospace">'+esc(ip)+'</td>'+
'<td>'+esc(d.os||'-')+'</td>'+
'<td>'+((d.online===true)?'<span class="label label-success">在线</span>':(d.online===false?'<span class="label label-default">离线</span>':'<span class="label label-warning" title="12 小时内见过但当前未上报">最近在线</span>'))+'</td>'+
'<td>'+(d.authorized?'<span class="label label-success">已授权</span>':'<span class="label label-danger">未授权</span>')+'</td>'+
'<td>'+(d.key_expiry_disabled?'<span class="label label-warning">永不过期</span>':'<span class="label label-default">会过期</span>')+'</td>'+
'<td style="white-space:nowrap">'+
'<button class="btn btn-xs" onclick="renameTailscaleDevice(\''+esc(d.id)+'\',\''+esc(d.name||'')+'\')">改名</button> '+
'<button class="btn btn-xs" onclick="setTsIp(\''+esc(d.id)+'\',\''+esc((d.addresses||[])[0]||'')+'\')" title="修改设备的 Tailscale IPv4(会断开当前连接,平台 serial 随之变化)">设置IP</button> '+
'<button class="btn btn-xs" onclick="toggleTsAuth(\''+esc(d.id)+'\','+(!d.authorized)+')">'+(d.authorized?'取消授权':'授权')+'</button> '+
'<button class="btn btn-xs" onclick="toggleTsKeyExpiry(\''+esc(d.id)+'\','+(!d.key_expiry_disabled)+')">'+(d.key_expiry_disabled?'恢复过期':'密钥不过期')+'</button> '+
'<button class="btn btn-danger btn-xs" onclick="deleteTailscaleDevice(\''+esc(d.id)+'\')">移除</button>'+
'</td></tr>';
}
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=
'<div style="margin-bottom:10px">新设备执行 <code>tailscale up --authkey=...</code> 接入(默认已预授权)</div>'+
'<textarea id="ts-key-box" class="form-control" rows="4" style="font-family:monospace" readonly>'+esc(r.key||'')+'</textarea>'+
'<button class="btn btn-primary" style="margin-top:10px" onclick="copyTsKey()">复制</button>'+
' <span id="ts-key-status" class="text-muted"></span>';
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:'<tr><td colspan="6" class="empty">暂无APK,点击"上传APK"上传</td></tr>'
});
applyListPager('apks');
initListSearch('apks','search-apks');
await fillAppsDeviceList();
}
function renderApkRow(a){
return '<tr>'+
'<td><strong>'+esc(a.display_name||'(未命名)')+'</strong></td>'+
'<td style="font-family:monospace;font-size:12px;color:#64748b">'+esc(a.package_name||'-')+'</td>'+
'<td>'+(a.version_name?esc(a.version_name):'-')+'</td>'+
'<td>'+fmtSize(a.size)+'</td>'+
'<td style="font-size:12px;color:#94a3b8">'+esc(a.upload_time)+'</td>'+
'<td>'+
'<button class="btn btn-primary btn-xs" onclick="openInstallModal(\''+a.id+'\')">安装</button> '+
'<button class="btn btn-danger btn-xs" onclick="deleteApk(\''+a.id+'\')">删除</button>'+
'</td>'+
'</tr>';
}
// 填充"设备已装应用"的设备下拉(保留当前选择)
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='<option value="">选择设备...</option>'+devices.map(d=>
'<option value="'+esc(d.serial)+'">'+esc((d.serial||'').replace(/:5555$/,'')+' ('+(d.device_name||d.model||'未知设备')+')')+'</option>'
).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='<tr><td colspan="4" class="empty">'+(r&&r.error?esc(r.error):'获取失败')+'</td></tr>';
showToast('获取失败','error');return;
}
const apps=r.apps||[];
if(!apps.length){tb.innerHTML='<tr><td colspan="4" class="empty">无应用</td></tr>';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:'<tr><td colspan="4" class="empty">无应用</td></tr>'
});
applyListPager('devapps');
initListSearch('devapps','search-devapps');
}
function renderDevAppRow(a){
return '<tr>'+
'<td><strong>'+esc(a.label||a.package)+'</strong>'+(a.label?' <span style="font-size:11px;color:#94a3b8;font-family:monospace">'+esc(a.package)+'</span>':'')+'</td>'+
'<td>'+(a.version_name?esc(a.version_name):'-')+'</td>'+
'<td style="font-size:12px;color:#94a3b8">'+(a.version_code!=null?esc(a.version_code):'-')+'</td>'+
'<td style="font-size:11px;color:#94a3b8;word-break:break-all">'+esc(a.path||'-')+'</td>'+
'</tr>';
}
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='<span style="color:#f59e0b">正在上传 '+esc(file.name)+' '+pct+'% ('+loaded+'/'+total+' MB)</span>'+
'<div class="progress" style="width:200px;height:6px;margin-top:4px"><div class="progress-bar" style="width:'+pct+'%"></div></div>';
}
};
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=
'<div style="margin-bottom:12px">'+
'<strong>安装应用:</strong><span class="label label-primary">'+esc(apkName)+'</span>'+
' <span style="font-size:12px;color:#94a3b8">运行中设备将自动跳过</span>'+
'</div>'+
'<div style="margin-bottom:8px">'+
'<button class="btn btn-sm" onclick="document.querySelectorAll(\'#install-dev-list input[type=checkbox]\').forEach(c=>c.checked=true)">全选</button> '+
'<button class="btn btn-sm" onclick="document.querySelectorAll(\'#install-dev-list input[type=checkbox]\').forEach(c=>c.checked=false)">全不选</button> '+
'<button class="btn btn-sm btn-primary" onclick="selectIdleForInstall()">全选空闲</button>'+
'</div>'+
'<div id="install-dev-list" class="device-list">'+
online.map(d=>{
const isUsb=d.source==='usb';
const st='<span class="label label-'+(isUsb?'primary':'default')+'" style="margin-left:6px">'+(isUsb?'本地USB':(d.source==='stf'?'STF':'adb'))+'</span>';
// serial 唯一,显示 IP + 型号便于区分同型号设备
const label=(d.serial||'').replace(/:5555$/,'')+' · '+(d.model||(isUsb?'USB 有线设备':'设备'));
return '<label><input type="checkbox" value="'+esc(d.serial)+'" checked>'+esc(label)+st+'</label>';
}).join('')+
'</div>';
document.getElementById('modal-title').textContent='安装应用到设备';
document.getElementById('modal-body').innerHTML=body;
document.getElementById('modal-footer').innerHTML=
'<button class="btn" onclick="closeModal()">取消</button>'+
'<button class="btn btn-primary" onclick="startInstall(\''+apkId+'\')">开始安装</button>';
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=
'<div id="install-progress" style="min-height:200px"><p class="text-muted">加载中...</p></div>';
document.getElementById('modal-footer').innerHTML=
'<button class="btn" onclick="closeInstallModal()" id="install-close-btn" style="display:none">关闭</button>';
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='<p class="text-muted">无安装任务</p>';
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 '<tr><td>'+esc(info.name||serial)+'</td>'+
'<td style="font-family:monospace;font-size:11px;color:#94a3b8">'+esc(serial)+'</td>'+
'<td><span class="label label-'+s.c+'">'+s.t+'</span></td>'+
'<td style="font-size:12px;color:#64748b">'+esc(info.msg||'')+'</td></tr>';
}).join('');
const pct=st.total>0?Math.round((st.success+st.failed+st.skipped)*100/st.total):0;
const summary=
'<div style="margin-bottom:12px">'+
'<div style="display:flex;gap:8px;margin-bottom:8px;font-size:13px;flex-wrap:wrap">'+
'<span>应用:<strong>'+esc(st.apk_name)+'</strong></span>'+
'<span class="label label-success">成功 '+st.success+'</span>'+
'<span class="label label-danger">失败 '+st.failed+'</span>'+
'<span class="label label-default">跳过 '+st.skipped+'</span>'+
'<span class="label label-warning">安装中 '+st.installing+'</span>'+
'<span class="label label-default">等待 '+st.pending+'</span>'+
'</div>'+
'<div class="progress" style="width:100%;height:8px"><div class="progress-bar" style="width:'+pct+'%"></div></div>'+
'</div>';
document.getElementById('install-progress').innerHTML=
summary+'<table class="table" style="margin-bottom:0"><thead><tr><th>设备</th><th>Serial</th><th>状态</th><th>详情</th></tr></thead><tbody>'+itemRows+'</tbody></table>';
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');