一、平台侧(账号 → 发布计划页) - 新表 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
1617 lines
94 KiB
JavaScript
1617 lines
94 KiB
JavaScript
// 步骤编辑器: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 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,<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());
|
||
}
|
||
|