Files
auto_control/static/admin/tasks.js
T
butubb bd51306a2a feat(任务): 「动作配置」分栏——步骤默认值 + 动作录制器
需求:滑动要能"我自己录制";在自定义动作旁边加一个分栏,能配动作默认值、
能录制操作当动作、能自己建立动作。

**步骤默认值**(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 报错。
2026-09-20 14:32:35 +08:00

606 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 任务 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();
}