需求:滑动要能"我自己录制";在自定义动作旁边加一个分栏,能配动作默认值、
能录制操作当动作、能自己建立动作。
**步骤默认值**(core/step_defaults.py,存 app_meta.step_defaults 单键,不建表):
- 覆盖 滑动(方向/时长区间/幅度/抖动/拟人)、点击(等待超时)、长按(时长/超时)、
等待(区间)、按键、输入文字(模式/候选文案/清空);
- **只影响之后新建的步骤**,已有步骤不动;新建时 `_makeStep` 用出厂值打底 + 默认值覆盖;
- 只存与出厂值不同的字段(以后调出厂默认,没配过的能跟着走);
- 字段白名单 + 范围夹取(SPEC):越界夹边界、非法值丢弃 → 手改 JSON 塞脏值也进不来。
**动作录制器**(static/admin/recorder.js):
- 在设备画面上点/划/按键/输入 → 自动翻译成步骤:
点→命中元素记「点击元素」(选择器)、没命中退化成「点击坐标」(百分比);
划→「滑动」方向/幅度/时长全按你实际操作算;停顿≥1s 可选记成「等待」(±15%);
- 两个入口:动作配置页「录制动作」(存成自定义动作,进动作库复用)、
步骤编辑器滑动那步的「录制手势」(只取手势回填该步骤);
- 画面+元素树走 /api/uiauto/snapshot 一次取齐,**每次操作后自动刷新**,
所以下一次点击用的是最新的树;执行仍走 /api/screen/{tap,swipe,key,text}(与大屏同一套)。
接口:GET/POST /api/step_defaults(读登录、写需 tasks 权限)。
文档:TASK_DEV §3.1(含翻译规则表与三条实现口径)、API §2.3、DATA_MODEL §5、README。
自测(无头浏览器 + CDP 真模拟拖动):默认值保存→回读→新建滑动步骤确实预填 0.42;
录制器取到画面与元素树;模拟"按住往上划"→ 录出
{direction:up, distance_ratio:0.5, duration_min:0.47, humanize:true}(幅度/时长按实际操作算);
恢复出厂链路 0.6 通过;页面无 JS 报错。
606 lines
35 KiB
JavaScript
606 lines
35 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');
|
||
}
|
||
|
||
|
||
// ================== 动作配置:步骤默认值 ==================
|
||
// 存 app_meta.step_defaults(见 core/step_defaults.py);只影响**之后新建**的步骤。
|
||
const _CFG_LABELS = {
|
||
swipe: '滑动', click: '点击元素', long_click: '长按元素', wait: '等待',
|
||
key_event: '按键', input_text: '输入文字',
|
||
};
|
||
const _CFG_FIELD_LABELS = {
|
||
direction: '方向', duration_min: '最短时长(秒)', duration_max: '最长时长(秒)',
|
||
distance_ratio: '幅度(屏占比)', jitter: '抖动幅度', humanize: '拟人轨迹',
|
||
wait_timeout: '等待超时(秒)', duration: '长按时长(秒)', min: '最短(秒)',
|
||
max: '最长(秒)', key: '按键', mode: '输入模式', texts: '候选文案(每行一条)',
|
||
fixed_text: '固定文字', clear_first: '输入前清空',
|
||
};
|
||
const _CFG_DIR_LABELS = {up:'上滑', down:'下滑', left:'左滑', right:'右滑'};
|
||
let _cfgPayload = null;
|
||
|
||
// 取一次步骤默认值缓存在 _stepDefaults(editor.js 的 _makeStep 用它预填新步骤)
|
||
async function loadStepDefaults(force) {
|
||
if (_stepDefaults && !force) return _stepDefaults;
|
||
const r = await apiGet('/api/step_defaults');
|
||
_stepDefaults = (r && r.ok) ? (r.defaults || {}) : {};
|
||
return _stepDefaults;
|
||
}
|
||
|
||
async function loadActionConfig(force) {
|
||
const box = document.getElementById('cfg-forms');
|
||
if (!box) return;
|
||
if (_cfgPayload && !force) { renderStepDefaults(); return; }
|
||
box.innerHTML = '<div class="text-muted" style="padding:10px">加载中…</div>';
|
||
const r = await apiGet('/api/step_defaults');
|
||
if (!r || !r.ok) { box.innerHTML = '<div class="text-muted" style="padding:10px">加载失败</div>'; return; }
|
||
_cfgPayload = r;
|
||
_stepDefaults = r.defaults || {}; // 编辑器新建步骤时预填用
|
||
renderStepDefaults();
|
||
}
|
||
|
||
function renderStepDefaults() {
|
||
const box = document.getElementById('cfg-forms');
|
||
if (!box || !_cfgPayload) return;
|
||
const d = _cfgPayload.defaults || {}, kinds = _cfgPayload.kinds || {}, spec = _cfgPayload.spec || {};
|
||
box.innerHTML = Object.keys(d).map(function (st) {
|
||
const fields = Object.keys(kinds[st] || {}).map(function (k) {
|
||
const kind = kinds[st][k], v = d[st][k], rule = spec[st] ? spec[st][k] : null;
|
||
let inp;
|
||
if (kind === 'bool') {
|
||
inp = '<label class="form-check"><input type="checkbox" data-st="' + st + '" data-k="' + k + '"' +
|
||
(v ? ' checked' : '') + '><span>' + (_CFG_FIELD_LABELS[k] || k) + '</span></label>';
|
||
return inp;
|
||
}
|
||
if (kind === 'choice') {
|
||
inp = '<select class="form-control" data-st="' + st + '" data-k="' + k + '">' +
|
||
(rule || []).map(function (o) {
|
||
const lab = _CFG_DIR_LABELS[o] || (o === 'random' ? '随机' : o === 'fixed' ? '指定' : o);
|
||
return '<option value="' + esc(o) + '"' + (v === o ? ' selected' : '') + '>' + esc(lab) + '</option>';
|
||
}).join('') + '</select>';
|
||
} else if (kind === 'num') {
|
||
inp = '<input type="number" class="form-control" data-st="' + st + '" data-k="' + k + '" value="' + esc(v) +
|
||
'" step="0.05" min="' + (rule ? rule[0] : '') + '" max="' + (rule ? rule[1] : '') + '">';
|
||
} else if (k === 'texts') {
|
||
inp = '<textarea class="form-control" rows="3" data-st="' + st + '" data-k="' + k + '">' + esc(v) + '</textarea>';
|
||
} else {
|
||
inp = '<input type="text" class="form-control" data-st="' + st + '" data-k="' + k + '" value="' + esc(v) + '">';
|
||
}
|
||
return '<div class="pg"><label>' + (_CFG_FIELD_LABELS[k] || k) + '</label>' + inp + '</div>';
|
||
}).join('');
|
||
return '<div class="cfg-card"><h4>' + (_CFG_LABELS[st] || st) + ' <span class="text-muted" style="font-weight:400">' + st + '</span></h4>' +
|
||
'<div class="cfg-row">' + fields + '</div></div>';
|
||
}).join('');
|
||
document.getElementById('cfg-hint').textContent = '';
|
||
}
|
||
|
||
function collectStepDefaults() {
|
||
const out = {};
|
||
document.querySelectorAll('#cfg-forms [data-st]').forEach(function (el) {
|
||
const st = el.dataset.st, k = el.dataset.k;
|
||
out[st] = out[st] || {};
|
||
if (el.type === 'checkbox') out[st][k] = el.checked;
|
||
else if (el.type === 'number') out[st][k] = parseFloat(el.value);
|
||
else out[st][k] = el.value;
|
||
});
|
||
return out;
|
||
}
|
||
|
||
async function saveStepDefaults() {
|
||
const r = await apiPost('/api/step_defaults', { defaults: collectStepDefaults() });
|
||
if (!r || !r.ok) { showToast((r && r.error) || '保存失败', 'error'); return; }
|
||
_cfgPayload = r;
|
||
_stepDefaults = r.defaults || {};
|
||
showToast('默认值已保存(只影响之后新建的步骤)', 'success');
|
||
document.getElementById('cfg-hint').textContent = '已保存 ' + new Date().toLocaleTimeString();
|
||
renderStepDefaults();
|
||
}
|
||
|
||
function resetStepDefaults() {
|
||
if (!_cfgPayload) return;
|
||
if (!confirm('恢复成出厂默认?(只影响这张表单,点「保存默认值」才真正生效)')) return;
|
||
_cfgPayload.defaults = JSON.parse(JSON.stringify(_cfgPayload.factory || {}));
|
||
renderStepDefaults();
|
||
}
|