feat(任务): 任务级「公共巡检」——独立于步骤画布的守护条件(含 webhook 通知)
需求:任务编辑器里能单独配"这个任务每隔 N 秒检查一次"——熄屏就点亮、某个元素
出现就通知、掉出 App 就停本设备;通知标题正文要能自己写。
配置与执行分离(这是本次的关键设计):
- **配置是任务级的**(`params.watchers`),在任务编辑器单独一块,不进步骤画布;
- **执行是穿插的**:worker 每执行完一步、以及长等待的每个分片,看一眼哪个巡检
到点了。不起线程 → 不需要并发模型,也不会和主流程抢屏幕(两边同时点屏幕会
互相打断)。代价是精度受步长影响(某步卡 30s,巡检最多晚 30s),已在文档写明。
- core/patrol.py(新):检查项/动作注册表(CHECKS/ACTIONS)+ evaluate/act。
检查:屏幕熄灭/亮着、元素存在/不存在、前台是/不是某 App;
动作:只通知、点亮、息屏、停止本设备。屏幕走 `dumpsys power`(0.3s,
不用 d.info——那玩意在部分设备要 14s),前台走 d.app_current()(0.7s)。
- tasks/generic/task.py:`_maybe_patrol` / `_run_patrol`(冷却、命中记一条
步骤明细、发通知);**文案在动作之前渲染**——点亮后 {screen} 就成了"亮屏",
用户要看的是"发现熄屏,已点亮"。
- 任务编辑器新增「公共巡检」块(static/admin/tasks.js)+ 样式;保存进 params.watchers。
- 通知:新增事件 `task.patrol.hit`(巡检命中)与 `task.notify.custom`(步骤发通知);
给了 title 就用它当标题(不再拼前缀),level 字段可点名级别。
顺带(巡检需要的原语,也可单独用):
- if_el 条件判断支持 `selector_type=screen`(亮/熄)与 `foreground`(前台包名);
非元素条件不参与「选择器健康」统计(否则会攒出假的"选择器失效"告警)。
- 新增两个步骤:`notify`(发自定义通知)、`stop_self`(停本设备,记"被停止"
而不是失败,不触发重试)。步骤类型 18 → 20,相关文档计数一并更新。
修 bug:`wait` 步骤在巡检耗时超过剩余时间后 `sleep(负数)` 抛
"sleep length must be non-negative"(真机联调抓到,已 clamp 到 0)。
自测:假设备单测 12 组(命中/冷却/间隔/元素/前台/停止/静默/异常不炸);
真机联调:熄屏→点亮(False→True)+ 通知文案正确、掉出抖音按间隔命中 5 次、
长等待里穿插生效且步骤回到 ok、清理后用户通知配置原样恢复。
文档:TASK_DEV §4.5(含两个可抄的例子)与步骤表/条件类型、NOTIFY §3、README。
This commit is contained in:
@@ -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'; // 巡检只对通用步骤任务有意义
|
||||
}
|
||||
|
||||
// ================== 自定义动作管理 ==================
|
||||
|
||||
Reference in New Issue
Block a user