Files
auto_control/static/admin/editor.js
T
butubb b98e6deac1 feat(发布计划): 视频发布计划(批量上传配对 → 时间线 → 推送到手机 → 发布任务 → 分享链接)
一、平台侧(账号 → 发布计划页)
- 新表 video_plan(schema v9→v10):账号×发布日期×编号 → 素材 + 标题 + 发布状态 + 分享链接;
  状态机 pending/ready/pushing/publishing/done/failed/unknown/skipped(**failed 与 unknown 必须分开**:
  推送阶段的失败可安全重试;碰过抖音之后的岔子只能算"结果未知",绝不自动重发)
- 素材上传:文件名 `手机号_日期_编号`(编号可省)解析配对;标题 txt `标题内容_手机号_日期_编号`;
  内容寻址落盘 data/videos/YYYY-MM/(sha1 分块算,同名不存两份),**不进整库备份**但进 manifest 反查
- 新蓝图 web/video_plan_api.py:上传/时间线/统计/单条增删改/推送到手机/标记结果/裁决/链接导出 CSV/
  任务列表与一键新建、**就地编辑**(GET/PUT /tasks/<id>)、**一键推送**(POST /push_all,按设备分组、设备内串行)
- 账号页拆子分栏(台账 / 发布计划)+ static/admin/release.js;清理 job(04:41 僵尸回收+过期行、04:47 素材文件)
- 上传体积:MAX_CONTENT_LENGTH(默认 2GiB)+ 413 JSON + nginx client_max_body_size(修现有 APK 上传隐患)

二、任务侧(平台推素材,抖音流程你自己写)
- 新步骤 push_release「推送发布视频」:原子占位 → adb push → **touch 改成"现在"** → 清旧目录同名副本 →
  触发扫描并**按路径**校验相册索引 → 标题写进剪贴板;默认目录 /sdcard/DCIM/Camera
- 新步骤 mark_release「标记发布结果」:回写 done/failed/unknown,成功时抓作品分享链接、删手机素材
- input_text 支持 text_source=release_title(自动取计划标题 + 回读校验);
  if_el 的候选值来源新增 release(**本机当前发布计划**的抖音号/昵称,发布前校验"登的是不是要发的号")
- build_release_steps 骨架 15 步:⓪ 亮屏 → ① 打开抖音(等首页) → ② 点「我」→ ③ 等抖音号出现 →
  ④ 条件判断(账号) → then ⑤ 推送 ⑥⑦⑧⑨⑩⑪⑫ 抖音点击/填标题 → ⑬ 标记 / else 发通知跳过

三、修(推送这一路的检测机制)
- **uiautomator2 3.x 的 d.shell() 返回 ShellResponse(tuple 子类)不是 str**:`'x' in resp` 恒 False、
  `.strip()` 不存在 → "推上去的文件大小不对"每次都判失败(文件其实推上去了)、相册校验永远报没进、
  删除确认永远判没删掉。新增 publish_flow._sh() 统一取 .output;大小改成解析 ls -l 的大小列
- **adb push 保留本地 mtime** → 推 3 天前上传的素材在按时间排序的相册里排不到最前,
  "点第一个 = 刚推的那个"不成立 → 推完 touch
- 相册校验**按路径**比(MediaStore 的 _data 会把目录小写、/storage/emulated/0 ≡ /sdcard),
  只比文件名会被老目录的同名残留骗过去
- 屏幕没亮就启动抖音会永远停在启动页(UI 树为空)→ 后面"点我/等抖音号"必然 miss,
  最后报成误导人的"账号不符" → 骨架第一步固定加「亮屏」,open_app 等「首页」出现

四、其它
- core/ledger.serial_of():设备名 → 当前地址(设备换 IP 后快照是错的)
- 通知事件 task.video.published / task.video.failed;备份清单加 video_plan 与素材统计
- 文档同步:DATA_MODEL §2.11 + schema v10、API(新接口与语义)、TASK_DEV §4.7 专章、
  ARCHITECTURE(账号页子分栏/release.js/两个 job)、DEPLOY(表数/nginx)、NOTIFY、DEVELOPMENT、README
2026-09-28 15:59:09 +08:00

1617 lines
94 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.
// 步骤编辑器:STEP_LIB/拖拽/参数表单/条件判断/元素抓取/测试此步骤
// ================== 步骤编辑器 ==================
var STEP_LIB=[
// cat:interact=交互操作 / screen=屏幕与App / flow=流程控制(操作库按此分组展示)
{type:'click',label:'点击元素',icon:'✦',cat:'interact',params:{selector_type:'xpath',selector_value:'',wait_timeout:2}},
{type:'click_xy',label:'点击坐标',icon:'🎯',cat:'interact',params:{x:50,y:50}},
{type:'long_click',label:'长按元素',icon:'👆',cat:'interact',params:{selector_type:'xpath',selector_value:'',duration:1.0,wait_timeout:2}},
{type:'swipe',label:'滑动',icon:'↕',cat:'interact',params:{direction:'up',duration_min:0.25,duration_max:0.50,distance_ratio:0.6,jitter:0.15,humanize:true}},
{type:'swipe_until',label:'滑动直到元素',icon:'🔍',cat:'interact',params:{selector_type:'xpath',selector_value:'',direction:'up',max_swipes:8,click_when_found:true,duration_min:0.25,duration_max:0.50,distance_ratio:0.6,jitter:0.15,humanize:true}},
{type:'input_text',label:'输入文字',icon:'⌨',cat:'interact',params:{mode:'random',texts:'你好\n有趣\n支持',fixed_text:'',clear_first:true,text_source:'manual',release_topics:''}},
{type:'clipboard',label:'剪贴板注入',icon:'📋',cat:'interact',params:{text:'',paste:true}},
{type:'key_event',label:'按键',icon:'⌨️',cat:'interact',params:{key:'back'}},
{type:'open_app',label:'打开App',icon:'▶',cat:'screen',params:{package:'',wait_home:false,home_feature:''}},
{type:'stop_app',label:'结束App',icon:'⏹',cat:'screen',params:{package:''}},
{type:'screen_on',label:'亮屏',icon:'💡',cat:'screen',params:{}},
{type:'screen_off',label:'息屏',icon:'🌙',cat:'screen',params:{}},
{type:'keep_screen',label:'保持亮屏',icon:'🔆',cat:'screen',params:{mode:'on'}},
{type:'wait',label:'等待',icon:'⏱',cat:'flow',params:{min:1.0,max:3.0}},
{type:'wait_el',label:'等待元素',icon:'⏳',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:10}},
{type:'loop',label:'循环块',icon:'↻',cat:'flow',params:{loop_mode:'rounds',max_iterations:10,loop_duration:600,children:[]}},
{type:'group',label:'动作组',icon:'📦',cat:'flow',params:{children:[]}},
{type:'if_el',label:'条件判断',icon:'❓',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:3,ocr_click:false,cmp_op:'',cmp_value:'',cmp_source:'',cmp_group:'',ident_type:'text',ident_value:'',then:[],else:[]}},
{type:'mark_done',label:'记为已做',icon:'✅',cat:'flow',params:{selector_type:'text',selector_value:''}},
{type:'notify',label:'发通知',icon:'🔔',cat:'flow',params:{title:'',message:'',level:'info'}},
{type:'stop_self',label:'停止本设备',icon:'⛔',cat:'flow',params:{reason:''}},
{type:'gesture',label:'录制手势',icon:'⏺',cat:'interact',params:{points:[],speed:1.0}}
];
// 步骤默认值(「任务 → 动作配置」页配置,见 core/step_defaults.py);新建步骤时预填
let _stepDefaults = null;
// 去重有效期选了「按小时」才显示小时数输入框
function _toggleDedupHours(){
var sel=document.getElementById('f-dedup_reset');
var wrap=document.getElementById('f-dedup_hours_wrap');
if(sel&&wrap)wrap.style.display=(sel.value==='hours')?'inline-flex':'none';
}
var STEP_LIB_CATS=[['interact','交互操作'],['screen','屏幕与App'],['flow','流程控制']];
var _customActions=[]; // 后端加载的自定义动作
var _stepIdCounter=0;
function _newStepId(){_stepIdCounter++;return 'step_'+Date.now().toString(36)+'_'+_stepIdCounter;}
// 步骤的所有子步骤数组(loop/group 的 children、if_el 的 then/else 分支)
function _childArrs(s){
var arrs=[];
if(s&&s.params){
if(Array.isArray(s.params.children))arrs.push(s.params.children);
if(Array.isArray(s.params.then))arrs.push(s.params.then);
if(Array.isArray(s.params.else))arrs.push(s.params.else);
}
return arrs;
}
// 递归去掉步骤 id(保存到自定义动作库/拖入画布时重新生成,避免重复拖入后 id 冲突导致多选错乱)
function _stripIds(steps){
steps.forEach(function(s){
delete s.id;
_childArrs(s).forEach(_stripIds);
});
}
function _findLib(type){return STEP_LIB.find(function(s){return s.type===type;});}
// 选择器组件(类型下拉 + 值输入 + 抓取元素 + 测试按钮),click/long_click/swipe_until/wait_el/if_el 复用
// withOcr=true 时额外提供 OCR识别 选项(图片/画布里的文字,仅条件判断支持)
// withDedup=true 时额外提供「去重」条件类型(身份做过了吗,见 core/dedup.py)
// keys 可换字段名(默认 selector_type/selector_value)——「去重」的身份元素用
// ident_type/ident_value,好让同一个条件里既能有主选择器又能有身份元素
function _selRowHtml(p, extra, withOcr, withDedup, keys){
keys=keys||{};
var kType=keys.type||'selector_type', kVal=keys.value||'selector_value';
var typeList=['xpath','description','text','resourceId','descriptionContains','className'];
// withOcr(条件判断用)额外提供"屏幕状态 / 前台App / 去重"三类非元素条件
var types=withOcr?typeList.concat(['ocr','screen','foreground']):typeList.slice();
if(withDedup)types.push('dedup');
var labels={xpath:'xpath',description:'description',text:'text',resourceId:'resourceId',
descriptionContains:'descriptionContains',className:'className',ocr:'OCR识别',
screen:'屏幕状态',foreground:'前台App是',dedup:'去重(这个身份做过了吗)'};
var opts=types.map(function(d){
return '<option value="'+d+'"'+(p[kType]===d?' selected':'')+'>'+labels[d]+'</option>';}).join('');
var toggle=withOcr?' onchange="_stepEditor._condTypeChanged(this)"':'';
var pick=_can('devices')?'<button class="btn btn-xs btn-pick" onclick="_stepEditor.openPicker(this)">抓取元素</button>':'';
var test=_can('devices')?'<button class="btn btn-xs" onclick="_stepEditor._testStep(this)" title="在设备上试执行本步骤,验证选择器是否命中">测试</button>':'';
var isScreen=p[kType]==='screen', isFg=p[kType]==='foreground';
var valLabel=isScreen?'屏幕状态':(isFg?'前台包名':'选择器值');
var valHtml;
if(isScreen){
valHtml='<select class="form-control" data-key="'+kVal+'" style="max-width:140px">'+
'<option value="off"'+(p[kVal]!=='on'?' selected':'')+'>熄屏时就命中</option>'+
'<option value="on"'+(p[kVal]==='on'?' selected':'')+'>亮屏时就命中</option></select>';
}else{
valHtml='<input type="text" class="form-control" data-key="'+kVal+'" value="'+esc(p[kVal]||'')+
'" placeholder="'+(isFg?'如 com.ss.android.ugc.aweme':'如 点赞')+'">'+(isFg?'':pick+test);
}
var head='<div class="form-group"'+(keys.hideValue?' style="grid-column:span 3"':'')+'>'+
'<label>选择器类型</label><select class="form-control" data-key="'+kType+'"'+toggle+'>'+opts+'</select></div>';
// hideValue:「去重」条件不需要 selector_value(身份元素在 ident_value 里),
// 与其显示一个没人用的输入框,不如只留类型下拉
if(keys.hideValue)return '<div class="form-row">'+head+'</div>'+(extra||'');
return '<div class="form-row">'+head+
'<div class="form-group" style="grid-column:span 2"><label>'+valLabel+'</label><div class="sel-picker">'+
valHtml+'</div></div></div>'+(extra||'');
}
// 加载自定义动作库
function _loadCustomActions(){
fetch('/api/custom_actions').then(function(r){return r.json();}).then(function(res){
if(res.ok)_customActions=res.actions||[];
if(typeof _stepEditor!=='undefined')_stepEditor._renderLib();
}).catch(function(){});
}
// 「拟人」开关 + 抖动幅度(滑动类步骤共用;见 core/humanize.py)
function _humanizeChkHtml(p){
return '<div class="form-row"><div class="form-group form-check">'
+'<input type="checkbox" data-key="humanize" '+(p.humanize!==false?'checked':'')+'>'
+'<label>拟人轨迹(弧线)</label></div>'
+'<div class="form-group"><label>抖动幅度</label><input type="number" class="form-control" data-key="jitter" value="'
+(p.jitter!=null?p.jitter:0.15)+'" step="0.05" min="0" max="0.4"></div></div>';
}
function _humanizeRowHtml(p,extra){
return extra+_humanizeChkHtml(p)
+'<div class="help">拟人:滑动走弧线,起止点/幅度/时长每次都抖一点,并按本设备的手速与幅度偏好执行'
+'(每台设备风格稳定不同,批量跑时不像同步机器人)。关掉=还原成正中直线;'
+'抖动填 0=位置/幅度不抖(时长仍在你填的范围内随机)</div>';
}
var _stepEditor={
_steps:[],
_selected:[],
_dragSuppressClick:false,
init:function(steps,maxDuration,draftKey,canvasId,libId,taskExtra){
this._draftKey=draftKey||'new';
this._selected=[];
this._updatePackBtns();
this._dragSuppressClick=false;
// 可指定编辑容器(任务编辑器 vs 动作编辑器),默认任务编辑器
this._canvas=document.getElementById(canvasId||'se-canvas');
this._lib=document.getElementById(libId||'se-lib');
// 嵌套结构数组注册表:arr 唯一 id → 数组引用(修复多层级循环内移动错位)
this._arrCounter=0;
this._arrById={};
this._arrIdMap=new WeakMap();
// 恢复上次未保存的草稿(实时保存);没有则用传入的步骤
var draft=this._loadDraft();
if(draft&&Array.isArray(draft)){
this._steps=draft;
}else{
this._steps=steps.length?JSON.parse(JSON.stringify(steps)):[];
}
this._ensureIds(this._steps);
this._renderLib();
this._renderCanvas();
_loadCustomActions();
// 实时同步:画布内任何输入(含下拉/勾选)都同步回 _steps 并自动保存
var canvas=this._canvas;
if(canvas&&!canvas.dataset.seBound){
canvas.dataset.seBound='1';
var self=this;
canvas.addEventListener('input',function(){self._syncFromDom();});
}
if(draft&&Array.isArray(draft)){
showToast('已恢复上次未保存的步骤编辑','success');
}
// max_duration 输入框(只在任务编辑器注入,动作编辑不需要)
var head=document.querySelector('#step-editor .se-head');
if(head&&!document.getElementById('f-max_duration')){
var md=document.createElement('span');
md.style.cssText='font-size:11px;display:flex;align-items:center;gap:4px';
md.innerHTML='运行时长上限(秒,0=不限) <input type="number" id="f-max_duration" value="'+(maxDuration||0)+'" min="0" style="width:70px;padding:2px 6px;border:1px solid var(--border);border-radius:4px;font-size:12px">';
head.appendChild(md);
}
// 去重有效期(任务级):**只在这一处配**——「条件判断→去重」和「记为已做」两处
// 各填一份的话,填不一致就会算出两个不同的 key、去重静默失效,所以强制共用。
var drHost=document.querySelector('#step-editor .se-head');
if(drHost&&!document.getElementById('f-dedup_reset')){
var ex=taskExtra||{};
var dr=document.createElement('span');
dr.style.cssText='font-size:11px;display:flex;align-items:center;gap:4px';
var selStyle='padding:2px 6px;border:1px solid var(--border);border-radius:4px;font-size:12px';
dr.innerHTML='去重有效期 <select id="f-dedup_reset" style="'+selStyle+'">'+
'<option value="day">每天一次</option>'+
'<option value="all">只做一次</option>'+
'<option value="hours">按小时</option></select>'+
'<span id="f-dedup_hours_wrap" style="display:none;align-items:center;gap:3px">每 '+
'<input type="number" id="f-dedup_hours" value="'+(ex.dedup_hours||6)+'" min="1" max="168" style="width:52px;'+selStyle+'"> 小时</span>';
drHost.appendChild(dr);
document.getElementById('f-dedup_reset').value=ex.dedup_reset||'day';
_toggleDedupHours();
document.getElementById('f-dedup_reset').addEventListener('change',_toggleDedupHours);
}
},
_ensureIds:function(steps){
var self=this;
steps.forEach(function(s){
if(!s.id)s.id=_newStepId();
_childArrs(s).forEach(function(a){self._ensureIds(a);});
});
},
_renderLib:function(){
var lib=this._lib;
if(!lib)return; // 未初始化编辑器(如仅加载动作列表时)跳过
// 按分类分组展示(STEP_LIB_CATS 定义分类顺序)
var items=STEP_LIB_CATS.map(function(c){
var list=STEP_LIB.filter(function(s){return s.cat===c[0];}).map(function(s){
return '<div class="lib-item" draggable="true" data-type="'+s.type+'" title="'+esc(s.label)+'">'+
'<span class="ico">'+s.icon+'</span><span>'+s.label+'</span></div>';
}).join('');
return '<div class="lib-section"><div class="lib-title">'+c[1]+'</div>'+list+'</div>';
}).join('');
// 自定义动作区
var customHtml='';
if(_customActions.length){
customHtml='<div class="lib-section"><div class="lib-title">自定义动作</div>';
customHtml+=_customActions.map(function(a){
return '<div class="lib-item custom" draggable="true" data-action-id="'+a.id+'">'+
'<span class="ico">'+(a.icon||'📦')+'</span><span>'+esc(a.name)+'</span>'+
'<button class="del-btn" title="删除" onclick="event.stopPropagation();_stepEditor.delCustomAction(\''+a.id+'\')">✕</button></div>';
}).join('');
customHtml+='</div>';
}
lib.innerHTML=items+customHtml;
var self=this;
// 绑定拖拽 + 点击(系统步骤)—— 用 _dragSuppressClick 防止拖拽后重复触发 click
lib.querySelectorAll('.lib-item[data-type]').forEach(function(item){
var type=item.dataset.type;
item.addEventListener('dragstart',function(e){
self._dragSuppressClick=true;
e.dataTransfer.setData('text/plain','type:'+type);
e.dataTransfer.effectAllowed='copy';
});
item.addEventListener('click',function(e){
if(self._dragSuppressClick){self._dragSuppressClick=false;return;}
self.addStep(type);
});
});
// 绑定拖拽 + 点击(自定义动作)
lib.querySelectorAll('.lib-item[data-action-id]').forEach(function(item){
var actionId=item.dataset.actionId;
item.addEventListener('dragstart',function(e){
self._dragSuppressClick=true;
e.dataTransfer.setData('text/plain','action:'+actionId);
e.dataTransfer.effectAllowed='copy';
});
item.addEventListener('click',function(e){
if(e.target.classList.contains('del-btn'))return;
if(self._dragSuppressClick){self._dragSuppressClick=false;return;}
self.addCustomAction(actionId);
});
});
},
_renderCanvas:function(){
if(!this._canvas)return;
this._renderStepList(this._canvas,this._steps);
},
_renderStepList:function(container,steps){
var self=this;
if(!steps.length){
container.innerHTML='<div class="se-empty">拖拽左侧操作到这里<div class="hint">或点击操作库直接添加</div></div>';
this._bindDropZone(container,steps); // 空时也要能接收拖入
return;
}
container.innerHTML='';
steps.forEach(function(step,idx){
container.appendChild(self._renderCard(step,idx,steps));
});
this._bindDropZone(container,steps);
},
_renderCard:function(step,idx,parentArr){
var self=this;
var lib=_findLib(step.type)||{icon:'?',label:step.type};
var card=document.createElement('div');
card.className='step-card'+(step.type==='loop'?' loop-block':'')+(step.type==='group'?' group-block':'')+(step.type==='if_el'?' if-block':'');
card.draggable=true;
card.dataset.idx=idx;
card.dataset.stepId=step.id||'';
card.dataset.stepType=step.type;
if(this._selected&&this._selected.indexOf(step.id)>=0)card.classList.add('selected');
// head
var head=document.createElement('div');
head.className='sc-head';
head.innerHTML='<span class="sc-ico">'+lib.icon+'</span>'+
'<span class="sc-label">'+esc(step.label||lib.label)+'</span>'+
'<span class="sc-type">'+step.type+'</span>'+
'<span class="sc-actions">'+
'<button title="选中/取消选中(可多选后打包)" onclick="event.stopPropagation();_stepEditor.toggleSelect(\''+(step.id||'')+'\')">☑</button>'+
'<button title="展开/收起参数" onclick="_stepEditor.toggleExpand(this)">⚙</button>'+
'<button title="上移" onclick="_stepEditor.move('+idx+',-1,\''+this._arrId(parentArr)+'\')">↑</button>'+
'<button title="下移" onclick="_stepEditor.move('+idx+',1,\''+this._arrId(parentArr)+'\')">↓</button>'+
'<button title="复制" onclick="_stepEditor.duplicate('+idx+',\''+this._arrId(parentArr)+'\')">⧉</button>'+
'<button title="删除" onclick="_stepEditor.remove('+idx+',\''+this._arrId(parentArr)+'\')">✕</button>'+
'</span>';
head.addEventListener('click',function(e){
if(e.target.tagName==='BUTTON')return;
self.toggleExpand(head.querySelector('button'));
});
card.appendChild(head);
// body(参数表单)
var body=document.createElement('div');
body.className='sc-body';
body.innerHTML=this._renderParams(step);
card.appendChild(body);
// 拖拽排序
card.addEventListener('dragstart',function(e){
e.dataTransfer.setData('text/plain','move:'+idx+':'+self._arrId(parentArr));
e.dataTransfer.effectAllowed='move';
card.classList.add('dragging');
});
card.addEventListener('dragend',function(){card.classList.remove('dragging');});
card.addEventListener('dragover',function(e){
e.preventDefault();
card.classList.add('drag-over');
});
card.addEventListener('dragleave',function(){card.classList.remove('drag-over');});
card.addEventListener('drop',function(e){
e.preventDefault();
e.stopPropagation(); // 关键:阻止冒泡到外层卡片/容器,避免重复添加
card.classList.remove('drag-over');
self._handleDrop(e,parentArr,idx);
});
// loop/group 子步骤
if(step.type==='loop'||step.type==='group'){
if(step.params&&step.params.children){
var childWrap=document.createElement('div');
childWrap.className='step-children';
this._renderStepList(childWrap,step.params.children);
card.appendChild(childWrap);
}
}
// if_el 双分支:找到时(then) / 未找到时(else),各自独立容器接收拖入
if(step.type==='if_el'&&step.params){
[['then','✅ 找到时执行','if-then'],['else','❌ 未找到时执行','if-else']].forEach(function(b){
var branch=document.createElement('div');
branch.className='if-branch';
var title=document.createElement('div');
title.className='branch-title '+b[0];
title.textContent=b[1];
var wrap=document.createElement('div');
wrap.className='step-children '+b[2];
self._renderStepList(wrap,step.params[b[0]]||[]);
branch.appendChild(title);
branch.appendChild(wrap);
card.appendChild(branch);
});
}
return card;
},
_path:function(arr){
// 用数组长度作为标识(顶层是 _steps,子层是某 loop 的 children)
// 这里简化:顶层 path='root',子层 path='loop:<idx>'
return arr===this._steps?'root':'child';
},
_renderParams:function(step){
var p=step.params||{};
var h='';
if(step.type==='open_app'){
h+='<div class="form-group"><label>包名</label><input type="text" class="form-control" data-key="package" value="'+esc(p.package||'')+'" placeholder="如 com.ss.android.ugc.aweme"></div>';
h+='<div class="form-row"><div class="form-group form-check"><input type="checkbox" data-key="wait_home" '+(p.wait_home?'checked':'')+'><label>等待进入首页</label></div>';
h+='<div class="form-group"><label>首页特征词</label><input type="text" class="form-control" data-key="home_feature" value="'+esc(p.home_feature||'')+'" placeholder="如 拍摄"></div></div>';
}else if(step.type==='stop_app'){
h+='<div class="form-group"><label>包名</label><input type="text" class="form-control" data-key="package" value="'+esc(p.package||'')+'" placeholder="如 com.ss.android.ugc.aweme"></div>';
h+='<div class="help">强制结束该 App(am force-stop),清除后台进程,下次打开为冷启动。常配合"打开App"使用:结束→重新打开</div>';
}else if(step.type==='screen_on'){
h+='<div class="help">亮屏(息屏时唤醒并滑动解锁)。任务执行前用——息屏状态下 u2 无法点击操作</div>';
}else if(step.type==='screen_off'){
h+='<div class="help">息屏。任务结束后用,避免长时间亮屏导致烧屏/发热</div>';
}else if(step.type==='keep_screen'){
h+='<div class="form-group"><label>模式</label><select class="form-control" data-key="mode">'+
'<option value="on"'+(p.mode!=='off'?' selected':'')+'>保持亮屏(把系统息屏超时顶到最大,不充电也不会黑屏)</option>'+
'<option value="off"'+(p.mode==='off'?' selected':'')+'>恢复自动息屏</option></select></div>';
h+='<div class="help">长任务(如养号看视频几小时)放"保持亮屏"防屏幕超时熄灭;任务结束前放"恢复自动息屏"</div>';
}else if(step.type==='swipe'){
h+='<div class="form-row"><div class="form-group"><label>方向</label><select class="form-control" data-key="direction">'+
['up','down','left','right'].map(function(d){return '<option value="'+d+'"'+(p.direction===d?' selected':'')+'>'+d+'</option>';}).join('')+'</select></div>';
h+='<div class="form-group"><label>最短时长(秒)</label><input type="number" class="form-control" data-key="duration_min" value="'+(p.duration_min||0.25)+'" step="0.05" min="0.05"></div>';
h+='<div class="form-group"><label>最长时长(秒)</label><input type="number" class="form-control" data-key="duration_max" value="'+(p.duration_max||0.50)+'" step="0.05" min="0.05"></div></div>';
h+=_humanizeRowHtml(p,'<div class="form-group"><label>幅度(屏占比)</label><input type="number" class="form-control" data-key="distance_ratio" value="'+(p.distance_ratio!=null?p.distance_ratio:0.6)+'" step="0.05" min="0.1" max="0.9"></div>');
h+='<div class="help">想要"照我划的那一下原样重放",请用操作库里的《录制手势》步骤——那个是纯轨迹回放,不套这里的参数</div>';
}else if(step.type==='click'){
h+=_selRowHtml(p,'<div class="form-group"><label>等待超时(秒)</label><input type="number" class="form-control" data-key="wait_timeout" value="'+(p.wait_timeout||2)+'" step="0.5" min="0.5"></div>');
}else if(step.type==='long_click'){
h+=_selRowHtml(p,'<div class="form-row"><div class="form-group"><label>长按时长(秒)</label><input type="number" class="form-control" data-key="duration" value="'+(p.duration||1.0)+'" step="0.5" min="0.3"></div>'+
'<div class="form-group"><label>等待超时(秒)</label><input type="number" class="form-control" data-key="wait_timeout" value="'+(p.wait_timeout||2)+'" step="0.5" min="0.5"></div></div>');
}else if(step.type==='swipe_until'){
h+=_selRowHtml(p,'<div class="form-row"><div class="form-group"><label>方向</label><select class="form-control" data-key="direction">'+
'<option value="up"'+(p.direction!=='down'?' selected':'')+'>上滑</option>'+
'<option value="down"'+(p.direction==='down'?' selected':'')+'>下滑</option></select></div>'+
'<div class="form-group"><label>最多滑动次数</label><input type="number" class="form-control" data-key="max_swipes" value="'+(p.max_swipes||8)+'" min="1" max="50"></div>'+
'<div class="form-group"><label>幅度(屏占比)</label><input type="number" class="form-control" data-key="distance_ratio" value="'+(p.distance_ratio!=null?p.distance_ratio:0.6)+'" step="0.05" min="0.1" max="0.9"></div></div>'+
'<div class="form-group form-check"><input type="checkbox" data-key="click_when_found" '+(p.click_when_found!==false?'checked':'')+'><label>找到后点击</label></div>'+
_humanizeChkHtml(p)+
'<div class="help">上滑/下滑直到目标元素出现(每次滑动后检查一次),找不到则跳过继续。滑动同样走拟人轨迹</div>');
}else if(step.type==='wait_el'){
h+=_selRowHtml(p,'<div class="form-group"><label>最长等待(秒)</label><input type="number" class="form-control" data-key="timeout" value="'+(p.timeout||10)+'" min="1" max="120"></div>'+
'<div class="help">等待目标元素出现(替代固定秒数等待),超时则继续</div>');
}else if(step.type==='click_xy'){
h+='<div class="form-row"><div class="form-group"><label>X(屏幕% )</label><input type="number" class="form-control" data-key="x" value="'+(p.x||50)+'" min="0" max="100"></div>'+
'<div class="form-group"><label>Y(屏幕% )</label><input type="number" class="form-control" data-key="y" value="'+(p.y||50)+'" min="0" max="100"></div></div>'+
'<div class="help">屏幕百分比坐标,50/50=屏幕中心。无选择器时的兜底点击</div>';
}else if(step.type==='key_event'){
h+='<div class="form-group"><label>按键</label><select class="form-control" data-key="key">'+
[['back','返回键'],['home','Home键'],['enter','回车键'],['menu','菜单键'],['recent','最近任务'],['delete','删除键']].map(function(k){
return '<option value="'+k[0]+'"'+(p.key===k[0]?' selected':'')+'>'+k[1]+'</option>';
}).join('')+'</select></div>';
h+='<div class="help">返回键用于退出评论/返回上一页,比结束App轻量</div>';
}else if(step.type==='notify'){
h+='<div class="form-group"><label>标题</label><input type="text" class="form-control" data-key="title" value="'+esc(p.title||'')+'" placeholder="如 抖音号1 掉线了(留空用任务通知)"></div>';
h+='<div class="form-group"><label>内容</label><textarea class="form-control" data-key="message" rows="2" style="min-height:52px">'+esc(p.message||'')+'</textarea></div>';
h+='<div class="form-row"><div class="form-group"><label>级别</label><select class="form-control" data-key="level">'+
[['info','ℹ️ 普通'],['success','✅ 正常'],['warning','⚠️ 警告'],['error','❌ 异常']].map(function(l){
return '<option value="'+l[0]+'"'+((p.level||'info')===l[0]?' selected':'')+'>'+l[1]+'</option>';}).join('')+
'</select></div></div>';
h+='<div class="help">推送到哪条群取决于 webhook 自己的<b>事件订阅</b>(勾了「任务自定义通知」的才收)。'
+'正文可用占位符:<b>{device}</b> 设备名、<b>{serial}</b> 地址、<b>{job}</b> 任务名、<b>{time}</b> 时间、'
+'<b>{app}</b> 当前前台包名、<b>{screen}</b> 亮屏/熄屏(后两个要查设备,不用就别写)</div>';
}else if(step.type==='stop_self'){
h+='<div class="form-group"><label>停止原因(可留空)</label><input type="text" class="form-control" data-key="reason" value="'+esc(p.reason||'')+'" placeholder="如 连续刷到广告"></div>';
h+='<div class="help">只停<b>本设备</b>的任务,其它设备照常;后续步骤不再执行,任务记为「被停止」而不是失败'
+'(不会触发失败重试)。常配合「条件判断」用:命中条件 → 先「发通知」再「停止本设备」</div>';
}else if(step.type==='gesture'){
var pts=(p.points||[]);
var dur=pts.length?pts[pts.length-1][2]:0;
h+='<div class="form-row"><div class="form-group" style="flex:2"><label>录制状态</label>'+
'<div style="padding:6px 0;font-size:13px">'+(pts.length
? (pts.length+' 个轨迹点 · 时长 '+(dur/1000).toFixed(2)+' 秒 · 起点 ('+pts[0][0]+','+pts[0][1]+')')
: '<span class="text-muted">还没有录制</span>')+'</div></div>';
if(_can('devices')){
h+='<div class="form-group"><button class="btn btn-sm btn-primary" onclick="_stepEditor.recordInto(this,\'phone\')">● 手机上录</button></div>';
h+='<div class="form-group"><button class="btn btn-sm" onclick="_stepEditor.recordInto(this,\'web\')">● 网页上录</button></div>';
}
h+='<div class="form-group"><label>速度</label><input type="number" class="form-control" data-key="speed" value="'+(p.speed!=null?p.speed:1.0)+'" step="0.1" min="0.1" max="5"></div>';
h+='<div class="form-group"><button class="btn btn-sm btn-danger" onclick="_stepEditor.clearGesture(this)">清空</button></div></div>';
h+='<div class="help">这是<b>纯录制回放</b>:路径与时长照录制原样重放,不做弧线/抖动/手速那套加工(和「滑动」步骤是两回事)。'
+'<b>手机上录</b>=用手指在真机上划(最真实);<b>网页上录</b>=在弹窗画面里按住鼠标拖。'
+'轨迹按屏幕绝对像素存,换分辨率不同的设备可能偏,同型号/同分辨率最稳</div>';
}else if(step.type==='wait'){
h+='<div class="form-row"><div class="form-group"><label>最短(秒)</label><input type="number" class="form-control" data-key="min" value="'+(p.min||1.0)+'" step="0.5" min="0.1"></div>';
h+='<div class="form-group"><label>最长(秒)</label><input type="number" class="form-control" data-key="max" value="'+(p.max||3.0)+'" step="0.5" min="0.1"></div></div>';
h+='<div class="form-group form-check"><input type="checkbox" data-key="vary_pace" '+(p.vary_pace?'checked':'')+'><label>按设备节奏微调(0.8~1.35 倍)</label></div>';
h+='<div class="help">勾上后每台设备按自己的节奏缩放这个等待:批量跑时设备之间会逐渐错开,不再整齐划一。代价是实际时长不再严格等于上面填的值</div>';
}else if(step.type==='clipboard'){
h+='<div class="form-group"><label>注入内容</label><textarea class="form-control" data-key="text" rows="3" style="font-family:monospace;min-height:70px">'+esc(p.text||'')+'</textarea></div>';
h+='<div class="form-group form-check"><input type="checkbox" data-key="paste" '+(p.paste!==false?'checked':'')+'><label>注入后立即粘贴(需输入框已聚焦)</label></div>';
h+='<div class="help">批量发链接场景:先"点击元素"聚焦评论区输入框,再执行本步骤注入+粘贴;循环里依次更换注入内容即可逐个发</div>';
}else if(step.type==='input_text'){
const im=p.mode==='fixed'?'fixed':'random';
const tsrc=p.text_source||'manual';
const fromPlan=(tsrc!=='manual');
// 取值来源:默认手填(行为与以前一致);也可以直接吃「发布计划」里那一条的标题
h+='<div class="form-group"><label>取值来源</label><select class="form-control" data-key="text_source" onchange="_stepEditor._inputSourceChanged(this)">'+
[['manual','手填 / 随机候选(默认)'],['release_title','发布计划这一条的标题'],['release_title_topics','发布计划标题 + 话题']]
.map(function(o){return '<option value="'+o[0]+'"'+(tsrc===o[0]?' selected':'')+'>'+o[1]+'</option>';}).join('')+
'</select><div class="help">选"发布计划"时,标题由前面的「推送发布视频」那一步取出来(同一次运行里传递;没取到会跳过并告警)—— 你就不用把同一句文案再抄一遍了</div></div>';
if(fromPlan){
h+='<div class="form-group" data-key-wrap="release_topics" style="display:'+(tsrc==='release_title_topics'?'block':'none')+'"><label>话题(逗号分隔,不带 # 会自动补)</label><input type="text" class="form-control" data-key="release_topics" value="'+esc(p.release_topics||'')+'" placeholder="如 苏州,日常"></div>';
h+='<div class="help" style="color:#f59e0b">取计划标题时<b>会回读输入框校验</b>:抖音的输入框有时不吃键盘输入,不校验就可能发出一篇空文案;校验不过会自动改走剪贴板粘贴再验一次</div>';
}
// 手填那两块:**只隐藏不删**(_syncParams 按 DOM 存在与否收集,删了会把用户填的值抹掉)
h+='<div class="form-group" style="display:'+(fromPlan?'none':'block')+'"><label>输入模式</label><select class="form-control" data-key="mode" onchange="_stepEditor._toggleInputMode(this)">'+
'<option value="random"'+(im==='random'?' selected':'')+'>随机候选</option>'+
'<option value="fixed"'+(im==='fixed'?' selected':'')+'>指定文字</option></select></div>';
h+='<div class="form-group" data-mode="random" style="display:'+((!fromPlan&&im==='random')?'block':'none')+'"><label>候选文字(每行一个,随机选一条)</label><textarea class="form-control" data-key="texts" rows="4" style="font-family:monospace;min-height:80px">'+esc(p.texts||'你好\n有趣\n支持')+'</textarea></div>';
h+='<div class="form-group" data-mode="fixed" style="display:'+((!fromPlan&&im==='fixed')?'block':'none')+'"><label>指定文字</label><input type="text" class="form-control" data-key="fixed_text" value="'+esc(p.fixed_text||'')+'"></div>';
h+='<div class="form-group form-check"><input type="checkbox" data-key="clear_first" '+(p.clear_first!==false?'checked':'')+'><label>输入前先清空</label></div>';
h+='<div class="help">需先用"点击元素"步骤定位到输入框,本步骤只负责输入文字</div>';
}else if(step.type==='loop'){
const lm=p.loop_mode==='time'?'time':(p.loop_mode==='forever'?'forever':'rounds');
h+='<div class="form-row"><div class="form-group"><label>循环方式</label><select class="form-control" data-key="loop_mode" onchange="_stepEditor._toggleLoopMode(this)">'+
'<option value="rounds"'+(lm==='rounds'?' selected':'')+'>按轮次</option>'+
'<option value="time"'+(lm==='time'?' selected':'')+'>按时间</option>'+
'<option value="forever"'+(lm==='forever'?' selected':'')+'>一直循环(直到任务停止)</option></select></div>'+
'<div class="form-group" data-lmode="rounds" style="display:'+(lm==='rounds'?'block':'none')+'"><label>最大循环次数</label><input type="number" class="form-control" data-key="max_iterations" value="'+(p.max_iterations||10)+'" min="1" max="100000"></div>'+
'<div class="form-group" data-lmode="time" style="display:'+(lm==='time'?'block':'none')+'"><label>循环时长(秒)</label><input type="number" class="form-control" data-key="loop_duration" value="'+(p.loop_duration||600)+'" min="1"></div></div>';
h+='<div class="help">将下方子步骤循环执行指定轮次/时长;<b>一直循环</b>无限跑直到任务被外部停止——配合调度"定时启动+停止"(如 9:00 启动 / 18:00 停止)实现全天候养号,或配合任务"运行时长上限"自动停止</div>';
}else if(step.type==='group'){
h+='<div class="help">动作组:按顺序执行下方子步骤一次。把多个步骤打包成一个可复用的整体。</div>';
h+='<div class="form-group"><button class="btn btn-xs btn-primary" onclick="_stepEditor.saveAsCustomAction(\''+esc(step.id||'')+'\')">存为自定义动作</button> <span class="help" style="display:inline">把本组步骤保存到左侧自定义动作库,方便复用</span></div>';
}else if(step.type==='if_el'){
// 屏幕状态 / 前台App / 去重 是"直接读设备当前状态或账本",没有等待超时、也没有选择器可抓
var isDedup=(p.selector_type==='dedup');
var dyn=(p.selector_type==='screen'||p.selector_type==='foreground'||isDedup);
// 文本比对:屏幕状态没有文本可比(亮/熄)、去重也不比文本,只有元素/OCR/前台 App 才给这块
// 候选值来源:默认"手填"(行为与以前完全一致);也可以直接吃「账号」页的台账
var cmpSrc=p.cmp_source||'';
var fromLedger=(cmpSrc==='device'||cmpSrc==='all'||cmpSrc==='group');
var cmpRow=dyn?'':'<div class="form-row"><div class="form-group"><label>文本比对</label>'+
'<select class="form-control" data-key="cmp_op">'+
[['','不比对(只看元素在不在)'],['等于','等于'],['不等于','不等于'],['包含','包含'],['不包含','不包含']]
.map(function(o){return '<option value="'+o[0]+'"'+((p.cmp_op||'')===o[0]?' selected':'')+'>'+o[1]+'</option>';}).join('')+
'</select></div>'+
'<div class="form-group"><label>候选值来源</label>'+
'<select class="form-control" data-key="cmp_source" onchange="_stepEditor._cmpSourceChanged(this)">'+
[['','手填(默认)'],['device','本机台账'],['all','全部台账'],['group','按设备分组'],
['release','本机当前发布计划(要发的那个号)']]
.map(function(o){return '<option value="'+o[0]+'"'+((p.cmp_source||'')===o[0]?' selected':'')+'>'+o[1]+'</option>';}).join('')+
'</select><div class="help">'+(p.cmp_source==='release'
? '取<b>本机下一条要发的发布计划</b>的抖音号 + 账号名称(推送前校验"当前登录的是不是要发的号"就用它)'
: '台账在「账号」页维护')+'</div></div>'+
(cmpSrc==='group'?
'<div class="form-group"><label>设备分组名</label>'+
'<input class="form-control" data-key="cmp_group" value="'+esc(p.cmp_group||'')+'" '+
'placeholder="分组名(工具→设备分组)"></div>':'')+
// 选台账时**只隐藏、不删**这个输入框:_syncParams 是按 DOM 存在与否收集的,
// 删了就等于把用户手填的值悄悄抹掉(同类坑见本文件 927 行附近的注释)
'<div class="form-group" style="grid-column:span 2'+(fromLedger?';display:none':'')+'">'+
'<label>比对的值(可多行,任一命中即可)</label>'+
'<textarea class="form-control" data-key="cmp_value" rows="2" style="font-family:var(--mono)" '+
'placeholder="如 35377983067&#10;35377983068">'+esc(p.cmp_value||'')+'</textarea>'+
'<div class="help">一行一个(也认 <code>|</code> 分隔):<b>任意一个命中就算命中</b>——多个号的场景把号都列上。'+
'<b>逗号不当分隔符</b>,因为要比对的文本本身常带逗号'
+(fromLedger?'<br><b>候选值来自台账</b>:这台设备在台账里登记的抖音号会作为候选,'
+'手填的值<b>追加</b>在后面(台账取不到号时只用手填值,不会把任务打哑)。'
+'<br>⚠ 台账只提供<b>比对用的号</b>;<b>不要</b>把台账号填到「去重」的身份元素里'
+'(身份是元素原文、逐字比,格式不同会让去重静默失效)。':'')
+'</div></div></div>';
// 「去重」条件:身份元素用 ident_type/ident_value(主选择器那行只剩类型下拉)
var identRow=isDedup?_selRowHtml(p,
'<div class="help"><b>身份元素</b>:填"账号"那个元素(点「抓取元素」选,如抖音号那个 TextView)。'
+'<b>留空 = 用本设备当身份</b>(一号一机时这样就行)。'
+'<br>身份<b>读不到时不会去重</b>(当"没做过"照常执行)——宁可重复一次,也不会误判成"做过"而漏做。'
+'<br>有效期在上方任务级的<b>「去重有效期」</b>里设,检查和记账共用一套(不用在步骤里各填一遍)。</div>',
false, false, {type:'ident_type', value:'ident_value'}):'';
h+=_selRowHtml(p,
(dyn?'':'<div class="form-group"><label>查找超时(秒)</label><input type="number" class="form-control" data-key="timeout" value="'+(p.timeout||3)+'" min="0.5" max="30" step="0.5"></div>'+
'<div class="form-group form-check" data-ocr-field style="display:'+(p.selector_type==='ocr'?'block':'none')+'">'+
'<input type="checkbox" data-key="ocr_click" '+(p.ocr_click?'checked':'')+'><label>OCR 命中后自动点击该文字</label></div>'+
cmpRow)+
'<div class="help">'+(isDedup
? '命中 → 执行下方"✅ 找到时"分支;未命中 → 执行"❌ 未找到时"分支。'
+'<b>去重</b>读的是平台的「去重账本」(所有设备共享一份,见「任务 → 去重记录」):'
+'<b>任何一台设备做过了,其余设备的这一步都会命中</b>——'
+'所以多个手机跑同一个任务、反复重跑,也不会把同一个号做两次。'
+'<br>建议这样摆:<b>命中分支</b>放「停止本设备」或「发通知」;'
+'<b>未命中分支</b>里放要做的动作(评论…),动作后面跟一个「记为已做」。'
+'<br>⚠ 记账要放在动作<b>成功之后</b>(用「记为已做」步骤),失败就不记账、下次还会重试。'
: (dyn
? '命中 → 执行下方"✅ 找到时"分支;未命中 → 执行"❌ 未找到时"分支。<b>屏幕状态</b>读系统 mWakefulness(亮/熄),'
+'<b>前台App是</b>判断当前前台是不是某个包名——都只看当前状态,不等待。'
: '超时内找到元素 → 执行下方"✅ 找到时"分支;未找到 → 执行"❌ 未找到时"分支。两分支可放任意步骤,也可再嵌套条件判断/循环;'
+'<b>OCR识别</b>可匹配图片/画布里的文字(截屏识别,跨平台)'
+'<br><b>文本比对</b>(可选):填了就把找到的那个元素的<b>文本</b>和"比对的值"比,'
+'比中了才算命中。例:元素文本是 <code>抖音号:35377983067</code>,'
+'想判断"这台登录的是不是这个号",选<b>包含</b>、值填 <code>35377983067</code> 即可(要整段一模一样才用"等于")。'
+'多个值一行一个,<b>任一命中即可</b>——"这几个号都算我的"。'
+'<br>⚠ 元素<b>没找到</b>时一律算未命中(走"未找到时"分支)——"没读到"不会被当成"和我设的不一样"。'))+'</div>',
true, true, isDedup?{hideValue:true}:null)+identRow;
}else if(step.type==='mark_done'){
h+=_selRowHtml(p,
'<div class="help">把身份值记进平台「去重账本」(所有设备共享一份)。'
+'放在动作<b>成功之后</b>——动作失败就不会记账,下次重跑还会重试这台设备。'
+'<br>✅ <b>留空 = 自动跟随上面「去重」检查的身份</b>(推荐):'
+'检查与记账必须用<b>同一个身份字符串</b>,key 才算得到一起;'
+'两处各配一遍的话,一边填了另一边忘了去重就会失效。'
+'<br>没有前置去重检查、又留空时,退回用<b>本设备</b>当身份(一号一机场景)。'
+'<br>有效期在上方任务级的<b>「去重有效期」</b>里设。</div>');
}
// 共享:触发概率(所有步骤通用,0~100)
h+='<div class="form-row" style="margin-top:8px"><div class="form-group"><label>触发概率(%)</label><input type="number" class="form-control" data-key="probability" value="'+(p.probability!=null?p.probability:100)+'" min="0" max="100"><div class="help">0~100,&lt;100 表示按概率执行(如 30=隔几次才触发一次,适合"偶尔点赞")</div></div></div>';
// 可编辑 label
h+='<div class="form-group"><label>步骤名称(可选)</label><input type="text" class="form-control" data-key="_label" value="'+esc(step.label||'')+'" placeholder="留空用默认"></div>';
return h;
},
_bindDropZone:function(container,arr){
// 常驻容器(#se-canvas)多次重渲染会累积 drop 监听 → 拖一次添加多个。
// 用 dataset 标记只绑定一次;数组引用用 WeakMap 每次更新。
if(!this._dropArrs)this._dropArrs=new WeakMap();
this._dropArrs.set(container,arr);
if(container.dataset.dropBound)return;
container.dataset.dropBound='1';
var self=this;
// 画布空白处也能接收 drop,拖入时高亮提示
container.addEventListener('dragover',function(e){
e.preventDefault();
container.classList.add('drag-over');
});
container.addEventListener('dragleave',function(e){
if(!container.contains(e.relatedTarget))container.classList.remove('drag-over');
});
container.addEventListener('drop',function(e){
e.preventDefault();
e.stopPropagation(); // 阻止冒泡到外层卡片/容器,避免重复添加
container.classList.remove('drag-over');
// 子卡片都会 stopPropagation,能冒泡到这里的 drop 一定落在空白区
// (含 .se-empty 的提示文字),直接追加到数组末尾
self._handleDrop(e,self._dropArrs.get(container),-1);
});
},
_handleDrop:function(e,arr,idx){
var self=this;
var data=e.dataTransfer.getData('text/plain');
if(!data)return;
// drop 处理中同步重建 DOM 会打断拖拽事件,可能被浏览器重发 drop 导致重复添加。
// 延迟到下一帧渲染,保证一次拖入只加一次。
function rerender(){ setTimeout(function(){self._renderCanvas();},0); }
if(data.startsWith('move:')){
// 拖拽排序:同层级排序 / 跨层级移入(把步骤拖进循环块、循环套循环)
var parts=data.split(':');
var fromIdx=parseInt(parts[1]);
var fromPath=parts[2];
var srcArr=this._resolveArr(fromPath);
if(srcArr){
if(srcArr===arr){
// 同层级排序
if(fromIdx===idx)return;
this._syncFromDom();
var moved=arr.splice(fromIdx,1)[0];
if(idx<0)arr.push(moved);
else if(fromIdx<idx)arr.splice(idx-1,0,moved);
else arr.splice(idx,0,moved);
rerender();
}else{
// 跨层级移入;禁止移进自己或自己的子孙(否则渲染会无限递归)
var moved2=srcArr[fromIdx];
if(moved2&&!this._containsArr(moved2,arr)){
this._syncFromDom();
srcArr.splice(fromIdx,1);
if(idx<0||idx>=arr.length)arr.push(moved2);
else arr.splice(idx,0,moved2);
rerender();
}
}
}
}else if(data.startsWith('action:')){
// 从自定义动作库拖入 → 展开为 group 步骤
var actionId=data.substring(7);
var action=_customActions.find(function(a){return a.id===actionId;});
if(action&&action.steps&&action.steps.length){
var ns=this._makeStep({type:'group',label:action.name,icon:action.icon||'📦',params:{children:[]}});
ns.label=action.name;
ns.params.children=JSON.parse(JSON.stringify(action.steps));
_stripIds(ns.params.children); // 重新生成 id,避免同一动作拖入多次后 id 冲突
this._ensureIds(ns.params.children);
if(idx<0||idx>=arr.length)arr.push(ns);
else arr.splice(idx,0,ns);
rerender();
}
}else if(data.startsWith('type:')){
// 从系统操作库拖入
var type=data.substring(5);
var lib=_findLib(type);
if(lib){
var ns2=this._makeStep(lib);
if(idx<0||idx>=arr.length)arr.push(ns2);
else arr.splice(idx,0,ns2);
rerender();
}
}
},
_makeStep:function(lib){
// 参数 = STEP_LIB 出厂值 ← 用户在「动作配置」里配的默认值(有才覆盖)
var params=JSON.parse(JSON.stringify(lib.params));
var d=_stepDefaults&&_stepDefaults[lib.type];
if(d)Object.keys(d).forEach(function(k){ if(d[k]!==undefined)params[k]=d[k]; });
return {id:_newStepId(),type:lib.type,label:lib.label,params:params};
},
// 递归找步骤对象(按 id)——写入录音必须改 _steps,不能只改 DOM
_findStep:function(id,arr){
var self=this, found=null;
(arr||this._steps).forEach(function(st){
if(found)return;
if(st.id===id){found=st;return;}
_childArrs(st).forEach(function(a){ if(!found)found=self._findStep(id,a); });
});
return found;
},
// 给某个「录制手势」步骤录制轨迹(src: 'phone' 手机上录 / 'web' 网页上录)
recordInto:function(btn,src){
var card=btn.closest('.step-card');
var step=card?this._findStep(card.dataset.stepId):null;
if(!step){showToast('找不到这个步骤','error');return;}
if(typeof openRecorder!=='function'){showToast('录制器未加载','error');return;}
var self=this;
openRecorder({mode:'gesture', autoStartPhone: src==='phone', onDone:function(p,from){
step.params={points:p.points,speed:p.speed||1.0};
var dur=p.points.length?p.points[p.points.length-1][2]:0;
step.label='录制手势 '+(from==='phone'?'手机':'网页')+' '+p.points.length+'点';
self._renderCanvas();
showToast('已录到 '+(p.points.length)+' 个点 / '+(dur/1000).toFixed(2)+' 秒','success');
}});
},
clearGesture:function(btn){
var card=btn.closest('.step-card');
var step=card?this._findStep(card.dataset.stepId):null;
if(!step){return;}
step.params={points:[],speed:1.0};
this._renderCanvas();
showToast('已清空录制的轨迹','success');
},
// 测试此步骤:在选中设备上试执行当前步骤,验证选择器
_testStep:function(btn){
var card=btn.closest('.step-card');
if(!card)return;
var type=card.dataset.stepType||'';
var params={};
card.querySelectorAll('[data-key]').forEach(function(inp){
if(inp.type==='checkbox')params[inp.dataset.key]=inp.checked;
else if(inp.type==='number')params[inp.dataset.key]=parseFloat(inp.value)||0;
else params[inp.dataset.key]=inp.value;
});
this._pendingTest={type:type,label:'测试',params:params};
this._showTestDevicePicker();
},
_showTestDevicePicker:function(){
document.getElementById('test-modal-title').textContent='测试步骤 - 选择设备';
document.getElementById('test-modal-body').innerHTML=
'<div class="el-device-grid"><div class="egd-title">选择在哪个设备上试执行</div><div id="test-device-list">加载中...</div></div>';
document.getElementById('test-overlay').classList.add('show');
var self=this;
apiGet('/api/uiauto/devices').then(function(r){
var list=document.getElementById('test-device-list');
if(!list)return;
if(!r||!r.ok){list.innerHTML='<div class="se-empty">'+esc((r&&r.error)||'获取设备失败')+'</div>';return;}
list.innerHTML=(r.devices||[]).map(function(d){
return self._devCard(d, "_stepEditor._runTestStep('"+esc(d.serial)+"')");
}).join('')||'<div class="se-empty">无可用设备</div>';
});
},
_runTestStep:async function(serial){
var step=this._pendingTest;
var body=document.getElementById('test-modal-body');
if(!step||!body)return;
body.innerHTML='<div class="se-empty">正在 '+esc(serial)+' 上执行(adb + u2 连接约需几秒)...<div class="hint">请稍候,期间不要操作该设备</div></div>';
var r=await apiPost('/api/steps/test',{serial:serial,step:step});
if(!r)return;
var icon=r.result==='命中'?'✅':(r.result==='未找到'?'❌':'▶');
body.innerHTML='<div class="se-empty" style="padding:20px">'+icon+' <strong>'+esc(r.result||'已执行')+'</strong>'+
'<div class="hint" style="margin-top:8px">'+esc(r.msg||'')+'</div>'+
'<button class="btn btn-primary" style="margin-top:14px" onclick="closeTestModal()">完成</button></div>';
},
addStep:function(type){
var lib=_findLib(type);
if(!lib)return;
this._syncFromDom();
this._steps.push(this._makeStep(lib));
this._renderCanvas();
},
addCustomAction:function(actionId){
var action=_customActions.find(function(a){return a.id===actionId;});
if(!action||!action.steps||!action.steps.length)return;
this._syncFromDom();
var ns=this._makeStep({type:'group',label:action.name,icon:action.icon||'📦',params:{children:[]}});
ns.label=action.name;
ns.params.children=JSON.parse(JSON.stringify(action.steps));
_stripIds(ns.params.children); // 重新生成 id,避免同一动作拖入多次后 id 冲突
this._ensureIds(ns.params.children);
this._steps.push(ns);
this._renderCanvas();
},
delCustomAction:function(actionId){
if(!confirm('确定删除这个自定义动作?'))return;
apiDelete('/api/custom_actions/'+actionId).then(function(res){
if(!res)return;
if(res.ok){_loadCustomActions();showToast('已删除','success');}
else showToast(res.error||'删除失败','error');
});
},
toggleSelect:function(stepId){
this._syncFromDom();
if(!this._selected)this._selected=[];
var i=this._selected.indexOf(stepId);
if(i>=0)this._selected.splice(i,1);
else this._selected.push(stepId);
this._renderCanvas();
this._updatePackBtns();
},
// 同步所有"打包选中步骤"按钮的可用状态(任务编辑器 + 动作编辑器)
_updatePackBtns:function(){
var n=this._selected&&this._selected.length;
['se-pack-btn','se-pack-btn2'].forEach(function(id){
var bar=document.getElementById(id);
if(bar)bar.disabled=n<1;
});
},
saveAsCustomAction:function(stepId){
// 从 group 步骤的"存为自定义动作"按钮调用时传入该 group 的 id:
// 优先收集该 group 的子步骤;否则收集 ☑ 选中的步骤
var stepsToSave=[];
if(stepId){
var group=this._findStepById(this._steps,stepId);
if(group&&group.params&&group.params.children&&group.params.children.length){
stepsToSave=JSON.parse(JSON.stringify(group.params.children));
_stripIds(stepsToSave);
}
}
if(!stepsToSave.length&&this._selected&&this._selected.length){
// 从选中步骤收集
stepsToSave=this._collectByIds(this._steps,this._selected);
}
if(!stepsToSave.length){
showToast('请先用 ☑ 选中要打包的步骤','error');
return;
}
var name=prompt('输入动作名称:','我的动作');
if(!name)return;
var self=this;
apiPost('/api/custom_actions',{name:name,steps:stepsToSave})
.then(function(res){
if(!res)return;
if(res.ok){
showToast('已保存到自定义动作库','success');
self._selected=[];
_loadCustomActions();
self._renderCanvas();
self._updatePackBtns();
}else showToast(res.error||'保存失败','error');
});
},
// 在嵌套结构中按 id 查找步骤(group/loop 的子步骤、if_el 的分支步骤也找得到)
_findStepById:function(steps,id){
var self=this;
for(var i=0;i<steps.length;i++){
var s=steps[i];
if(s.id===id)return s;
var f=null;
_childArrs(s).forEach(function(a){ if(!f)f=self._findStepById(a,id); });
if(f)return f;
}
return null;
},
_collectByIds:function(steps,ids){
// 收集选中的步骤(深拷贝,去掉 id)。
// 递归搜索嵌套子步骤:☑ 也能选中 group/loop/if_el 内部的步骤,只搜顶层会漏
var result=[];
var self=this;
steps.forEach(function(s){
if(ids.indexOf(s.id)>=0){
var copy=JSON.parse(JSON.stringify(s));
_stripIds([copy]);
result.push(copy); // 容器选中时子步骤已包含在拷贝里,不再单独收集
}else{
_childArrs(s).forEach(function(a){
result=result.concat(self._collectByIds(a,ids));
});
}
});
return result;
},
toggleExpand:function(btn){
var card=btn.closest('.step-card');
card.classList.toggle('expanded');
},
_toggleInputMode:function(sel){
var body=sel.closest('.sc-body');
var mode=sel.value;
body.querySelectorAll('[data-mode]').forEach(function(g){
g.style.display=g.dataset.mode===mode?'block':'none';
});
},
_toggleLoopMode:function(sel){
var body=sel.closest('.sc-body');
var mode=sel.value;
body.querySelectorAll('[data-lmode]').forEach(function(g){
g.style.display=g.dataset.lmode===mode?'block':'none';
});
},
// 输入文字:取值来源切到"发布计划"时,把手填那两块藏起来(话题输入框反过来显示)
_inputSourceChanged:function(sel){
this._syncFromDom(); // 同 _cmpSourceChanged:不先同步,重渲染会吞掉刚改的值
var self=this;
setTimeout(function(){self._renderCanvas();},0);
},
// 条件判断:候选值来源切到台账时,把"比对的值"藏起来(按分组还要显示分组名输入框)
_cmpSourceChanged:function(sel){
// 同 _condTypeChanged:先把 DOM 的值同步回 _steps,否则重渲染会吞掉刚改的东西
this._syncFromDom();
var self=this;
setTimeout(function(){self._renderCanvas();},0);
},
// 条件判断:选择器类型切到 OCR 时显示"命中后点击"开关
_condTypeChanged:function(sel){
// 条件类型一变,"值"控件也要跟着换(元素值输入框 / 屏幕亮熄下拉 / 包名输入),
// 整卡重渲染最省心。先把 DOM 的值同步回 _steps,否则重渲染会丢掉刚改的东西。
this._syncFromDom();
var self=this;
setTimeout(function(){self._renderCanvas();},0);
},
// 给数组分配唯一 id,用于 move/duplicate/remove/拖拽定位到正确的父数组
_arrId:function(arr){
if(arr===this._steps)return 'root';
if(!this._arrIdMap)this._arrIdMap=new WeakMap();
if(!this._arrById)this._arrById={};
if(!this._arrIdMap.has(arr)){
var id='arr_'+(++this._arrCounter);
this._arrIdMap.set(arr,id);
this._arrById[id]=arr;
}
return this._arrIdMap.get(arr);
},
_resolveArr:function(path){
if(path==='root')return this._steps;
return this._arrById&&this._arrById[path];
},
// 判断 step 的子孙数组中是否包含 arr(防循环块套进自己导致渲染无限递归)
_containsArr:function(step,arr){
var arrs=_childArrs(step);
if(!arrs.length)return false;
if(arrs.indexOf(arr)>=0)return true;
for(var i=0;i<arrs.length;i++){
for(var j=0;j<arrs[i].length;j++){
if(this._containsArr(arrs[i][j],arr))return true;
}
}
return false;
},
move:function(idx,dir,path){
this._syncFromDom(); // 先同步 DOM 编辑,避免重渲染丢内容
var arr=this._resolveArr(path);
if(!arr)return;
var ni=idx+dir;
if(ni<0||ni>=arr.length)return;
var tmp=arr[ni];arr[ni]=arr[idx];arr[idx]=tmp;
this._renderCanvas();
},
duplicate:function(idx,path){
this._syncFromDom();
var arr=this._resolveArr(path);
if(!arr)return;
var copy=JSON.parse(JSON.stringify(arr[idx]));
copy.id=_newStepId();
this._ensureIds([copy]);
arr.splice(idx+1,0,copy);
this._renderCanvas();
},
remove:function(idx,path){
this._syncFromDom();
var arr=this._resolveArr(path);
if(!arr)return;
arr.splice(idx,1);
this._renderCanvas();
},
collect:function(){
// 先把当前 DOM 上的参数值同步回 _steps
var self=this;
this._syncParams(this._canvas,this._steps);
return JSON.parse(JSON.stringify(this._steps));
},
validate:function(steps){
// 校验步骤配置,返回警告列表
var warnings=[];
// 去重:收集「条件判断→去重」与「记为已做」各自用的身份元素,最后比对是否一致
var dedupIdents=[], hasDedupCheck=false, hasMarkDone=false;
function check(arr,prefix){
arr.forEach(function(s,i){
var name=prefix?(prefix+'.'+(i+1)):('步骤'+(i+1));
var label=s.label||s.type;
if(s.type==='click'){
if(!s.params.selector_value){
warnings.push(name+' "'+label+'": 选择器值为空,点击步骤会跳过。请用"抓取元素"按钮选择目标。');
}
}else if(s.type==='long_click'||s.type==='swipe_until'||s.type==='wait_el'){
if(!s.params.selector_value){
warnings.push(name+' "'+label+'": 选择器值为空,该步骤会跳过。请用"抓取元素"或"测试"按钮选择目标。');
}
}else if(s.type==='click_xy'){
const x=parseFloat(s.params.x), y=parseFloat(s.params.y);
if(isNaN(x)||isNaN(y)||x<0||x>100||y<0||y>100){
warnings.push(name+' "'+label+'": 坐标需在 0-100 之间(屏幕百分比)。');
}
}else if(s.type==='open_app'||s.type==='stop_app'){
if(!s.params.package){
warnings.push(name+' "'+label+'": 包名为空,无法'+(s.type==='open_app'?'启动':'结束')+'App');
}
}else if(s.type==='input_text'){
if(s.params.mode==='fixed'&&!s.params.fixed_text){
warnings.push(name+' "'+label+'": 指定文字为空');
}else if(s.params.mode==='random'&&!s.params.texts){
warnings.push(name+' "'+label+'": 候选文字为空');
}
}else if(s.type==='clipboard'){
if(!s.params.text){
warnings.push(name+' "'+label+'": 注入内容为空,步骤会跳过。');
}
}else if(s.type==='gesture'){
if(!(s.params.points||[]).length){
warnings.push(name+' "'+label+'": 录制手势还没有轨迹,执行时会被跳过。');
}
}else if(s.type==='if_el'){
// 「去重」「屏幕状态」这两类条件**不用文本比对**(面板里那块是隐藏的)。
// 所以:① 留着旧 cmp_op/cmp_value 不该报警告(用户看不到、也清不掉,
// 只会每次保存都被打扰);② 也别报"会被忽略"——那是设计如此。
var cmpUsed=(s.params.selector_type!=='screen'&&s.params.selector_type!=='dedup');
if(s.params.selector_type!=='dedup'&&!s.params.selector_value){
warnings.push(name+' "'+label+'": 条件判断的选择器为空,会直接走"未找到"分支。');
}
var cmpSrcV=(s.params.cmp_source||'');
var fromLedger=(cmpSrcV==='device'||cmpSrcV==='all'||cmpSrcV==='group');
// 候选值"从别处取"的那两档(台账 / 发布计划):手填可以留空,别报"没填值"
var fromOther=(fromLedger||cmpSrcV==='release');
if(cmpUsed&&s.params.cmp_op&&!String(s.params.cmp_value||'').trim()&&!fromOther){
warnings.push(name+' "'+label+'": 选了"文本比对('+s.params.cmp_op+')"但没填比对的值,'
+'执行时永远不命中(相当于每次都走"未找到时"分支)。');
}
if(cmpUsed&&fromLedger&&s.params.cmp_op){
warnings.push(name+' "'+label+'": 候选值来自台账 —— 台账里没有这台设备'
+'(或设备池里的设备名与台账「设备号」对不上)时会取不到号,'
+'这一步将永远走"未找到"分支。请到「账号」页核对设备号。');
}
if(cmpUsed&&cmpSrcV==='release'&&s.params.cmp_op){
warnings.push(name+' "'+label+'": 候选值来自"本机当前发布计划" —— 本机没有待发的计划'
+'时取不到号(这一步会走"未找到"分支)。这是设计如此(宁可不发、也不发错号),'
+'但请确认前面有「推送发布视频」步骤、且这条计划确实排到了本机。');
}
if(s.params.selector_type==='dedup'){
hasDedupCheck=true;
// 空值也收进来:一边填了、另一边没填 = 两处身份不一致(去重会对不上),
// 必须报出来 —— 只收非空的话,这种最常见的不一致反而检测不到
dedupIdents.push({where:name,type:s.params.ident_type||'text',
value:(s.params.ident_value||'').trim()});
}
}else if(s.type==='mark_done'){
hasMarkDone=true;
// 留空 = 自动跟随去重检查的身份(执行器就是这么做的),所以**空值不参与比对**;
// 只有"填了、且和检查侧不一样"才是真问题
var mv=(s.params.selector_value||'').trim();
if(mv)dedupIdents.push({where:name,type:s.params.selector_type||'text',value:mv});
}
_childArrs(s).forEach(function(a){
if(a.length)check(a,name+' 子步骤');
});
});
}
check(steps,'');
// 去重的两个坑,保存时直接点出来(配错的话去重会「静默失效」,最难查)
if(hasDedupCheck&&!hasMarkDone){
warnings.push('任务里有「条件判断→去重」但没有「记为已做」:做完动作不会记账,'
+'下次重跑还会再做一遍(去重起不到作用)。请在动作后面加一个「记为已做」。');
}
if(hasMarkDone&&!hasDedupCheck){
warnings.push('任务里有「记为已做」但没有「条件判断→去重」:只会记账、不会拦重复。'
+'请在动作前面加一个条件判断、类型选「去重」。');
}
if(dedupIdents.length>1){
var first=dedupIdents[0];
var showId=function(x){ return x.value?('「'+x.value+'」'):'(留空 → 用设备当身份)'; };
dedupIdents.slice(1).forEach(function(d){
if(d.value!==first.value||d.type!==first.type){
warnings.push('去重两处的身份不一致:'+first.where+' 是 '+showId(first)
+',而 '+d.where+' 是 '+showId(d)
+' —— 身份不一致会算出两个不同的 key,去重会失效'
+'(检查查不到记账 / 或者反过来,结果就是重复或漏做)。'
+'建议把「记为已做」的身份元素留空(自动跟随检查),或两处填成同一个元素。');
}
});
}
return warnings;
},
_syncParams:function(container,steps){
var self=this;
var cards=container.querySelectorAll(':scope > .step-card');
cards.forEach(function(card,i){
if(!steps[i])return;
// 只读本卡片直接子级的 .sc-body,避免把嵌套子步骤的输入误写进本步骤 params
var inputs=card.querySelectorAll(':scope > .sc-body [data-key]');
inputs.forEach(function(inp){
var key=inp.dataset.key;
if(inp.type==='checkbox'){
steps[i].params[key]=inp.checked;
}else if(inp.type==='number'){
steps[i].params[key]=parseFloat(inp.value)||0;
}else{
if(key==='_label'){
steps[i].label=inp.value||steps[i].label;
}else{
steps[i].params[key]=inp.value;
}
}
});
// 递归子步骤:loop/group 的 children
var childWrap=card.querySelector(':scope > .step-children');
if(childWrap&&steps[i].params&&steps[i].params.children){
self._syncParams(childWrap,steps[i].params.children);
}
// if_el 双分支(then/else 在 .if-branch 内,不会与上面的直接子级选择器冲突)
//
// ⚠ `.if-branch` 与 `.step-children` 之间**必须是 `>`(直接子级)**:
// 写成后代(空格)时,条件判断里再嵌套一个条件判断的话,本卡片会**匹配到内层
// 那个 if_el 的分支容器**(文档顺序上更靠前),于是把内层分支的子步骤当成自己的
// 去同步 —— 症状:自己的步骤"填了值保存就没了"、步骤名被内层的容器步骤覆盖。
var thenWrap=card.querySelector(':scope > .if-branch > .step-children.if-then');
if(thenWrap&&steps[i].params&&Array.isArray(steps[i].params.then)){
self._syncParams(thenWrap,steps[i].params.then);
}
var elseWrap=card.querySelector(':scope > .if-branch > .step-children.if-else');
if(elseWrap&&steps[i].params&&Array.isArray(steps[i].params.else)){
self._syncParams(elseWrap,steps[i].params.else);
}
});
},
// 把画布 DOM 上的参数同步回 _steps,并自动保存草稿(操作前调用防丢失)
_syncFromDom:function(){
this._syncParams(this._canvas,this._steps);
this._autosave();
},
_autosave:function(){
try{localStorage.setItem('se_draft_'+this._draftKey,JSON.stringify(this._steps));}catch(e){}
},
_loadDraft:function(){
try{
var d=localStorage.getItem('se_draft_'+this._draftKey);
if(d)return JSON.parse(d);
}catch(e){}
return null;
},
clearDraft:function(){
try{localStorage.removeItem('se_draft_'+this._draftKey);}catch(e){}
},
// ================== 元素抓取 ==================
openPicker:function(btn){
var input=btn.previousElementSibling;
this._elTargetInput=input;
// 直接弹设备选择页(独立模态框,不影响任务编辑窗口)
this._showDevicePicker();
},
// 设备选择卡片(「抓取元素」与「测试此步骤」两处共用):
// **设备名优先**,型号与地址降为副标题——同型号好几台时只有平台起的名字
// (A08)能认出"是哪一台",光看 IP 选错设备是常事。
_devCard:function(d,onclick){
var sub=[d.model||d.product||'', d.serial||''].filter(Boolean).join(' · ');
return '<div class="el-device-card" onclick="'+onclick+'">'+
'<div class="edc-icon">📱</div>'+
'<div class="edc-info"><div class="edc-name">'+esc(d.name||d.serial)+'</div>'+
'<div class="edc-serial">'+esc(sub)+'</div></div>'+
(d.status?'<span class="edc-status">'+esc(d.status)+'</span>':'')+'</div>';
},
// 步骤1:设备选择页
_showDevicePicker:function(){
var self=this;
document.getElementById('el-picker-title').textContent='抓取元素 - 选择设备';
document.getElementById('el-picker-body').innerHTML='<div class="el-device-grid"><div class="egd-title">选择要抓取元素的设备</div><div id="el-device-list">加载中...</div></div>';
document.getElementById('el-picker-footer').innerHTML='<button class="btn" onclick="closeElPicker()">取消</button>';
document.getElementById('el-picker-overlay').classList.add('show');
fetch('/api/uiauto/devices').then(function(r){return r.json();}).then(function(res){
var list=document.getElementById('el-device-list');
if(!res.ok){list.innerHTML='<div class="se-empty">'+esc(res.error||'获取设备失败')+'<br><button class="btn btn-sm" style="margin-top:10px" onclick="_stepEditor._showDevicePicker()">重试</button></div>';return;}
var devices=res.devices||[];
if(!devices.length){list.innerHTML='<div class="se-empty">uiauto2 未发现已连接设备</div>';return;}
list.innerHTML=devices.map(function(d){
return self._devCard(d, "_stepEditor._showElPicker('"+esc(d.serial)+"')");
}).join('');
}).catch(function(e){
document.getElementById('el-device-list').innerHTML='<div class="se-empty">请求失败: '+esc(e)+'<br><button class="btn btn-sm" style="margin-top:10px" onclick="_stepEditor._showDevicePicker()">重试</button></div>';
});
},
// 步骤2:选中设备后,显示截图 + 元素树
_showElPicker:function(serial){
var self=this;
self._elSerial=serial;
document.getElementById('el-picker-title').textContent='抓取元素 - '+serial;
document.getElementById('el-picker-body').innerHTML=
'<div class="el-picker-main">'+
'<div class="el-shot-wrap">'+
'<div class="el-shot-toolbar">'+
'<button class="btn" onclick="_stepEditor._refreshShot()" title="重新抓取截图与元素树(一次取齐)">重新抓取</button>'+
'<span class="el-shot-info" id="el-shot-info">加载中...</span>'+
'</div>'+
'<div class="el-shot-container" id="el-shot-container">'+
'<div class="el-shot-loading">正在获取截图...</div>'+
'</div>'+
'</div>'+
'<div class="el-list-wrap">'+
'<div class="el-tabs">'+
'<button class="el-tab active" data-pane="tree" onclick="_stepEditor._tab(this)">层级</button>'+
'<button class="el-tab" data-pane="attr" onclick="_stepEditor._tab(this)">属性</button>'+
'<button class="el-tab" data-pane="color" onclick="_stepEditor._tab(this)">颜色</button>'+
'</div>'+
'<div class="el-pane active" id="el-pane-tree">'+
'<input type="text" class="el-picker-search" id="el-search" placeholder="搜索 resource-id/text/description..." oninput="_stepEditor._filterEl(this.value)">'+
'<div class="el-picker-list" id="el-list" style="max-height:calc(100% - 42px)"><div class="se-empty">加载元素树...</div></div>'+
'</div>'+
'<div class="el-pane" id="el-pane-attr"><div class="se-empty">点左侧图上的元素,或点「层级」里的条目看属性</div></div>'+
'<div class="el-pane" id="el-pane-color">'+
'<div style="padding:8px 12px;font-size:11.5px;color:var(--text-light)">把鼠标移到左侧图上取色;点元素可看该元素的主色</div>'+
'<div id="el-color-list"></div>'+
'</div>'+
'</div>'+
'</div>';
document.getElementById('el-picker-footer').innerHTML=
'<button class="btn" onclick="_stepEditor._showDevicePicker()">← 返回选设备</button>'+
'<button class="btn" onclick="closeElPicker()">取消</button>'+
'<span class="text-muted" style="margin-left:10px;font-size:11.5px">'
+'提示:先「▶ 点一下」在设备上验证位置,再点元素回填选择器;'
+'「✓ 测选择器」会用将填入的选择器真跑一次点击</span>';
// 一次取齐截图 + 元素树(见 _loadSnapshot 的说明)
self._loadSnapshot();
},
// 右侧页签切换
_tab:function(btn){
var pane=btn.dataset.pane;
document.querySelectorAll('#el-picker-body .el-tab').forEach(function(b){
b.classList.toggle('active', b===btn);
});
['tree','attr','color'].forEach(function(p){
var el=document.getElementById('el-pane-'+p);
if(el)el.classList.toggle('active', p===pane);
});
},
// 属性页:选中元素的全部属性
_renderAttrs:function(i){
var el=(this._elData||[])[i];
var box=document.getElementById('el-pane-attr');
if(!box)return;
if(!el){box.innerHTML='<div class="se-empty">点左侧图上的元素,或点「层级」里的条目看属性</div>';return;}
var sg=el.suggested||{};
// 稳定性说明:把"这个选择器靠什么定位"写清楚,用户才知道它会不会因为界面变化而失效
var stab=sg.semantic?'✓ 语义型 —— 靠 @'+(sg.via||'')+' 限定,不依赖同类元素个数,换设备/换版本仍命中'
:sg.indexed?'⚠ 序号型 —— 依赖「同类元素共 '+sg.total+' 个、取第 '+sg.occ+' 个」,界面一变就失配'
:sg.broad?'⚠ 宽泛 —— 无唯一属性,可能误匹配其他元素'
:(sg.value?'✓ 唯一属性,直接定位':'—');
var rows=[['建议选择器', sg.value||'(无可靠选择器)'],['选择器稳定性', stab],
['class', el.class||''], ['resource-id', el.resource_id||''],
['text', el.text||''], ['content-desc', el.description||''],
['package', el.package||''], ['clickable', el.clickable||''],
['bounds', el.bounds||''], ['层级深度', String(el.depth||0)]];
box.innerHTML=rows.map(function(r){
return '<div class="el-attr"><b>'+esc(r[0])+'</b><span>'+esc(r[1]||'-')+'</span></div>';
}).join('');
},
// 颜色页:鼠标位置的像素色 + 选中元素的主色
_pixelAt:function(x,y){
try{
var c=this._colorCanvas, img=document.getElementById('el-shot-container').querySelector('img');
if(!c||!img)return null;
if(c.width!==img.naturalWidth){c.width=img.naturalWidth;c.height=img.naturalHeight;}
var ctx=c.getContext('2d');
ctx.drawImage(img,0,0);
var d=ctx.getImageData(Math.max(0,Math.min(c.width-1,x)),Math.max(0,Math.min(c.height-1,y)),1,1).data;
return '#'+[d[0],d[1],d[2]].map(function(v){return ('0'+v.toString(16)).slice(-2);}).join('');
}catch(e){return null;}
},
_updatePixel:function(x,y){
var box=document.getElementById('el-color-list');
if(!box)return;
var col=this._pixelAt(Math.round(x),Math.round(y));
if(!col)return;
box.innerHTML='<div class="el-color-row"><span class="el-swatch" style="background:'+col+'"></span>'+
'<span>光标处 '+esc(col)+'</span></div>'+
(box.dataset.dom||'');
},
_renderColors:function(i){
var box=document.getElementById('el-color-list');
if(!box)return;
var el=(this._elData||[])[i];
var dom='';
if(el&&el.bounds){
var m=el.bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
if(m){
var x=Math.round((+m[1]+ +m[3])/2), y=Math.round((+m[2]+ +m[4])/2);
var c=this._pixelAt(x,y);
if(c) dom='<div class="el-color-row"><span class="el-swatch" style="background:'+c+'"></span>'+
'<span>选中元素中心 '+esc(c)+'</span></div>';
}
}
box.dataset.dom=dom;
box.innerHTML=dom||'<div class="se-empty" style="padding:12px">把鼠标移到左侧图上取色</div>';
},
// 一次取齐:截图 + 元素树(后端用原生 u2 背靠背取)
// 为什么不再分两个接口:截图与元素树分两次拿,中间隔着 dump 本身的 1.3~1.8 秒,
// 界面只要在动(信息流/视频/加载),框就会落在旧位置上("抓取错位")。
_loadSnapshot:function(){
var serial=this._elSerial;
if(!serial)return;
var container=document.getElementById('el-shot-container');
var info=document.getElementById('el-shot-info');
var old=document.querySelector('.el-shot-warn');
if(old)old.remove();
if(container)container.innerHTML='<div class="el-shot-loading">正在抓取(截图 + 元素树一次取齐)…</div>';
if(info)info.textContent='抓取中…';
var self=this;
fetch('/api/uiauto/snapshot?serial='+encodeURIComponent(serial)+'&_='+Date.now())
.then(function(r){return r.json();})
.then(function(d){
if(!d.ok){
if(container)container.innerHTML='<div class="el-shot-loading">'+esc(d.error||'抓取失败')+'</div>';
if(info)info.textContent='失败';
return;
}
self._elData=d.elements||[];
if(d.unstable){
var w=document.createElement('div');
w.className='el-shot-warn';
w.innerHTML='⚠ 抓取期间界面有变化,框可能对不上 —— 建议把设备停在<b>静止界面</b>后点「重新抓取」';
container.parentNode.insertBefore(w, container);
}
var img=new Image();
img.onload=function(){
container.innerHTML='';
container.appendChild(img);
if(info){
info.textContent=d.width+'x'+d.height+' · '+self._elData.length+' 个元素 · '
+(d.cost_ms||0)+'ms'+(d.unstable?' · ⚠ 界面在变化':'');
}
// 图片显示尺寸一变(窗口缩放、面板宽度变化、滚动条出现…)就重算所有框 ——
// 这是"完全不错位"的保证:框永远跟着图片走,不留旧坐标
if(window.ResizeObserver){
if(self._ro)self._ro.disconnect();
self._ro=new ResizeObserver(function(){ self._renderOverlays(); });
self._ro.observe(img);
}
self._colorCanvas=document.createElement('canvas');
self._renderOverlays();
self._renderAttrs(null);
self._renderColors(null);
};
img.src=d.image;
self._renderEl(self._elData);
var search=document.getElementById('el-search');
if(search&&search.value)self._filterEl(search.value);
})
.catch(function(e){
if(container)container.innerHTML='<div class="el-shot-loading">请求失败: '+esc(e)+'</div>';
});
},
// 兼容旧调用名(工具栏「重新抓取」按钮)
_refreshShot:function(){ this._loadSnapshot(); },
// 在截图上渲染元素边界框(可点击)
_renderOverlays:function(){
var container=document.getElementById('el-shot-container');
var img=container.querySelector('img');
if(!img||!this._elData)return;
var self=this;
function draw(){
// 清除旧 overlay 和旧点击处理
container.querySelectorAll('.el-shot-overlay').forEach(function(o){o.remove();});
container.onclick=null;
var scaleX=img.clientWidth/img.naturalWidth;
var scaleY=img.clientHeight/img.naturalHeight;
// 图片在 container 里的偏移
var imgOffsetX=img.offsetLeft;
var imgOffsetY=img.offsetTop;
var imgW=img.naturalWidth, imgH=img.naturalHeight;
self._elData.forEach(function(el,i){
if(!el.bounds)return;
// bounds 格式 "[x1,y1][x2,y2]"
var m=el.bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
if(!m)return;
var x1=parseInt(m[1]),y1=parseInt(m[2]),x2=parseInt(m[3]),y2=parseInt(m[4]);
var w=(x2-x1)*scaleX;
var h=(y2-y1)*scaleY;
if(w<4||h<4)return; // 太小不画
// 跳过占满屏幕的容器:面积>90%屏幕,没有选择价值,还会盖住小元素
if((x2-x1)*(y2-y1) > imgW*imgH*0.9)return;
var ov=document.createElement('div');
ov.className='el-shot-overlay';
ov.setAttribute('data-idx',i);
ov.style.left=(imgOffsetX+x1*scaleX)+'px';
ov.style.top=(imgOffsetY+y1*scaleY)+'px';
ov.style.width=w+'px';
ov.style.height=h+'px';
ov.title=el.name||el.class||'';
container.appendChild(ov);
});
// 点击截图:按 bounds 命中测试,选中最小的(最深的)元素,
// 而不是被 DOM 层叠中的大容器/大兄弟挡住
container.onmousemove=function(e){
var im=container.querySelector('img');
if(!im||!self._elData)return;
var r=im.getBoundingClientRect();
var px=(e.clientX-r.left)/scaleX, py=(e.clientY-r.top)/scaleY;
var best=-1,bestArea=Infinity;
self._elData.forEach(function(el,i){
var mb=el.bounds&&el.bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
if(!mb)return;
var a=+mb[1],b=+mb[2],c=+mb[3],dd=+mb[4];
if(px>=a&&px<=c&&py>=b&&py<=dd){var ar=(c-a)*(dd-b); if(ar<bestArea){bestArea=ar;best=i;}}
});
self._updatePixel(px, py);
var hov=container.querySelector('.el-shot-hover');
if(best<0){ if(hov)hov.remove(); return; }
var b2=self._elData[best].bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
if(!hov){hov=document.createElement('div');hov.className='el-shot-hover';container.appendChild(hov);}
hov.style.left=(imgOffsetX+(+b2[1])*scaleX)+'px';
hov.style.top=(imgOffsetY+(+b2[2])*scaleY)+'px';
hov.style.width=((+b2[3])-(+b2[1]))*scaleX+'px';
hov.style.height=((+b2[4])-(+b2[2]))*scaleY+'px';
};
container.onmouseleave=function(){
var hov=container.querySelector('.el-shot-hover');
if(hov)hov.remove();
};
container.onclick=function(e){
var im=container.querySelector('img');
if(!im)return;
var r=im.getBoundingClientRect();
var px=(e.clientX-r.left)/scaleX; // 换算回原始分辨率坐标
var py=(e.clientY-r.top)/scaleY;
var best=-1,bestArea=Infinity;
self._elData.forEach(function(el,i){
var mb=el.bounds&&el.bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
if(!mb)return;
var a=parseInt(mb[1]),b=parseInt(mb[2]),c=parseInt(mb[3]),dd=parseInt(mb[4]);
if(px>=a&&px<=c&&py>=b&&py<=dd){
var area=(c-a)*(dd-b);
if(area<bestArea){bestArea=area;best=i;}
}
});
if(best>=0)self._highlightEl(best);
};
}
if(img.complete&&img.naturalWidth>0)draw();
else img.onload=draw;
},
// 高亮元素(截图框 + 列表项联动)
_highlightEl:function(i){
this._renderAttrs(i);
this._renderColors(i);
// 清除旧高亮(只在 el-picker-overlay 范围内查找)
var picker=document.getElementById('el-picker-overlay');
picker.querySelectorAll('.el-shot-overlay.active').forEach(function(o){o.classList.remove('active');});
picker.querySelectorAll('.el-picker-item.active').forEach(function(o){o.classList.remove('active');});
// 高亮当前(overlay 用 data-idx 查找,渲染时跳过了全屏容器,索引不再一一对应)
var overlay=picker.querySelector('.el-shot-overlay[data-idx="'+i+'"]');
if(overlay)overlay.classList.add('active');
// 列表项用 data-idx 查找(过滤后顺序可能不同)
var item=picker.querySelector('.el-picker-item[data-idx="'+i+'"]');
if(item){
item.classList.add('active');
item.scrollIntoView({block:'nearest',behavior:'smooth'});
}
// 信息栏显示将填入的选择器,确认是否精确命中目标
var infoEl=document.getElementById('el-shot-info');
if(infoEl&&this._elData&&this._elData[i]){
var sg=this._elData[i].suggested||{};
infoEl.textContent='已选中: '+(sg.type||'')+' '+(sg.value||'');
}
},
_renderEl:function(list){
var self=this;
var html=list.map(function(el){
var idx=self._elData.indexOf(el);
// 属性顺序有讲究:**文字/描述排最前并加粗**——它们是最稳的定位依据,
// 也是"一眼认出这是哪个元素"的凭据(id 只是补充)。见 doc/TASK_DEV.md §5.4。
var attrs=[];
if(el.text)attrs.push('<span class="ep-text">text:<b>'+esc(el.text)+'</b></span>');
if(el.description)attrs.push('<span class="ep-desc">desc:<b>'+esc(el.description)+'</b></span>');
if(el.resource_id)attrs.push('<span class="ep-id">id:'+esc(el.resource_id)+'</span>');
var indent=' '.repeat(Math.min(el.depth||0,6));
var sg=el.suggested||{};
if(sg.invalid)attrs.push('<span style="color:#dc2626">⚠ '+esc(sg.reason||'无可用属性')+'</span>');
var acts='<div class="ep-acts" style="margin-left:auto;display:flex;gap:4px;flex:none">'
+'<button class="btn btn-xs" title="直接在设备上点这个元素(按元素中心坐标点击,服务端自动吸附到可点元素)" '
+'onclick="event.stopPropagation();_stepEditor._testTap('+idx+')">▶ 点一下</button>'
+(!sg.invalid&&sg.value
? '<button class="btn btn-xs" title="用将填入的选择器在设备上真跑一次 click,验证是否命中" '
+'onclick="event.stopPropagation();_stepEditor._testSelector('+idx+')">✓ 测选择器</button>'
: '')
+'<button class="btn btn-xs" title="把这条的选择器填入步骤" '
+'onclick="event.stopPropagation();_stepEditor._fillEl('+idx+')">✓ 填入</button>'
+'</div>';
return '<div class="el-picker-item" data-idx="'+idx+'" title="'+esc(sg.value||'')+'" onclick="_stepEditor._pickEl('+idx+')">'+
'<span class="ep-depth">'+(el.depth||0)+'</span>'+
'<div class="ep-info"><div class="ep-name">'+indent+esc(el.name||el.class||'')+'</div>'+
'<div class="ep-attrs">'+attrs.join('')+'</div></div>'+
'<span class="ep-sel">'+esc(sg.type)+'</span>'+
(sg.semantic?' <span class="label label-success" title="语义选择器:用 '+esc(sg.via||'')+' 限定,只认「这个元素长什么样」,不认「同类元素有几个」—— 换设备、换版本、界面增减元素都照样命中">✓ 语义</span>':'')+
(sg.indexed?' <span class="label label-warning" title="⚠ 序号型:依赖「同属性元素共 '+sg.total+' 个、这是第 '+sg.occ+' 个」。界面上多/少一个同类元素(如抖音灰度版少一个 tab)就会指到别的元素上 —— 建议改选带 text 或 content-desc 的元素,拿到语义选择器">⚠ 序号 '+sg.occ+'/'+sg.total+'</span>':'')+
(sg.broad?' <span class="label label-warning" title="无唯一属性,选择器可能误匹配其他元素,建议选带文字/id 的元素">⚠宽泛</span>':'')+
acts+
'</div>';
}).join('');
document.getElementById('el-list').innerHTML=html||'<div class="se-empty">无元素</div>';
},
// 抓取时"直接点一下":按元素 bounds 中心在设备上点击(服务端 snap 吸附)
_testTap:function(idx){
var el=this._elData&&this._elData[idx]; var serial=this._elSerial;
if(!el||!serial)return;
var m=(el.bounds||'').match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
if(!m){ showToast('该元素没有 bounds,无法坐标点击','error'); return; }
var x=Math.round((+m[1]+ +m[3])/2), y=Math.round((+m[2]+ +m[4])/2);
var self=this;
apiPost('/api/screen/tap', {serial:serial, x:x, y:y, snap:1}).then(function(r){
if(!r||!r.ok){ showToast((r&&r.error)||'点击失败','error'); return; }
showToast('已点击'+(r.label?('「'+r.label+'」'):'')+' ('+r.x+','+r.y+')'+(r.snapped?' [已吸附]':''),'success');
setTimeout(function(){ if(self._elSerial===serial)self._refreshShot(); }, 800);
});
},
// 抓取时验证"将填入的选择器":走 /api/steps/test 真跑一次 click
_testSelector:function(idx){
var el=this._elData&&this._elData[idx]; var serial=this._elSerial;
if(!el||!serial)return;
var sg=el.suggested||{};
if(!sg.value){ showToast('该元素没有可用选择器','error'); return; }
var self=this;
var step={type:'click', label:'测试点击',
params:{selector_type:sg.type||'xpath', selector_value:sg.value, wait_timeout:2}};
apiPost('/api/steps/test', {serial:serial, step:step}).then(function(r){
if(!r||!r.ok){ showToast((r&&r.error)||'测试失败','error'); return; }
var ok=(r.result==='命中');
showToast('选择器测试: '+(r.result||'')+(r.msg?(' — '+r.msg):''), ok?'success':'error');
setTimeout(function(){ if(self._elSerial===serial)self._refreshShot(); }, 800);
});
},
_filterEl:function(kw){
if(!kw){this._renderEl(this._elData);this._renderOverlays();return;}
kw=kw.toLowerCase();
var f=this._elData.filter(function(el){
return (el.resource_id&&el.resource_id.toLowerCase().includes(kw))||
(el.text&&el.text.toLowerCase().includes(kw))||
(el.description&&el.description.toLowerCase().includes(kw));
});
this._renderEl(f);
},
// 点层级条目 = **选中**(看属性/颜色/高亮),不关窗不回填
_pickEl:function(i){
this._highlightEl(i);
},
// 「✓ 填入」才回填选择器并关窗
_fillEl:function(i){
var el=this._elData[i];
if(!el||!this._elTargetInput)return;
if(el.suggested&&el.suggested.invalid){
showToast(el.suggested.reason||'该元素无法生成选择器','error');
return;
}
// 填入推荐选择器值,并切换选择器类型
this._elTargetInput.value=el.suggested.value;
// 选择器类型下拉:通用编辑器里在同一个 .sc-body 内;别的编辑器(「发布计划」页的
// 步骤就地编辑)结构不同,取不到就跳过、交给下面的 _onFill 自己处理
var box=this._elTargetInput.closest('.sc-body');
var sel=box?box.querySelector('[data-key="selector_type"]'):null;
if(sel)sel.value=el.suggested.type;
// 程序赋值不会触发 input 事件,手动同步防丢失。
// 默认同步回通用编辑器的 _steps;**别的编辑器用 _onFill 接管**
// (见 static/admin/release.js 的就地编辑),否则会同步到不相干的模型上
if(typeof this._onFill==='function')this._onFill(el, this._elTargetInput);
else this._syncFromDom();
// 只关闭抓取元素弹窗,不影响任务编辑窗口
closeElPicker();
showToast('已填入: '+el.suggested.type+'='+el.suggested.value,'success');
}
};
async function saveTask(jobId){
const name=document.getElementById('f-name').value.trim();
if(!name){showToast('任务名不能为空','error');return;}
let params={};
const tt=document.getElementById('f-task_type').value;
if(tt==='generic_steps'){
// 通用步骤任务:从步骤编辑器收集
const steps=_stepEditor.collect();
if(!steps.length){showToast('请至少添加一个步骤','error');return;}
// 校验:click 步骤的 selector_value 不能为空
var warnings=_stepEditor.validate(steps);
if(warnings.length){
if(!confirm('步骤配置有问题:\n\n'+warnings.join('\n')+'\n\n确定要保存吗?'))return;
}
const mdEl=document.getElementById('f-max_duration');
params={max_duration:mdEl?parseInt(mdEl.value)||0:0,steps:steps};
// 去重有效期(任务级;「条件判断→去重」与「记为已做」共用这一套)
const drEl=document.getElementById('f-dedup_reset');
params.dedup_reset=drEl?drEl.value:'day';
if(drEl&&drEl.value==='hours'){
const dhEl=document.getElementById('f-dedup_hours');
params.dedup_hours=Math.max(1,parseInt(dhEl&&dhEl.value)||6);
}
// 公共巡检(任务级,独立于步骤画布;实现在 tasks.js + core/patrol.py)
if(typeof patrolCollect==='function'){
const ws=patrolCollect();
if(ws.length)params.watchers=ws;
}
}else{
try{params=JSON.parse(document.getElementById('f-params').value||'{}');}
catch(e){showToast('参数 JSON 格式错误','error');return;}
}
// 离线自动跳过(serial/group 模式):任务触发时跳过设备池中不在线的设备
const skipEl=document.getElementById('f-skip_offline');
params.skip_offline = skipEl?skipEl.checked:true;
// 抢占设备:触发时停止目标设备上的其他任务,接管执行
const preemptEl=document.getElementById('f-preempt');
params.preempt = preemptEl?preemptEl.checked:false;
const targetMode=document.getElementById('f-target_mode').value;
const target={mode:targetMode};
if(targetMode==='group')target.group_name=document.getElementById('f-target_group').value;
if(targetMode==='serial')target.serial=document.getElementById('f-target_serial').value;
const schedMode=document.getElementById('f-schedule_mode').value;
let schedule={mode:'once'};
if(schedMode==='cron'){
schedule={mode:'cron',cron:buildCronFromFreq()};
}else if(schedMode==='cron_stop'){
const st=(document.getElementById('f-schedule_stop_time').value||'18:00').split(':');
schedule={mode:'cron_stop',
cron:buildCronFromFreq(),
stop_cron:(parseInt(st[1])||0)+' '+(parseInt(st[0])||0)+' * * *'};
}
// 运行窗口:只约束定时触发(窗口外定时不启动),手动执行不受限制
if(document.getElementById('f-window_enable').checked){
schedule.window={
start:document.getElementById('f-window_start').value||'09:00',
end:document.getElementById('f-window_end').value||'18:00',
};
}
const data={
name:name,
task_type:document.getElementById('f-task_type').value,
enabled:document.getElementById('f-enabled').checked,
target:target,
params:params,
schedule:schedule,
retry:{max_attempts:Math.max(1,parseInt(document.getElementById('f-retry_attempts').value)||1),
delay:Math.max(10,parseInt(document.getElementById('f-retry_delay').value)||60)}
};
if(jobId){
const r=await apiPut('/api/jobs/'+jobId,data);
handleResult(r,()=>{_stepEditor.clearDraft();closeModal();loadTasks();});
}else{
const r=await apiPost('/api/jobs',data);
handleResult(r,()=>{_stepEditor.clearDraft();closeModal();loadTasks();});
}
}
async function deleteTask(jobId){
if(!confirm('确认删除此任务吗?'))return;
const r=await apiDelete('/api/jobs/'+jobId);
handleResult(r,()=>loadTasks());
}