语义消歧(B 项):主属性在整棵树里重复时,先找第二个属性把目标单独圈出来 ——
//*[@resource-id="x" and @text="我"] (次属性 text > content-desc > class,
单个不够就两两组合)
标 semantic:true + via;只有组合也分不开(列表里同 id 同文字)才退回
(//*[@resource-id="x"])[k] (标 indexed,前端黄标提醒脆弱)
抖音底部导航正是这个场景:tab 个数随灰度版本变(4 个 ↔ 3 个),序号必然错位。
顺带修掉两个结构性缺陷(给上面做验证时逐条 lxml 求值发现的,均非本次引入):
1. 结构步进把 class 当标签名 —— dump 的 XML 标签**一律是 <node>**,class 在
@class 上,所以 //FrameLayout[1]/… 这类路径**永远零命中**;改 *[@class="…"][n]
2. 兜底结构路径用 @index 定位兄弟 —— 实测同级 index 会重复(状态栏/内容区/
导航栏三个兄弟全是 index="0");改按子节点位置 //hierarchy/*[1]/*[2]
前端:抓取列表把 text/content-desc 排到最前并加粗上色(最稳的定位依据);
序号型从蓝标改**黄标 ⚠ 序号 k/n**,语义型给**绿标 ✓ 语义**;属性页新增
「选择器稳定性」一行说明这个选择器靠什么定位、会不会因界面变化失效。
真机实测(192.168.20.100,248 个元素):
精确命中目标 221 → 247 | 死选择器 26 → 0 | 语义型 0 → 26(序号型 141 → 115)
「我」的语义选择器经 /api/steps/test 真机点击 → 命中 ✓
文档:TASK_DEV §5.3/5.4(含两个 XPath 坑)、API §8(suggested 字段表 +
snapshot 行)、research/U2_ELEMENT_SELECTORS §五/§六、backlog ②标记完成。
1284 lines
68 KiB
JavaScript
1284 lines
68 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}},
|
||
{type:'swipe_until',label:'滑动直到元素',icon:'🔍',cat:'interact',params:{selector_type:'xpath',selector_value:'',direction:'up',max_swipes:8,click_when_found:true}},
|
||
{type:'input_text',label:'输入文字',icon:'⌨',cat:'interact',params:{mode:'random',texts:'你好\n有趣\n支持',fixed_text:'',clear_first:true}},
|
||
{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,then:[],else:[]}}
|
||
];
|
||
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识别 选项(图片/画布里的文字,仅条件判断支持)
|
||
function _selRowHtml(p, extra, withOcr){
|
||
var types=withOcr?['xpath','description','text','resourceId','descriptionContains','className','ocr']:
|
||
['xpath','description','text','resourceId','descriptionContains','className'];
|
||
var labels={xpath:'xpath',description:'description',text:'text',resourceId:'resourceId',
|
||
descriptionContains:'descriptionContains',className:'className',ocr:'OCR识别'};
|
||
var opts=types.map(function(d){
|
||
return '<option value="'+d+'"'+(p.selector_type===d?' selected':'')+'>'+labels[d]+'</option>';}).join('');
|
||
var toggle=withOcr?' onchange="_stepEditor._toggleOcrMode(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>':'';
|
||
return '<div class="form-row"><div class="form-group"><label>选择器类型</label><select class="form-control" data-key="selector_type"'+toggle+'>'+opts+'</select></div>'+
|
||
'<div class="form-group" style="grid-column:span 2"><label>选择器值</label><div class="sel-picker">'+
|
||
'<input type="text" class="form-control" data-key="selector_value" value="'+esc(p.selector_value||'')+'" placeholder="如 点赞">'+pick+test+
|
||
'</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(){});
|
||
}
|
||
|
||
var _stepEditor={
|
||
_steps:[],
|
||
_selected:[],
|
||
_dragSuppressClick:false,
|
||
init:function(steps,maxDuration,draftKey,canvasId,libId){
|
||
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);
|
||
}
|
||
},
|
||
_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>';
|
||
}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>'+
|
||
'<div class="form-group form-check"><input type="checkbox" data-key="click_when_found" '+(p.click_when_found!==false?'checked':'')+'><label>找到后点击</label></div>'+
|
||
'<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==='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>';
|
||
}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';
|
||
h+='<div class="form-group"><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:'+(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:'+(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'){
|
||
h+=_selRowHtml(p,
|
||
'<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>'+
|
||
'<div class="help">超时内找到元素 → 执行下方"✅ 找到时"分支;未找到 → 执行"❌ 未找到时"分支。两分支可放任意步骤,也可再嵌套条件判断/循环;'+
|
||
'<b>OCR识别</b>可匹配图片/画布里的文字(截屏识别,跨平台)</div>', true);
|
||
}
|
||
// 共享:触发概率(所有步骤通用,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){
|
||
return {id:_newStepId(),type:lib.type,label:lib.label,params:JSON.parse(JSON.stringify(lib.params))};
|
||
},
|
||
// 测试此步骤:在选中设备上试执行当前步骤,验证选择器
|
||
_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 '<div class="el-device-card" onclick="_stepEditor._runTestStep(\''+esc(d.serial)+'\')">'+
|
||
'<div class="edc-icon">📱</div>'+
|
||
'<div class="edc-info"><div class="edc-serial">'+esc(d.serial)+'</div>'+
|
||
'<div class="edc-model">'+esc(d.model||d.product||'')+'</div></div></div>';
|
||
}).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';
|
||
});
|
||
},
|
||
// 条件判断:选择器类型切到 OCR 时显示"命中后点击"开关
|
||
_toggleOcrMode:function(sel){
|
||
var card=sel.closest('.step-card');
|
||
var f=card&&card.querySelector('[data-ocr-field]');
|
||
if(f)f.style.display=sel.value==='ocr'?'block':'none';
|
||
},
|
||
// 给数组分配唯一 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=[];
|
||
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==='if_el'){
|
||
if(!s.params.selector_value){
|
||
warnings.push(name+' "'+label+'": 条件判断的选择器为空,会直接走"未找到"分支。');
|
||
}
|
||
}
|
||
_childArrs(s).forEach(function(a){
|
||
if(a.length)check(a,name+' 子步骤');
|
||
});
|
||
});
|
||
}
|
||
check(steps,'');
|
||
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 内,不会与上面的直接子级选择器冲突)
|
||
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();
|
||
},
|
||
// 步骤1:设备选择页
|
||
_showDevicePicker:function(){
|
||
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 '<div class="el-device-card" onclick="_stepEditor._showElPicker(\''+esc(d.serial)+'\')">'+
|
||
'<div class="edc-icon">📱</div>'+
|
||
'<div class="edc-info"><div class="edc-serial">'+esc(d.serial)+'</div>'+
|
||
'<div class="edc-model">'+esc(d.model||d.product||'')+'</div></div>'+
|
||
'<span class="edc-status">'+esc(d.status||'device')+'</span></div>';
|
||
}).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;
|
||
var sel=this._elTargetInput.closest('.sc-body').querySelector('[data-key="selector_type"]');
|
||
if(sel)sel.value=el.suggested.type;
|
||
// 程序赋值不会触发 input 事件,手动同步回 _steps 防丢失
|
||
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};
|
||
}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());
|
||
}
|
||
|