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:
@@ -258,3 +258,88 @@ def _extract(root, out):
|
|||||||
|
|
||||||
count_attrs(root)
|
count_attrs(root)
|
||||||
flatten(root)
|
flatten(root)
|
||||||
|
|
||||||
|
|
||||||
|
# ================== 原生 u2 快照(截图 + 元素树一次取齐) ==================
|
||||||
|
def _xml_to_node(elem):
|
||||||
|
"""把 uiautomator2 的 XML 节点转成 uiautodev 那套 {name, properties, children}。
|
||||||
|
|
||||||
|
两边的属性名本来就一样(resource-id / text / content-desc / class / bounds 字符串),
|
||||||
|
所以只要套一层壳,就能原样复用上面的 _extract(选择器建议逻辑不用写第二遍)。
|
||||||
|
"""
|
||||||
|
props = dict(elem.attrib)
|
||||||
|
return {"name": props.get("class", ""), "properties": props,
|
||||||
|
"children": [_xml_to_node(c) for c in list(elem)]}
|
||||||
|
|
||||||
|
|
||||||
|
def _shots_differ(a, b, threshold=8):
|
||||||
|
"""两张截图是不是"明显不一样"(用来判断抓取期间界面有没有在动)。
|
||||||
|
|
||||||
|
逐像素求差的包围盒 → 用"变化区域占比"判断,避免个别像素抖动误报。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from PIL import ImageChops
|
||||||
|
if a.size != b.size:
|
||||||
|
return True
|
||||||
|
diff = ImageChops.difference(a.convert("RGB"), b.convert("RGB"))
|
||||||
|
bbox = diff.getbbox()
|
||||||
|
if not bbox:
|
||||||
|
return False
|
||||||
|
area = (bbox[2] - bbox[0]) * (bbox[3] - bbox[1])
|
||||||
|
total = a.size[0] * a.size[1]
|
||||||
|
# 变化面积超过阈值(默认 8%)才算"界面在动"
|
||||||
|
return (area / total) * 100.0 > threshold
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot(serial, quality=85):
|
||||||
|
"""一次取齐:设备截图 + 元素树(**同一个 u2 连接、背靠背取**)。
|
||||||
|
|
||||||
|
为什么不用现在的"两个接口":截图和元素树分两次取时,中间隔着 dump 本身的
|
||||||
|
1.3~1.8 秒;界面只要在动画(信息流/视频/加载),框就会落在旧位置上。
|
||||||
|
|
||||||
|
顺带做**双截图校验**:dump 前后各截一张,两张差得多就标 `unstable`,
|
||||||
|
让前端明确提示"界面在变化中,请停在静止界面再抓"——而不是悄悄给一个错位的框。
|
||||||
|
|
||||||
|
返回 (ok, 数据 | 错误信息)。数据形如:
|
||||||
|
{"image": "data:image/jpeg;base64,…", "width": 720, "height": 1650,
|
||||||
|
"elements": [...], "unstable": false, "cost_ms": 1800}
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import time as _t
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
import uiautomator2 as u2
|
||||||
|
|
||||||
|
t0 = _t.time()
|
||||||
|
try:
|
||||||
|
d = u2.connect(serial)
|
||||||
|
img1 = d.screenshot() # 先截:用户看到的就是这一刻
|
||||||
|
xml = d.dump_hierarchy() # 慢的一步(1.3~1.8s)
|
||||||
|
img2 = d.screenshot() # 再截:和第一张比对
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"抓取失败: {e}"
|
||||||
|
|
||||||
|
unstable = _shots_differ(img1, img2)
|
||||||
|
if not unstable:
|
||||||
|
img2.close()
|
||||||
|
try:
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img1.convert("RGB").save(buf, format="JPEG", quality=quality)
|
||||||
|
image = "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
|
||||||
|
w, h = img1.size
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"截图编码失败: {e}"
|
||||||
|
|
||||||
|
elements = []
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(xml)
|
||||||
|
_extract(_xml_to_node(root), elements)
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"元素树解析失败: {e}"
|
||||||
|
|
||||||
|
return True, {"image": image, "width": w, "height": h,
|
||||||
|
"elements": elements, "unstable": unstable,
|
||||||
|
"cost_ms": int((_t.time() - t0) * 1000)}
|
||||||
|
|||||||
@@ -127,6 +127,7 @@
|
|||||||
| GET | `/api/uiauto/status` | L | uiautodev 服务是否在跑 |
|
| GET | `/api/uiauto/status` | L | uiautodev 服务是否在跑 |
|
||||||
| GET | `/api/uiauto/devices` | D | 抓元素可选设备 |
|
| GET | `/api/uiauto/devices` | D | 抓元素可选设备 |
|
||||||
| GET | `/api/uiauto/elements` | D | 设备 UI 元素树 |
|
| GET | `/api/uiauto/elements` | D | 设备 UI 元素树 |
|
||||||
|
| GET | `/api/uiauto/snapshot` | D | **一次取齐**截图 + 元素树(原生 u2,抓取弹窗用) |
|
||||||
| GET | `/api/uiauto/screenshot` | D | uiautodev 截图(JPEG) |
|
| GET | `/api/uiauto/screenshot` | D | uiautodev 截图(JPEG) |
|
||||||
| POST | `/api/steps/test` | D | 真机试执行单个步骤 |
|
| POST | `/api/steps/test` | D | 真机试执行单个步骤 |
|
||||||
|
|
||||||
|
|||||||
@@ -109,12 +109,14 @@
|
|||||||
界面**只要在动**(抖音信息流、视频、加载动画…),两秒足够让元素位置全变 →
|
界面**只要在动**(抖音信息流、视频、加载动画…),两秒足够让元素位置全变 →
|
||||||
**框永远落在旧位置上** —— 这就是"老是错位",不是偶发。
|
**框永远落在旧位置上** —— 这就是"老是错位",不是偶发。
|
||||||
|
|
||||||
### 解法
|
### 解法(✅ 2026-09-13 已实现,见 `core/uiauto_helper.snapshot`)
|
||||||
|
|
||||||
1. **一次请求取齐**(推荐,也是用户说的"用原始 u2"):
|
1. **一次请求取齐**(推荐,也是用户说的"用原始 u2"):
|
||||||
新增 `GET /api/uiauto/snapshot?serial=X`,服务端用**一个 u2 连接**背靠背
|
新增 `GET /api/uiauto/snapshot?serial=X`,服务端用**一个 u2 连接**背靠背
|
||||||
`dump_hierarchy()` + `screenshot()`,返回 `{image, elements}`;
|
`dump_hierarchy()` + `screenshot()`,返回 `{image, elements}`;
|
||||||
前端只调这一个接口 → 天然同源,间隔从 ~2.2s 降到 ~1.4s。
|
前端只调这一个接口 → 天然同源,间隔从 ~2.2s 降到 ~1.4s。
|
||||||
|
**并且抓取弹窗改成大图 1:1 预览**(原来预览列只有 320px、图片被缩到 294px,
|
||||||
|
框全挤在一起,看着就像"错位";现在铺满左侧、按原始分辨率显示)。
|
||||||
2. **双截图校验**(关键:让失败可见而不是悄悄错位):
|
2. **双截图校验**(关键:让失败可见而不是悄悄错位):
|
||||||
抓取时前后各截一张,两张**不一致就明确提示**"界面在抓取过程中变化了,
|
抓取时前后各截一张,两张**不一致就明确提示**"界面在抓取过程中变化了,
|
||||||
请让设备停在目标界面再抓",而不是给一个已经错位的框。
|
请让设备停在目标界面再抓",而不是给一个已经错位的框。
|
||||||
|
|||||||
+71
-53
@@ -831,7 +831,7 @@ var _stepEditor={
|
|||||||
'<div class="el-picker-main">'+
|
'<div class="el-picker-main">'+
|
||||||
'<div class="el-shot-wrap">'+
|
'<div class="el-shot-wrap">'+
|
||||||
'<div class="el-shot-toolbar">'+
|
'<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>'+
|
'<span class="el-shot-info" id="el-shot-info">加载中...</span>'+
|
||||||
'</div>'+
|
'</div>'+
|
||||||
'<div class="el-shot-container" id="el-shot-container">'+
|
'<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 class="text-muted" style="margin-left:10px;font-size:11.5px">'
|
||||||
+'提示:先「▶ 点一下」在设备上验证位置,再点元素回填选择器;'
|
+'提示:先「▶ 点一下」在设备上验证位置,再点元素回填选择器;'
|
||||||
+'「✓ 测选择器」会用将填入的选择器真跑一次点击</span>';
|
+'「✓ 测选择器」会用将填入的选择器真跑一次点击</span>';
|
||||||
// 加载截图和元素树(_refreshShot 内部同时刷新两者)
|
// 一次取齐截图 + 元素树(见 _loadSnapshot 的说明)
|
||||||
self._refreshShot();
|
self._loadSnapshot();
|
||||||
},
|
},
|
||||||
// 拉取元素树(进入页面和刷新时共用;带时间戳防缓存)
|
// 一次取齐:截图 + 元素树(后端用原生 u2 背靠背取)
|
||||||
_loadElData:function(){
|
// 为什么不再分两个接口:截图与元素树分两次拿,中间隔着 dump 本身的 1.3~1.8 秒,
|
||||||
var serial=this._elSerial;
|
// 界面只要在动(信息流/视频/加载),框就会落在旧位置上("抓取错位")。
|
||||||
if(!serial)return;
|
_loadSnapshot:function(){
|
||||||
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(){
|
|
||||||
var serial=this._elSerial;
|
var serial=this._elSerial;
|
||||||
if(!serial)return;
|
if(!serial)return;
|
||||||
var container=document.getElementById('el-shot-container');
|
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');
|
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 self=this;
|
||||||
var img=new Image();
|
fetch('/api/uiauto/snapshot?serial='+encodeURIComponent(serial)+'&_='+Date.now())
|
||||||
img.onload=function(){
|
.then(function(r){return r.json();})
|
||||||
container.innerHTML='';
|
.then(function(d){
|
||||||
container.appendChild(img);
|
if(!d.ok){
|
||||||
var infoEl=document.getElementById('el-shot-info');
|
if(container)container.innerHTML='<div class="el-shot-loading">'+esc(d.error||'抓取失败')+'</div>';
|
||||||
if(infoEl)infoEl.textContent=img.naturalWidth+'x'+img.naturalHeight;
|
if(info)info.textContent='失败';
|
||||||
// 截图加载完后重新渲染元素边界框
|
return;
|
||||||
self._renderOverlays();
|
}
|
||||||
};
|
self._elData=d.elements||[];
|
||||||
img.onerror=function(){
|
if(d.unstable){
|
||||||
container.innerHTML='<div class="el-shot-loading">截图加载失败</div>';
|
var w=document.createElement('div');
|
||||||
var infoEl=document.getElementById('el-shot-info');
|
w.className='el-shot-warn';
|
||||||
if(infoEl)infoEl.textContent='失败';
|
w.innerHTML='⚠ 抓取期间界面有变化,框可能对不上 —— 建议把设备停在<b>静止界面</b>后点「重新抓取」';
|
||||||
};
|
container.parentNode.insertBefore(w, container);
|
||||||
img.src='/api/uiauto/screenshot?serial='+encodeURIComponent(serial)+'&_='+Date.now();
|
}
|
||||||
// 同时重新拉取元素树——只刷截图会导致"截图是新的、元素框是旧的"错位
|
var img=new Image();
|
||||||
this._loadElData();
|
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(){
|
_renderOverlays:function(){
|
||||||
var container=document.getElementById('el-shot-container');
|
var container=document.getElementById('el-shot-container');
|
||||||
@@ -947,6 +940,31 @@ var _stepEditor={
|
|||||||
});
|
});
|
||||||
// 点击截图:按 bounds 命中测试,选中最小的(最深的)元素,
|
// 点击截图:按 bounds 命中测试,选中最小的(最深的)元素,
|
||||||
// 而不是被 DOM 层叠中的大容器/大兄弟挡住
|
// 而不是被 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){
|
container.onclick=function(e){
|
||||||
var im=container.querySelector('img');
|
var im=container.querySelector('img');
|
||||||
if(!im)return;
|
if(!im)return;
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
|||||||
.el-device-card .edc-model{font-size:12px;color:var(--text-light);margin-top:2px}
|
.el-device-card .edc-model{font-size:12px;color:var(--text-light);margin-top:2px}
|
||||||
.el-device-card .edc-status{font-size:11px;padding:2px 10px;border-radius:99px;background:var(--success-soft);color:#15803d;flex-shrink:0;font-weight:600}
|
.el-device-card .edc-status{font-size:11px;padding:2px 10px;border-radius:99px;background:var(--success-soft);color:#15803d;flex-shrink:0;font-weight:600}
|
||||||
.el-picker-main{display:flex;flex:1;overflow:hidden}
|
.el-picker-main{display:flex;flex:1;overflow:hidden}
|
||||||
.el-shot-wrap{flex:0 0 320px;border-right:1px solid var(--border);background:var(--ink);display:flex;flex-direction:column}
|
.el-shot-wrap{flex:1 1 auto;min-width:0;border-right:1px solid var(--border);background:var(--ink);display:flex;flex-direction:column}
|
||||||
.el-shot-toolbar{padding:6px 10px;background:var(--ink-2);display:flex;gap:6px;align-items:center;border-bottom:1px solid var(--ink-line)}
|
.el-shot-toolbar{padding:6px 10px;background:var(--ink-2);display:flex;gap:6px;align-items:center;border-bottom:1px solid var(--ink-line)}
|
||||||
.el-shot-toolbar .btn{padding:3px 10px;font-size:12px;background:#39414d;color:#e5e7eb;border:none;box-shadow:none}
|
.el-shot-toolbar .btn{padding:3px 10px;font-size:12px;background:#39414d;color:#e5e7eb;border:none;box-shadow:none}
|
||||||
.el-shot-toolbar .btn:hover{background:#4b5563}
|
.el-shot-toolbar .btn:hover{background:#4b5563}
|
||||||
@@ -297,10 +297,12 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
|||||||
.el-shot-container{flex:1;overflow:auto;display:flex;align-items:flex-start;justify-content:center;padding:8px;position:relative}
|
.el-shot-container{flex:1;overflow:auto;display:flex;align-items:flex-start;justify-content:center;padding:8px;position:relative}
|
||||||
.el-shot-container img{max-width:100%;height:auto;border-radius:4px;display:block}
|
.el-shot-container img{max-width:100%;height:auto;border-radius:4px;display:block}
|
||||||
.el-shot-container .el-shot-loading{color:#8a93a0;font-size:13px;padding:40px}
|
.el-shot-container .el-shot-loading{color:#8a93a0;font-size:13px;padding:40px}
|
||||||
.el-shot-overlay{position:absolute;border:1px solid rgba(103,232,249,.35);background:transparent;cursor:pointer;border-radius:2px;transition:all .1s}
|
.el-shot-overlay{position:absolute;border:1px solid rgba(103,232,249,.45);background:transparent;cursor:pointer;border-radius:2px;transition:all .1s}
|
||||||
|
.el-shot-hover{position:absolute;border:2px solid rgba(248,113,113,.9);background:rgba(248,113,113,.15);pointer-events:none;border-radius:2px;z-index:5}
|
||||||
|
.el-shot-warn{background:#fff3cd;border-bottom:1px solid #ffda6a;color:#7a5b00;font-size:12px;padding:6px 10px}
|
||||||
.el-shot-overlay:hover{border-color:#67e8f9;background:rgba(103,232,249,.18)}
|
.el-shot-overlay:hover{border-color:#67e8f9;background:rgba(103,232,249,.18)}
|
||||||
.el-shot-overlay.active{border:2px solid #f87171;background:rgba(248,113,113,.22)}
|
.el-shot-overlay.active{border:2px solid #f87171;background:rgba(248,113,113,.22)}
|
||||||
.el-list-wrap{flex:1;display:flex;flex-direction:column;overflow:hidden}
|
.el-list-wrap{flex:0 0 400px;display:flex;flex-direction:column;overflow:hidden;border-left:1px solid var(--border)}
|
||||||
.el-picker-search{width:100%;padding:8px 12px;border:none;border-bottom:1px solid var(--border);font-size:13px;outline:none;font-family:var(--body)}
|
.el-picker-search{width:100%;padding:8px 12px;border:none;border-bottom:1px solid var(--border);font-size:13px;outline:none;font-family:var(--body)}
|
||||||
.el-picker-list{flex:1;overflow-y:auto}
|
.el-picker-list{flex:1;overflow-y:auto}
|
||||||
.el-picker-item{padding:8px 12px;border-bottom:1px solid var(--border);cursor:pointer;font-size:12px;display:flex;gap:8px;align-items:flex-start}
|
.el-picker-item{padding:8px 12px;border-bottom:1px solid var(--border);cursor:pointer;font-size:12px;display:flex;gap:8px;align-items:flex-start}
|
||||||
@@ -1042,7 +1044,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
|||||||
|
|
||||||
<!-- ========== 元素抓取模态框(独立第二层,不覆盖任务编辑窗口)========== -->
|
<!-- ========== 元素抓取模态框(独立第二层,不覆盖任务编辑窗口)========== -->
|
||||||
<div id="el-picker-overlay" class="modal-overlay" style="z-index:1100" onclick="if(event.target===this)closeElPicker()">
|
<div id="el-picker-overlay" class="modal-overlay" style="z-index:1100" onclick="if(event.target===this)closeElPicker()">
|
||||||
<div class="modal" id="el-picker-box" style="max-width:900px">
|
<div class="modal" id="el-picker-box" style="max-width:1280px;width:96vw">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3 id="el-picker-title">抓取元素</h3>
|
<h3 id="el-picker-title">抓取元素</h3>
|
||||||
<button class="close" onclick="closeElPicker()">×</button>
|
<button class="close" onclick="closeElPicker()">×</button>
|
||||||
|
|||||||
@@ -358,6 +358,30 @@ def api_uiauto_devices():
|
|||||||
_log.info(f"抓取元素设备列表补全 {added} 台在线设备: {list(seen)}")
|
_log.info(f"抓取元素设备列表补全 {added} 台在线设备: {list(seen)}")
|
||||||
return jsonify({"ok": True, "devices": data})
|
return jsonify({"ok": True, "devices": data})
|
||||||
|
|
||||||
|
@bp.route("/api/uiauto/snapshot")
|
||||||
|
@perm_required(PERM_DEVICES)
|
||||||
|
def api_uiauto_snapshot():
|
||||||
|
"""一次取齐:设备截图 + 元素树(**原生 u2,同一个连接背靠背取**)。
|
||||||
|
|
||||||
|
抓取元素时用它代替「/api/uiauto/screenshot + /api/uiauto/elements」两连击:
|
||||||
|
那两条是两次独立取数,中间隔着 dump 本身的 1.3~1.8 秒 —— 界面只要在动
|
||||||
|
(信息流/视频/加载动画),框就会落在旧位置上("抓取错位")。
|
||||||
|
同一个连接背靠背取把这个窗口压到最小,并额外用**双截图校验**标出
|
||||||
|
"抓取期间界面在变化"(`unstable`),前端据此明确提示而不是悄悄给错位的框。
|
||||||
|
|
||||||
|
?serial=xxx
|
||||||
|
→ {ok, image(data-url), width, height, elements:[...], unstable, cost_ms}
|
||||||
|
"""
|
||||||
|
serial = request.args.get("serial", "")
|
||||||
|
if not serial:
|
||||||
|
return jsonify({"ok": False, "error": "缺少 serial"}), 400
|
||||||
|
ok, data = uiauto_helper.snapshot(serial)
|
||||||
|
if not ok:
|
||||||
|
# 设备没连上/抓取失败:502(依赖不可用),与 /api/uiauto/elements 一致
|
||||||
|
return jsonify({"ok": False, "error": data}), 502
|
||||||
|
return jsonify({"ok": True, **data})
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/uiauto/screenshot")
|
@bp.route("/api/uiauto/screenshot")
|
||||||
@perm_required(PERM_DEVICES)
|
@perm_required(PERM_DEVICES)
|
||||||
def api_uiauto_screenshot():
|
def api_uiauto_screenshot():
|
||||||
|
|||||||
Reference in New Issue
Block a user