// 任务 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||[]:[]; _devicesList=dr?dr.devices||[]:[]; 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')?( ''+ ''+ ''+ '' ):'只读')+ ''+ ''; } function openTaskModal(jobId){ const job=jobId?(_jobsData||[]).find(j=>j.id===jobId):null; const isEdit=!!job; document.getElementById('modal-title').textContent=isEdit?'编辑任务':'新建任务'; // 任务编辑器含步骤编辑,加宽弹窗;关闭时在 closeModal 移除 document.getElementById('modal-box').classList.add('wide-modal'); const j=job||{name:'',task_type:'douyin_nurture',enabled:true,target:{mode:'all'},params:{},schedule:{mode:'once'},retry:{max_attempts:1,delay:60}}; const tt=_taskTypes.map(t=>'').join(''); const gl=_groupsList.map(g=>'').join(''); const dl=_devicesList.map(s=>'').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')?'':'')+ '
'+ '
'+ '
操作库
'+ '
'+ '
'+ '
'; // 初始化步骤编辑器(generic_steps 时显示,其他隐藏);jobId 作为草稿 key _stepEditor.init(j.params&&j.params.steps?j.params.steps:[],j.params&&j.params.max_duration?j.params.max_duration:0,jobId||''); 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'; } // ================== 自定义动作管理 ================== 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'); }