fix: 元素选择器序号语义 + 抓取弹窗直接点击测试

- uiauto_helper: 同属性多实例的选择器由 `//*[@id="x"][k]` 改为 `(//*[@id="x"])[k]`——
  前者在 XPath 里是"父节点内排第 k",多实例时 [2..n] 全部失配(实测抖音底部 4 个同 id tab:
  仅 [1] 可用),任务里表现为"未找到元素"但界面上元素明明存在。
- tasks/generic/task.py: 新增 _norm_legacy_xpath,执行前把**历史遗留**的
  `//*[@attr=…][k]` 窄范围纠正为带括号形式(只改前缀,结构路径 …/FrameLayout[2] 的
  兄弟序号保持不动)——已存任务无需重抓即可恢复。
- editor.js: 抓取弹窗每条元素新增「▶ 点一下」(按 bounds 中心真点一次,/api/screen/tap
  snap=1 吸附)与「✓ 测选择器」(用将填入的选择器跑 /api/steps/test 验证命中),
  点击后自动刷新截图;底部加用法提示。
- doc/TASK_DEV.md:写明 xpath 序号必须整体加括号 + 旧形态自动纠正 + 优先文字/唯一 id。
This commit is contained in:
2026-09-10 15:31:33 +08:00
parent 8072f4a380
commit 3f44cd1491
4 changed files with 89 additions and 8 deletions
+43 -1
View File
@@ -845,7 +845,10 @@ var _stepEditor={
'</div>';
document.getElementById('el-picker-footer').innerHTML=
'<button class="btn" onclick="_stepEditor._showDevicePicker()">← 返回选设备</button>'+
'<button class="btn" onclick="closeElPicker()">取消</button>';
'<button class="btn" onclick="closeElPicker()">取消</button>'+
'<span class="text-muted" style="margin-left:10px;font-size:11.5px">'
+'提示:先「▶ 点一下」在设备上验证位置,再点元素回填选择器;'
+'「✓ 测选择器」会用将填入的选择器真跑一次点击</span>';
// 加载截图和元素树(_refreshShot 内部同时刷新两者)
self._refreshShot();
},
@@ -999,6 +1002,14 @@ var _stepEditor={
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>'
: '')
+'</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>'+
@@ -1006,10 +1017,41 @@ var _stepEditor={
'<span class="ep-sel">'+esc(sg.type)+'</span>'+
(sg.indexed?' <span class="label label-info" title="界面中同属性元素共 '+sg.total+' 个,按从上到下这是第 '+sg.occ+' 个,选择器已带序号精确定位">#'+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();