需求:任务编辑器里能单独配"这个任务每隔 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。
506 lines
30 KiB
JavaScript
506 lines
30 KiB
JavaScript
// 任务 Tab:任务计划 CRUD/调度解析/运行窗口 + 自定义动作管理
|
||
// ================== 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||[]:[];
|
||
// items 带名称(设备身份);老接口只有 serial 列表时兜底成对象
|
||
_devicesList=(dr&&dr.items)?dr.items
|
||
:((dr&&dr.devices)||[]).map(s=>({serial:s,name:'',model:''}));
|
||
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)),
|
||
sortValue:(j,k)=>{ if(k==='id')return j.id||''; if(k==='enabled')return j.enabled?1:0; if(k==='next_run')return j.next_run||''; return j[k]||''; },
|
||
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>';
|
||
}
|
||
|
||
// 任务信封归一化:补齐 7 个键,避免下游无保护访问
|
||
// (原文只对"已存在的 job"安全,AI 草稿/半成品信封会让 j.retry.max_attempts 之类直接抛错)
|
||
function _normalizeTaskEnv(env){
|
||
const e=env||{}, t=e.target||{}, s=e.schedule||{}, r=e.retry||{}, p=e.params||{};
|
||
return {
|
||
name:e.name||'',
|
||
task_type:e.task_type||'generic_steps',
|
||
enabled:(e.enabled===undefined)?true:!!e.enabled,
|
||
target:{mode:t.mode||'all', group_name:t.group_name||'', serial:t.serial||''},
|
||
schedule:{mode:s.mode||'once', cron:s.cron||'', stop_cron:s.stop_cron||'',
|
||
window:s.window||null},
|
||
retry:{max_attempts:r.max_attempts||1, delay:r.delay||60},
|
||
params:Object.assign({}, p)
|
||
};
|
||
}
|
||
|
||
// ================== 公共巡检(任务级,独立于步骤画布) ==================
|
||
// 与后端 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}(服务端已校验,这里只预填给人核对)
|
||
function openTaskModal(jobId, prefill){
|
||
const job=jobId?(_jobsData||[]).find(j=>j.id===jobId):null;
|
||
const isEdit=!!job;
|
||
const pf=(prefill&&prefill.task)?prefill:null;
|
||
document.getElementById('modal-title').textContent=
|
||
isEdit?'编辑任务':(pf?'AI 生成任务(请核对后保存)':'新建任务');
|
||
// 任务编辑器含步骤编辑,加宽弹窗;关闭时在 closeModal 移除
|
||
document.getElementById('modal-box').classList.add('wide-modal');
|
||
|
||
const j=_normalizeTaskEnv(job||(pf&&pf.task)||{});
|
||
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(x=>'<option value="'+esc(x.serial)+'"'+(x.serial===j.target.serial?' selected':'')+'>'
|
||
+esc(devText(x.name, x.serial))+'</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 class="form-check" style="margin-top:8px">'+
|
||
'<input type="checkbox" id="f-skip_offline" '+(j.params&&j.params.skip_offline===false?'':'checked')+'>'+
|
||
'<label for="f-skip_offline">离线设备自动跳过(推荐)</label>'+
|
||
'</div>'+
|
||
'<div class="form-check" style="margin-top:6px">'+
|
||
'<input type="checkbox" id="f-preempt" '+(j.params&&j.params.preempt?'checked':'')+'>'+
|
||
'<label for="f-preempt" title="触发时停止目标设备上正在运行的其他任务,接管设备执行本任务">抢占设备(打断其他任务)</label>'+
|
||
'</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>'+
|
||
// 公共巡检:任务级配置,**不在步骤画布里**(穿插执行,见 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 草稿的提醒/复核项:贴在步骤编辑器上方,别让人漏看
|
||
if(pf&&((pf.notes&&pf.notes.length)||(pf.warnings&&pf.warnings.length))){
|
||
const se=document.getElementById('step-editor');
|
||
if(se) se.insertAdjacentHTML('beforebegin',
|
||
'<div class="tg-note" style="margin-bottom:10px"><b>AI 探索说明(请核对)</b>'
|
||
+(pf.notes||[]).map(n=>'<div>· '+esc(n)+'</div>').join('')
|
||
+((pf.warnings||[]).length
|
||
? '<div style="margin-top:6px;color:#b45309"><b>校验提醒</b>'
|
||
+pf.warnings.map(w=>'<div>· '+esc(w)+'</div>').join('')+'</div>'
|
||
: '')
|
||
+'</div>');
|
||
}
|
||
// 初始化步骤编辑器(generic_steps 时显示,其他隐藏);jobId 作为草稿 key。
|
||
// AI 草稿用一个唯一 key:既避开 localStorage 里"新建任务"的旧草稿覆盖预填内容,
|
||
// 又保留自动存草稿(保存成功时 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>'+
|
||
'<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';
|
||
const pb=document.getElementById('patrol-box');
|
||
if(pb)pb.style.display=isGeneric?'block':'none'; // 巡检只对通用步骤任务有意义
|
||
}
|
||
|
||
// ================== 自定义动作管理 ==================
|
||
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)),
|
||
sortValue:(a,k)=>{ if(k==='steps')return (a.steps||[]).length; if(k==='created_at')return a.created_at||''; return a[k]||''; },
|
||
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');
|
||
}
|
||
|