AI 控制台下新增子分栏「🧭 AI 建任务」:描述要做什么(例:建一个跑 2 小时的任务、自动刷
某 App、随机点赞),AI 用 de_* 工具自己在设备上探索(看屏/读元素树/点按验证),把走通的
路径写成一条任务草稿,经服务端校验后交人在步骤编辑器里核对/手改/试跑,保存才入库。
链路:POST /api/agent/run{mode:"designer", settings}
→ Agent 自探 → 本地工具 submit_task(draft)
→ core/task_draft 校验(失败把 errors 回灌模型让它改)
→ 只暂存(运行态 + app_meta.agent_task_draft,**不落库**)
→ SSE done{mode,draft,warnings} → 页面草稿预览 → openTaskModal(null, prefill) 预填
关键实现
- core/task_draft.py(新):把执行器的"静默跳过点"(未知 type/空 selector/空 children/
嵌套>5/节点>60/cron 非法/必填缺失)前移成显式 error——POST /api/jobs 对 params 是盲存的,
执行器又静默跳过错误步骤,没有这道闸门就是"任务建好了、跑起来什么都没做"。
归一化兜底任务名/target/schedule/retry/时长;页面填的设置以 overrides 优先于模型。
故意**不比执行器更严**:loop_mode 近义值归一(count→rounds)、缺 max_iterations 补默认 10
(执行器本来就默认)——实测卡太死会把一轮探索耗在改字段上。
有副作用的步骤(评论/发送/购买/删除…)只警告并把触发概率压到 30%(编辑器可改回)。
- mcp_agent/agent.py:双系统提示词(CHAT/DESIGNER)+ 平台级本地工具
(LOCAL_TOOL_SPECS,不进 MCP)+ 每工具调用上限 40 + designer 输出上限 8192 +
**json.loads 容错**(草稿被截断时给模型可读错误,而不是整轮失败)。
- web/agent_api.py:mode/settings 透传、submit_task 处理器(app_context 内校验+暂存)、
done 带 draft、GET /api/agent/task_draft{,+POST,/clear}(草稿走 app_meta,不新建表)。
- 前端:static/admin/taskgen.js + #agent-sub-taskgen 子面板(showSubTab 机制);
tasks.js 的 openTaskModal(jobId, prefill) + 信封归一化 + 唯一 draftKey;
agent.js 按 mode 门控(一个 run 只有一个事件队列,两个 EventSource 会互相瓜分事件)。
顺带修掉一个 chat 也踩的协议 bug:一轮里同时调 de_screenshot 与别的工具时,截图图像会被
插在两条 tool 消息之间 → 模型侧判"工具回应不足"直接 400。改为本轮 tool 消息发完再附图像,
_repair_tool_messages 也改成只数**连续**的 tool 消息。
真机实测(Redmi 22120RN86C,设置页):8 步探索(含 tap_text 验证)→ submit_task 一次通过 →
草稿 8 个顶层步骤(screen_on/open_app/wait_el/click/wait/key_event…)、max_duration 1800、
无 click_xy、3 条 evidence;页面恢复草稿 + 预填编辑器 + 提示块渲染均正常,无 JS 报错。
自测数据已清理(草稿已丢弃、未创建任何任务)。
文档:AI_TASK_GEN.md 状态改「P0 已实现」+ §10 实现记录(差异/护栏/未做项)、AI_CONSOLE.md
(子分栏、designer 分支、SSE done 负载、app_meta 键)、API.md、DATA_MODEL.md、ARCHITECTURE.md、
DEVELOPMENT.md(自测入口)、README.md 索引、backlog 勾掉 P0。
371 lines
22 KiB
JavaScript
371 lines
22 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)
|
|
};
|
|
}
|
|
|
|
// 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>';
|
|
|
|
// 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);
|
|
|
|
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';
|
|
}
|
|
|
|
// ================== 自定义动作管理 ==================
|
|
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');
|
|
}
|
|
|