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:
+55
-15
@@ -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||'');
|
||||
}
|
||||
|
||||
// 加载自定义动作库
|
||||
@@ -324,6 +338,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>';
|
||||
@@ -353,12 +381,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,<100 表示按概率执行(如 30=隔几次才触发一次,适合"偶尔点赞")</div></div></div>';
|
||||
@@ -626,10 +659,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){
|
||||
@@ -1220,6 +1255,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;}
|
||||
|
||||
@@ -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