// 任务 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:'暂无任务,点击"新建任务"创建' }); 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=''+schedTxt+''; // 下次运行时间(后端已按运行窗口跳过窗口外触发点) const nextRun=fmtNextRun(j.next_run); const nextOut=''+(nextRun||'-')+''; const retry=j.retry.max_attempts>1?(j.retry.max_attempts+'次/'+j.retry.delay+'s'):'不重试'; const en=j.enabled?'启用':'停用'; return ''+ ''+esc(j.id)+''+ ''+esc(j.name)+''+ ''+esc(j.task_type)+''+ ''+en+''+ ''+esc(tgt)+(tgtDetail?'('+esc(tgtDetail)+')':'')+''+ schedOut+ nextOut+ ''+retry+''+ ''+ (_can('tasks')?( ''+ ''+ ''+ '' ):'只读')+ ''+ ''; } // 任务信封归一化:补齐 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='
(没有巡检项)
'; 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 '';}).join(''); const actOpts=_PATROL_ACTIONS.map(function(a){ return '';}).join(''); const typeOpts=['xpath','description','text','resourceId','descriptionContains','className'] .map(function(t){return '';}).join(''); return '
' +'
' +'' +'' +'' +'
' +'
' +'
' +'
' +'
' +'
' +'
' +'
' +'
' +'
' +'
' +'
' +'
' +'
' +'' +'
' +'
' +'
' +'
占位符:{device} 设备名、{serial} 地址、{job} 任务名、{time} 时间、{app} 当前前台包名、' +'{screen} 亮屏/熄屏(后两个要查设备,不用就别写)。冷却期内重复命中不会重复动作/通知,防刷屏
' +'
'; } // 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=>'').join(''); const gl=_groupsList.map(g=>'').join(''); const dl=_devicesList.map(x=>'').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=> '').join(''); document.getElementById('modal-body').innerHTML= '
'+ '
'+ '
'+ '
'+ '
'+ '
'+ ''+ '
'+ ''+ '
'+ '
'+ ''+ '
'+ '
'+ ''+ ''+ '
'+ '
'+ ''+ ''+ '
'+ '
'+ '
'+ '
'+ ''+ '
'+ ''+ ''+ ''+ ''+ ''+ ''+ '
'+ '
'+ '
'+ '
'+ '
'+ '
'+ '
窗口只约束定时触发:窗口外定时不会启动,手动执行不受限制。跨午夜示例:21:00-09:00 = 晚 9 点到次日早 9 点允许运行
'+ '
'+ '
'+ '
'+ '
'+ '
可留空使用默认参数。抖音任务可设 max_duration(秒,如 1800=30分钟)限制单设备运行时长
'+ '
'+ '
步骤编辑器用步骤卡片上的 ☑ 选中多个步骤,可打包成自定义动作'+ (_can('tasks')?'':'')+ '
'+ '
'+ '
操作库
'+ '
'+ '
'+ '
'+ // 公共巡检:任务级配置,**不在步骤画布里**(穿插执行,见 core/patrol.py) '
'+ '
'+ '公共巡检可选:任务运行期间按间隔穿插检查,命中就动作 + 通知'+ ''+ '
'+ '
'+ '
和步骤的区别:步骤是"按顺序做完这一段",巡检是"全程都要盯着的条件"(比如熄屏就点亮、掉到桌面就通知)。'+ '检查穿插在步骤之间执行,不额外抢设备;某一步耗时很长时,巡检最多晚那一步的时间。
'+ '
'; // AI 草稿的提醒/复核项:贴在步骤编辑器上方,别让人漏看 if(pf&&((pf.notes&&pf.notes.length)||(pf.warnings&&pf.warnings.length))){ const se=document.getElementById('step-editor'); if(se) se.insertAdjacentHTML('beforebegin', '
AI 探索说明(请核对)' +(pf.notes||[]).map(n=>'
· '+esc(n)+'
').join('') +((pf.warnings||[]).length ? '
校验提醒' +pf.warnings.map(w=>'
· '+esc(w)+'
').join('')+'
' : '') +'
'); } // 初始化步骤编辑器(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= ''+ ''; 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:'暂无自定义动作,可点击"新建动作"或用任务编辑器☑选中步骤打包' }); applyListPager('actions'); initListSearch('actions','search-actions'); _stepEditor._renderLib(); // 刷新步骤编辑器左侧动作库 } function renderActionRow(a){ const n=(a.steps||[]).length; return ''+ ''+esc(a.icon||'📦')+''+ ''+esc(a.name)+''+ ''+n+''+ ''+esc(a.created_at||'-')+''+ ''+ (_can('tasks')?( ' '+ '' ):'只读')+ ''+ ''; } 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= '
'+ '
'+ '
'+ '
'+ '
用下方步骤编辑器编排动作内容,保存后可拖入任务步骤编辑器复用
'+ '
'+ '
动作步骤支持循环/分组/概率触发/拖拽排序'+ (_can('tasks')?'':'')+'
'+ '
'+ '
操作库
'+ '
'+ '
'+ '
'; document.getElementById('action-modal-footer').innerHTML= ''+ ''; // 用独立画布初始化步骤编辑器(不与任务编辑器冲突) _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'); }