feat(抓取): 元素检查器改成本地版"大图 + 一次取齐"——复刻云检查器的体验

用户要的是 uiauto.dev 那个检查器的体验(左边大图点元素、右边层级树、选完回填),
并希望本地复刻(云页面跨域,拿不到它的选中结果,没法自动回传)。

- core/uiauto_helper.py: 新增 `snapshot(serial)`
  * **一个 u2 连接背靠背** dump_hierarchy() + screenshot():截图与元素树同源同刻,
    不再像原来那样分两个接口取(中间隔着 dump 本身的 1.3~1.8 秒)
  * `_xml_to_node()`:把 u2 的 XML 节点转成 uiautodev 那套结构,直接复用既有的
    选择器建议逻辑(不写第二遍)
  * **双截图校验**:dump 前后各截一张,差异明显就标 `unstable`,让前端明确提示
    "界面在变化中,请停在静止界面再抓",而不是悄悄给一个可能错位的框
- web/tasks_api.py: 新增 `GET /api/uiauto/snapshot`(登录 + 设备权限)
- static/admin/editor.js + templates/admin/monitor.html:
  * 抓取弹窗从 900px 加宽到 1280px,预览列从"固定 320px"改为铺满左侧 →
    **图片按原始分辨率 1:1 显示**(实测 720x1650 缩放 1.000),元素框严丝合缝;
    原来缩到 294px 时框全挤在一起,看着就像错位
  * 工具栏显示 `720x1650 · 247 个元素 · 2370ms`;界面在变时顶部弹黄色提示条
  * 鼠标在图上移动时高亮"最深命中"的元素框,便于确认真要点哪个

实测:抓取弹窗 1:1 显示、222 个框逐一贴合元素、无 JS 报错;
`unstable` 在静止界面为 false。
This commit is contained in:
2026-09-13 21:17:12 +08:00
parent accd06df4b
commit 37a2b1c59b
6 changed files with 190 additions and 58 deletions
+71 -53
View File
@@ -831,7 +831,7 @@ var _stepEditor={
'<div class="el-picker-main">'+
'<div class="el-shot-wrap">'+
'<div class="el-shot-toolbar">'+
'<button class="btn" onclick="_stepEditor._refreshShot()" title="重新拉取截图和元素树(界面变化后刷新用)">刷新截图+元素</button>'+
'<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">'+
@@ -849,65 +849,58 @@ var _stepEditor={
'<span class="text-muted" style="margin-left:10px;font-size:11.5px">'
+'提示:先「▶ 点一下」在设备上验证位置,再点元素回填选择器;'
+'「✓ 测选择器」会用将填入的选择器真跑一次点击</span>';
// 加载截图和元素树(_refreshShot 内部同时刷新两者)
self._refreshShot();
// 一次取齐截图 + 元素树(见 _loadSnapshot 的说明)
self._loadSnapshot();
},
// 拉取元素树(进入页面和刷新时共用;带时间戳防缓存)
_loadElData:function(){
var serial=this._elSerial;
if(!serial)return;
var listEl=document.getElementById('el-list');
if(listEl)listEl.innerHTML='<div class="se-empty">加载元素树...</div>';
var self=this;
fetch('/api/uiauto/elements?serial='+encodeURIComponent(serial)+'&_='+Date.now())
.then(function(r){return r.json();})
.then(function(data){
var le=document.getElementById('el-list');
if(!le)return;
if(!data.ok){
le.innerHTML='<div class="se-empty">'+esc(data.error||'抓取失败')+'</div>';
return;
}
self._elData=data.elements||[];
self._renderEl(self._elData);
// 刷新后保持搜索过滤状态
var search=document.getElementById('el-search');
if(search&&search.value)self._filterEl(search.value);
self._renderOverlays();
})
.catch(function(e){
var le=document.getElementById('el-list');
if(le)le.innerHTML='<div class="se-empty">请求失败: '+esc(e)+'</div>';
});
},
// 刷新截图
_refreshShot:function(){
// 一次取齐:截图 + 元素树(后端用原生 u2 背靠背取)
// 为什么不再分两个接口:截图与元素树分两次拿,中间隔着 dump 本身的 1.3~1.8 秒,
// 界面只要在动(信息流/视频/加载),框就会落在旧位置上("抓取错位")。
_loadSnapshot:function(){
var serial=this._elSerial;
if(!serial)return;
var container=document.getElementById('el-shot-container');
if(!container)return;
container.innerHTML='<div class="el-shot-loading">正在获取截图...</div>';
var info=document.getElementById('el-shot-info');
if(info)info.textContent='加载中...';
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;
var img=new Image();
img.onload=function(){
container.innerHTML='';
container.appendChild(img);
var infoEl=document.getElementById('el-shot-info');
if(infoEl)infoEl.textContent=img.naturalWidth+'x'+img.naturalHeight;
// 截图加载完后重新渲染元素边界框
self._renderOverlays();
};
img.onerror=function(){
container.innerHTML='<div class="el-shot-loading">截图加载失败</div>';
var infoEl=document.getElementById('el-shot-info');
if(infoEl)infoEl.textContent='失败';
};
img.src='/api/uiauto/screenshot?serial='+encodeURIComponent(serial)+'&_='+Date.now();
// 同时重新拉取元素树——只刷截图会导致"截图是新的、元素框是旧的"错位
this._loadElData();
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?' · ⚠ 界面在变化':'');
}
self._renderOverlays();
};
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');
@@ -947,6 +940,31 @@ var _stepEditor={
});
// 点击截图:按 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;}}
});
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;