Files
auto_control/static/admin/tasks.js
T
butubb 876224f876 feat(去重): 跨设备「已做过」账本 —— 同一个号不会做两次 + 「谁做过了」看得见
用户场景(他原话):一台手机登录 5 个抖音号、一共 5 台手机,每个任务只让其中一个
目标号评论;每天跑一次但不知道什么时候跑完,于是"一直重复跑" → 结果
"一个手机还没评论到,一个手机都评论两次了"。

**根因不是"单设备重复",是跨设备没有共享的判断 + 进度不可见。** 所以做两件事:
① 幂等;② 把"谁做过了、还差谁"摆到台面上(不然只能靠重跑确认,而重跑又在制造重复)。

- `core/models.py`:新表 `done_mark`(迁移账本补 v7)。**判据只有 `scope_key` 的
  唯一索引**——多台设备会同时判断"没做过","先查后插"有竞态(两台都插),
  唯一索引 + `INSERT ... ON DUPLICATE KEY`/`INSERT OR IGNORE` 的**受影响行数**才原子。
- `core/dedup.py`(新):`build_key`(`任务|身份|时间桶`)/ `check` / `mark` /
  `list_marks`(带"今天做了几台/几个号"统计)/ `delete_mark` / `clear_job` / `purge_old`。
  自建 app context(照 device_pool 的 `_ctx()`),任务线程/Web/清理都不用关心。
- 任务侧两个部件(**检查在前、记账在后**):
  · `if_el` 新增条件类型 `selector_type="dedup"`:命中=这个身份做过了 → 走 then 分支。
    身份元素在 `ident_type`/`ident_value`(留空 = 用设备 serial,一号一机场景)。
  · 新步骤 `mark_done`「记为已做」(22 种步骤):放动作**成功之后**。
  拆两步的用意:动作失败就不记账,下次重跑还会重试该设备 —— 失败不丢。
- 有效期(`dedup_reset` = day/all/hours)放**任务级**:检查与记账两处各填一份的话,
  填不一致就算出两个 key、去重会**静默失效**,所以强制只配一处(编辑器顶部下拉)。
- 三条防误伤规则(都有测试兜着):
  · 身份读不到 / 身份值过长 → **不去重、当没做过照常执行**。绝不能把"读不到"
    当成空身份——那会让所有设备共用一个 key、第一台记账后其余全被误判成"做过"。
  · `kind='all'`(只做一次)的记录**永不清理**(清了等于语义失效);清理只删 day/hours。
  · 去重的两个易错点在保存时直接告警:身份元素两边不一致、有检查没记账/有记账没检查。
- 「任务 → 去重记录」新子分栏(`static/admin/dedup.js`):统计行 + 明细表 +
  删单条(那个号重跑)/ 清空任务(整批重跑)。接口 3 个(GET/delete/clear,PERM_TASKS)。
- 每日 04:23 清理(挂现有 APScheduler),`TABLE_LABELS` 补中文名(备份覆盖自动派生)。
- AI 建任务草稿校验同步:`dedup` 走自己的规则(要 ident_value、xpath 前缀校验),
  没填身份元素只警告不拦(用设备当身份是合法用法);普通条件空选择器仍然拦。
- 文档:TASK_DEV §4.6(去重专章 + App 内检测的兜底配方与它的三个局限)、
  DATA_MODEL §2.9、API 三个接口、ARCHITECTURE(分层/装配/子分栏/JS 分工/清理)、
  DEPLOY §5.2(15 张表)、步骤数 21→22 全库同步。

自测:单元 + 集成 33 项(**含 8 线程抢同一个身份、恰好一个成功**的原子性断言,
以及"all 记录不被清理""身份读不到不去重""清了能重跑")、
**真机端到端**(cs1 上"检查→动作→记账"跑两遍:第二遍被拦、换 serial 的"另一台设备"
同样被拦、删记录后能重跑)、草稿校验 5 项、GET 冒烟 56 路由 0 个 500。

(注:本分支基于 feat/if-el-multi-value,因为它俩都要改 task.py 的 STEP_TYPES 与
editor.js 的 STEP_LIB 同一区域,分开从 dev 拉必然冲突——这份是超集,合一次两份都进。)
2026-09-24 10:55:29 +08:00

607 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,
null,null,{dedup_reset:(j.params&&j.params.dedup_reset)||'day',dedup_hours:(j.params&&j.params.dedup_hours)||6});
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();
}