Merge branch 'feat/task-patrol'——任务级公共巡检(含与拟人滑动的合并:import、能力表两处冲突已解)

This commit is contained in:
2026-09-16 13:10:05 +08:00
15 changed files with 644 additions and 41 deletions
+55 -15
View File
@@ -19,7 +19,9 @@ var STEP_LIB=[
{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:[]}}
{type:'if_el',label:'条件判断',icon:'❓',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:3,ocr_click:false,then:[],else:[]}},
{type:'notify',label:'发通知',icon:'🔔',cat:'flow',params:{title:'',message:'',level:'info'}},
{type:'stop_self',label:'停止本设备',icon:'⛔',cat:'flow',params:{reason:''}}
];
var STEP_LIB_CATS=[['interact','交互操作'],['screen','屏幕与App'],['flow','流程控制']];
var _customActions=[]; // 后端加载的自定义动作
@@ -46,19 +48,31 @@ 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']:
// withOcr(条件判断用)额外提供"屏幕状态 / 前台App"两类非元素条件
var types=withOcr?['xpath','description','text','resourceId','descriptionContains','className','ocr','screen','foreground']:
['xpath','description','text','resourceId','descriptionContains','className'];
var labels={xpath:'xpath',description:'description',text:'text',resourceId:'resourceId',
descriptionContains:'descriptionContains',className:'className',ocr:'OCR识别'};
descriptionContains:'descriptionContains',className:'className',ocr:'OCR识别',
screen:'屏幕状态',foreground:'前台App是'};
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 toggle=withOcr?' onchange="_stepEditor._condTypeChanged(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>':'';
var isScreen=p.selector_type==='screen', isFg=p.selector_type==='foreground';
var valLabel=isScreen?'屏幕状态':(isFg?'前台包名':'选择器值');
var valHtml;
if(isScreen){
valHtml='<select class="form-control" data-key="selector_value" style="max-width:140px">'+
'<option value="off"'+(p.selector_value!=='on'?' selected':'')+'>熄屏时就命中</option>'+
'<option value="on"'+(p.selector_value==='on'?' selected':'')+'>亮屏时就命中</option></select>';
}else{
valHtml='<input type="text" class="form-control" data-key="selector_value" value="'+esc(p.selector_value||'')+
'" placeholder="'+(isFg?'如 com.ss.android.ugc.aweme':'如 点赞')+'">'+(isFg?'':pick+test);
}
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||'');
'<div class="form-group" style="grid-column:span 2"><label>'+valLabel+'</label><div class="sel-picker">'+
valHtml+'</div></div></div>'+(extra||'');
}
// 加载自定义动作库
@@ -343,6 +357,20 @@ var _stepEditor={
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==='notify'){
h+='<div class="form-group"><label>标题</label><input type="text" class="form-control" data-key="title" value="'+esc(p.title||'')+'" placeholder="如 抖音号1 掉线了(留空用任务通知)"></div>';
h+='<div class="form-group"><label>内容</label><textarea class="form-control" data-key="message" rows="2" style="min-height:52px">'+esc(p.message||'')+'</textarea></div>';
h+='<div class="form-row"><div class="form-group"><label>级别</label><select class="form-control" data-key="level">'+
[['info','ℹ️ 普通'],['success','✅ 正常'],['warning','⚠️ 警告'],['error','❌ 异常']].map(function(l){
return '<option value="'+l[0]+'"'+((p.level||'info')===l[0]?' selected':'')+'>'+l[1]+'</option>';}).join('')+
'</select></div></div>';
h+='<div class="help">推送到哪条群取决于 webhook 自己的<b>事件订阅</b>(勾了「任务自定义通知」的才收)。'
+'正文可用占位符:<b>{device}</b> 设备名、<b>{serial}</b> 地址、<b>{job}</b> 任务名、<b>{time}</b> 时间、'
+'<b>{app}</b> 当前前台包名、<b>{screen}</b> 亮屏/熄屏(后两个要查设备,不用就别写)</div>';
}else if(step.type==='stop_self'){
h+='<div class="form-group"><label>停止原因(可留空)</label><input type="text" class="form-control" data-key="reason" value="'+esc(p.reason||'')+'" placeholder="如 连续刷到广告"></div>';
h+='<div class="help">只停<b>本设备</b>的任务,其它设备照常;后续步骤不再执行,任务记为「被停止」而不是失败'
+'(不会触发失败重试)。常配合「条件判断」用:命中条件 → 先「发通知」再「停止本设备」</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>';
@@ -374,12 +402,17 @@ var _stepEditor={
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'){
// 屏幕状态 / 前台App 是"直接读设备当前状态",没有等待超时、也没有选择器可抓
var dyn=(p.selector_type==='screen'||p.selector_type==='foreground');
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>'+
(dyn?'':'<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);
'<input type="checkbox" data-key="ocr_click" '+(p.ocr_click?'checked':'')+'><label>OCR 命中后自动点击该文字</label></div>')+
'<div class="help">'+(dyn
? '命中 → 执行下方"✅ 找到时"分支;未命中 → 执行"❌ 未找到时"分支。<b>屏幕状态</b>读系统 mWakefulness(亮/熄),'
+'<b>前台App是</b>判断当前前台是不是某个包名——都只看当前状态,不等待。'
: '超时内找到元素 → 执行下方"✅ 找到时"分支;未找到 → 执行"❌ 未找到时"分支。两分支可放任意步骤,也可再嵌套条件判断/循环;'
+'<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>';
@@ -647,10 +680,12 @@ var _stepEditor={
});
},
// 条件判断:选择器类型切到 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';
_condTypeChanged:function(sel){
// 条件类型一变,"值"控件也要跟着换(元素值输入框 / 屏幕亮熄下拉 / 包名输入),
// 整卡重渲染最省心。先把 DOM 的值同步回 _steps,否则重渲染会丢掉刚改的东西。
this._syncFromDom();
var self=this;
setTimeout(function(){self._renderCanvas();},0);
},
// 给数组分配唯一 id,用于 move/duplicate/remove/拖拽定位到正确的父数组
_arrId:function(arr){
@@ -1241,6 +1276,11 @@ async function saveTask(jobId){
}
const mdEl=document.getElementById('f-max_duration');
params={max_duration:mdEl?parseInt(mdEl.value)||0:0,steps:steps};
// 公共巡检(任务级,独立于步骤画布;实现在 tasks.js + core/patrol.py)
if(typeof patrolCollect==='function'){
const ws=patrolCollect();
if(ws.length)params.watchers=ws;
}
}else{
try{params=JSON.parse(document.getElementById('f-params').value||'{}');}
catch(e){showToast('参数 JSON 格式错误','error');return;}
+135
View File
@@ -80,6 +80,128 @@ function _normalizeTaskEnv(env){
};
}
// ================== 公共巡检(任务级,独立于步骤画布) ==================
// 与后端 core/patrol.py 的 CHECKS/ACTIONS 一一对应;选项变动时两边一起改。
// 存进任务 params.watchers,worker 在步骤之间穿插执行(见 core/patrol.py 模块注释)。
const _PATROL_CHECKS=[
['screen_off','屏幕熄灭',''], ['screen_on','屏幕亮着',''],
['element_exists','元素存在','selector'], ['element_missing','元素不存在','selector'],
['foreground_is','前台是该App','package'], ['foreground_not','前台不是该App','package'],
];
const _PATROL_ACTIONS=[
['none','只发通知,不动设备'], ['screen_on','点亮屏幕'],
['screen_off','熄灭屏幕'], ['stop_self','停止本设备任务'],
];
let _patrolItems=[];
function patrolInit(items){
_patrolItems=(items||[]).filter(function(x){return x&&typeof x==='object';})
.map(function(x){return Object.assign({},x);});
patrolRender();
}
function patrolAdd(){
patrolSync();
_patrolItems.push({enabled:true,name:'',interval:60,check:'screen_off',selector_type:'xpath',
selector_value:'',action:'screen_on',notify:true,
title:'',message:'',cooldown:300});
patrolRender();
}
function patrolRemove(i){
patrolSync();
_patrolItems.splice(i,1);
patrolRender();
}
// DOM → 内存(重渲染前必须调,否则刚填的值会被覆盖)
function patrolSync(){
const list=document.getElementById('patrol-list');
if(!list)return;
list.querySelectorAll('.patrol-card').forEach(function(card,i){
const w=_patrolItems[i]; if(!w)return;
const get=function(k){
const el=card.querySelector('[data-pk="'+k+'"]');
if(!el)return undefined;
return el.type==='checkbox'?el.checked:el.value;
};
['name','check','action','selector_type','selector_value','title','message'].forEach(function(k){
const v=get(k); if(v!==undefined)w[k]=v;
});
['interval','cooldown'].forEach(function(k){
const v=get(k); if(v!==undefined)w[k]=parseInt(v,10)||0;
});
['enabled','notify'].forEach(function(k){
const v=get(k); if(v!==undefined)w[k]=v;
});
});
}
function patrolCollect(){
patrolSync();
return _patrolItems.map(function(w){
return {enabled:w.enabled!==false, name:(w.name||'').trim(), interval:parseInt(w.interval,10)||60,
check:w.check||'screen_off', selector_type:w.selector_type||'xpath',
selector_value:(w.selector_value||'').trim(), action:w.action||'none',
notify:w.notify!==false, title:(w.title||'').trim(), message:(w.message||'').trim(),
cooldown:parseInt(w.cooldown,10)||0};
});
}
// 检查项一变,条件行(选择器/包名)跟着换
function patrolCheckChanged(sel){
const card=sel.closest('.patrol-card');
const need=(_PATROL_CHECKS.find(function(c){return c[0]===sel.value;})||[])[2]||'';
const row=card.querySelector('.patrol-cond');
row.style.display=need?'flex':'none';
const typeBox=row.querySelector('.patrol-seltype');
if(typeBox)typeBox.style.display=(need==='selector')?'block':'none';
const lb=row.querySelector('.patrol-vallabel');
if(lb)lb.textContent=(need==='package')?'前台包名':'选择器值';
const inp=row.querySelector('[data-pk="selector_value"]');
if(inp)inp.placeholder=(need==='package')?'如 com.ss.android.ugc.aweme':'如 点赞';
}
function patrolRender(){
const list=document.getElementById('patrol-list');
if(!list)return;
if(!_patrolItems.length){
list.innerHTML='<div class="text-muted" style="font-size:12px;padding:4px 0">(没有巡检项)</div>';
return;
}
list.innerHTML=_patrolItems.map(_patrolCardHtml).join('');
}
function _patrolCardHtml(w,i){
const need=(_PATROL_CHECKS.find(function(c){return c[0]===w.check;})||[])[2]||'';
const chkOpts=_PATROL_CHECKS.map(function(c){
return '<option value="'+c[0]+'"'+(w.check===c[0]?' selected':'')+'>'+c[1]+'</option>';}).join('');
const actOpts=_PATROL_ACTIONS.map(function(a){
return '<option value="'+a[0]+'"'+(w.action===a[0]?' selected':'')+'>'+a[1]+'</option>';}).join('');
const typeOpts=['xpath','description','text','resourceId','descriptionContains','className']
.map(function(t){return '<option value="'+t+'"'+(w.selector_type===t?' selected':'')+'>'+t+'</option>';}).join('');
return '<div class="patrol-card">'
+'<div class="patrol-row">'
+'<label class="form-check"><input type="checkbox" data-pk="enabled" '+(w.enabled!==false?'checked':'')+'><span>启用</span></label>'
+'<input class="form-control" data-pk="name" style="flex:1" placeholder="巡检名,如 熄屏点亮" value="'+esc(w.name||'')+'">'
+'<button class="btn btn-xs btn-danger" onclick="patrolRemove('+i+')">删除</button>'
+'</div>'
+'<div class="patrol-row">'
+'<div class="pg"><label>每隔(秒)</label><input type="number" class="form-control" data-pk="interval" value="'+(w.interval||60)+'" min="5"></div>'
+'<div class="pg"><label>检查</label><select class="form-control" data-pk="check" onchange="patrolCheckChanged(this)">'+chkOpts+'</select></div>'
+'<div class="pg"><label>命中后动作</label><select class="form-control" data-pk="action">'+actOpts+'</select></div>'
+'<div class="pg"><label>冷却(秒)</label><input type="number" class="form-control" data-pk="cooldown" value="'+(w.cooldown!=null?w.cooldown:300)+'" min="0"></div>'
+'</div>'
+'<div class="patrol-row patrol-cond" style="display:'+(need?'flex':'none')+'">'
+'<div class="pg patrol-seltype" style="display:'+(need==='selector'?'block':'none')+'"><label>选择器类型</label>'
+'<select class="form-control" data-pk="selector_type">'+typeOpts+'</select></div>'
+'<div class="pg" style="flex:1"><label class="patrol-vallabel">'+(need==='package'?'前台包名':'选择器值')+'</label>'
+'<input class="form-control" data-pk="selector_value" value="'+esc(w.selector_value||'')+'" placeholder="'
+(need==='package'?'如 com.ss.android.ugc.aweme':'如 点赞')+'"></div>'
+'</div>'
+'<div class="patrol-row">'
+'<label class="form-check"><input type="checkbox" data-pk="notify" '+(w.notify!==false?'checked':'')+'><span>命中发通知</span></label>'
+'<div class="pg" style="flex:1"><label>标题</label><input class="form-control" data-pk="title" value="'+esc(w.title||'')+'" placeholder="{device} 熄屏了"></div>'
+'<div class="pg" style="flex:2"><label>内容</label><input class="form-control" data-pk="message" value="'+esc(w.message||'')+'" placeholder="任务 {job} 在 {time} 发现 {screen},已处理"></div>'
+'</div>'
+'<div class="help">占位符:{device} 设备名、{serial} 地址、{job} 任务名、{time} 时间、{app} 当前前台包名、'
+'{screen} 亮屏/熄屏(后两个要查设备,不用就别写)。冷却期内重复命中不会重复动作/通知,防刷屏</div>'
+'</div>';
}
// openTaskModal(jobId, prefill)
// jobId —— 传 id = 编辑已有任务
// prefill —— AI 建任务的草稿:{task, notes, warnings}(服务端已校验,这里只预填给人核对)
@@ -179,6 +301,16 @@ function openTaskModal(jobId, prefill){
'<div class="se-lib" id="se-lib"><div class="lib-title">操作库</div></div>'+
'<div class="se-canvas" id="se-canvas"></div>'+
'</div>'+
'</div>'+
// 公共巡检:任务级配置,**不在步骤画布里**(穿插执行,见 core/patrol.py)
'<div class="patrol-box" id="patrol-box">'+
'<div class="patrol-head">'+
'<span>公共巡检<span class="hint" style="margin-left:8px">可选:任务运行期间按间隔穿插检查,命中就动作 + 通知</span></span>'+
'<button class="btn btn-xs" onclick="patrolAdd()">+ 添加巡检</button>'+
'</div>'+
'<div id="patrol-list"></div>'+
'<div class="help">和步骤的区别:步骤是"按顺序做完这一段",巡检是"全程都要盯着的条件"(比如熄屏就点亮、掉到桌面就通知)。'+
'检查穿插在步骤之间执行,不额外抢设备;某一步耗时很长时,巡检最多晚那一步的时间。</div>'+
'</div>';
// AI 草稿的提醒/复核项:贴在步骤编辑器上方,别让人漏看
@@ -198,6 +330,7 @@ function openTaskModal(jobId, prefill){
// 又保留自动存草稿(保存成功时 saveTask 会 clearDraft 清掉它)。
const draftKey=jobId||(pf?('ai:'+Date.now()):'');
_stepEditor.init(j.params&&j.params.steps?j.params.steps:[],j.params&&j.params.max_duration?j.params.max_duration:0,draftKey);
patrolInit(j.params&&j.params.watchers);
document.getElementById('modal-footer').innerHTML=
'<button class="btn" onclick="closeModal()">取消</button>'+
@@ -285,6 +418,8 @@ function onTaskTypeChange(){
const isGeneric=tt==='generic_steps';
document.getElementById('step-editor').classList.toggle('show',isGeneric);
document.getElementById('f-params_wrap').style.display=isGeneric?'none':'block';
const pb=document.getElementById('patrol-box');
if(pb)pb.style.display=isGeneric?'block':'none'; // 巡检只对通用步骤任务有意义
}
// ================== 自定义动作管理 ==================