Compare commits
5
Commits
6fd9e6f8f3
...
4ea777a55a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ea777a55a | ||
|
|
37a2b1c59b | ||
|
|
accd06df4b | ||
|
|
ee559786e7 | ||
|
|
a94f63f0bf |
@@ -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 | 真机试执行单个步骤 |
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
| [STF_REMOVAL.md](STF_REMOVAL.md) | **历史记录**:摘除 OpenSTF 的迁移过程(阶段 0-3) | 追溯背景时参考 |
|
| [STF_REMOVAL.md](STF_REMOVAL.md) | **历史记录**:摘除 OpenSTF 的迁移过程(阶段 0-3) | 追溯背景时参考 |
|
||||||
| [staffdeck/KNOWLEDGE_BASE.md](staffdeck/KNOWLEDGE_BASE.md) | 给 StaffDeck 数字员工的知识库(MCP 接入/工具/约定/红线) | 外部 AI 接入方 |
|
| [staffdeck/KNOWLEDGE_BASE.md](staffdeck/KNOWLEDGE_BASE.md) | 给 StaffDeck 数字员工的知识库(MCP 接入/工具/约定/红线) | 外部 AI 接入方 |
|
||||||
| [staffdeck/JOB_SPEC.md](staffdeck/JOB_SPEC.md) | 数字员工岗位说明(岗位描述/看板摘要/执行约束) | 外部 AI 接入方 |
|
| [staffdeck/JOB_SPEC.md](staffdeck/JOB_SPEC.md) | 数字员工岗位说明(岗位描述/看板摘要/执行约束) | 外部 AI 接入方 |
|
||||||
|
| [research/U2_ELEMENT_SELECTORS.md](research/U2_ELEMENT_SELECTORS.md) | **元素选择器研究**:「点不到按钮」的根因(序号型选择器随界面变形而错位)与语义选择器解法 | 写任务/抓元素的开发 |
|
||||||
| [backlog/TODO.md](backlog/TODO.md) | 已确认但暂缓的待办(含已知问题) | 所有开发者 |
|
| [backlog/TODO.md](backlog/TODO.md) | 已确认但暂缓的待办(含已知问题) | 所有开发者 |
|
||||||
|
|
||||||
项目根目录的 [README.md](../README.md) 是**项目总览与快速上手**(面向第一次接触项目的人),细节都在本目录。
|
项目根目录的 [README.md](../README.md) 是**项目总览与快速上手**(面向第一次接触项目的人),细节都在本目录。
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# 元素选择器研究:为什么"点不到按钮"(2026-09-13)
|
||||||
|
|
||||||
|
> 背景:任务步骤(`click` 等)频繁出现"元素明明在屏幕上却点不到"。用户怀疑是
|
||||||
|
> 任务执行用的 u2 与元素抓取用的 uiautodev 两条通道不一致,要求研究。
|
||||||
|
> 分支:`research/raw-u2-elements`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、先否掉一个假设:两条通道**并不**冲突
|
||||||
|
|
||||||
|
| | 用的东西 | 连接方式 |
|
||||||
|
|---|---|---|
|
||||||
|
| 任务执行(点击/输入…)| 原生 `u2.connect(serial)` | 直连设备的 uiautomator2 server |
|
||||||
|
| 元素抓取(编辑器按钮)| `uiautodev` 服务(localhost:20242)→ `/api/android/{serial}/hierarchy` | 它自己的连接 |
|
||||||
|
|
||||||
|
实测(同一设备、同一屏、同一时刻,两边各 dump 一次):
|
||||||
|
|
||||||
|
| 对比项 | u2 | uiautodev |
|
||||||
|
|---|---|---|
|
||||||
|
| 节点总数 | 330 | 330 |
|
||||||
|
| `id/0qf`(抖音底部 tab) | 3 | 3 |
|
||||||
|
| `id/content_layout` | 5 | 5 |
|
||||||
|
|
||||||
|
**逐项一致** —— 两个通道给出的元素树是同一份(都是设备的 UiAutomation dump)。
|
||||||
|
所以"抓取看到的和执行看到的不是一回事"这个假设**不成立**。
|
||||||
|
|
||||||
|
> 顺带纠正一处过时注释:`core/uiauto_helper.py` 说"uiautodev 归一化浮点坐标无法换算像素"。
|
||||||
|
> 实际上 uiautodev 的节点里 **`rect` 就是像素**(`{x,y,width,height}`),`bounds` 才是
|
||||||
|
> 归一化的 0~1 浮点。要用像素直接用 `rect` 即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、真正的原因:**序号型选择器**(`(…)[k]`)会随界面变形而错位
|
||||||
|
|
||||||
|
复现(192.168.20.100,抖音 v40.4.0)—— 进「我」页面,底部导航 dump 出来是:
|
||||||
|
|
||||||
|
```
|
||||||
|
[1] text='首页' bounds=[38,1484][106,1530]
|
||||||
|
[2] text='消息' bounds=[470,1484][538,1530]
|
||||||
|
[3] text='我' bounds=[631,1484][665,1530]
|
||||||
|
```
|
||||||
|
|
||||||
|
只有 **3 个** `0qf`。而任务里的步骤写的是:
|
||||||
|
|
||||||
|
```
|
||||||
|
(//*[@resource-id="com.ss.android.ugc.aweme:id/0qf"])[4] ← 第 4 个,根本不存在
|
||||||
|
```
|
||||||
|
|
||||||
|
**必然点空** —— 日志里表现为 `click 未找到元素`,而界面上那个「我」明明就在那儿。
|
||||||
|
|
||||||
|
### 为什么有的设备是 4 个?
|
||||||
|
|
||||||
|
抖音底部的「朋友」tab 是**灰度功能**:有的账号/设备有(首页/朋友/消息/我 = 4 个),
|
||||||
|
有的没有(首页/消息/我 = 3 个)。同一个序号 `[4]`:
|
||||||
|
|
||||||
|
- 4 个 tab 的设备 → 点到「我」(碰巧对)
|
||||||
|
- 3 个 tab 的设备 → 点空(或点到别的东西)
|
||||||
|
|
||||||
|
**同一个选择器在不同机器上语义不同**,这是"时好时坏"的根源。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、解法:用**语义选择器**,不要依赖序号
|
||||||
|
|
||||||
|
实测有效(同一台 3-tab 设备):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ✗ 序号型:依赖"底部有几个 tab"
|
||||||
|
(//*[@resource-id="com.ss.android.ugc.aweme:id/0qf"])[4]
|
||||||
|
|
||||||
|
# ✓ 语义型:同一个 id,用文字限定 —— 不管有几个 tab 都对
|
||||||
|
//*[@resource-id="com.ss.android.ugc.aweme:id/0qf" and @text="我"]
|
||||||
|
```
|
||||||
|
|
||||||
|
验证:命中(bounds 落在「我」上)→ 点击 → 进入我页面 → `jy-`(切换按钮)出现 ✓
|
||||||
|
|
||||||
|
同类可用限定条件(按优先级):
|
||||||
|
|
||||||
|
1. `@text="…"` —— 文字最稳(tab 名、按钮名)
|
||||||
|
2. `@content-desc="…"` —— 无文字但有描述(如 `首页,按钮`)
|
||||||
|
3. `@resource-id="…"` —— **唯一**时直接用
|
||||||
|
4. 组合:`//*[@resource-id="x" and @text="y"]` —— **同一 id 多实例时的最佳解**
|
||||||
|
5. 结构路径(`…/FrameLayout[2]`)—— 最后手段,最脆
|
||||||
|
6. `(…)[k]` —— **只在上面都不行时**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、抓取弹窗"老是错位"的根因 —— **截图和元素树不是同一时刻**
|
||||||
|
|
||||||
|
(2026-09-13 追加,用户反馈"抓取的窗口老是会错位")
|
||||||
|
|
||||||
|
先把几个常见猜测逐一排除(都实测过):
|
||||||
|
|
||||||
|
| 猜测 | 实测结果 |
|
||||||
|
|------|---------|
|
||||||
|
| 两条通道元素树不一致 | ✗ 排除:节点数/各 id 个数逐项相同 |
|
||||||
|
| 截图被 CSS 缩放了、框没跟着缩 | ✗ 排除:`scaleX=clientWidth/naturalWidth` 算得对,实测框位置与理论值一致 |
|
||||||
|
| 窗口 resize 后没重算 | ✗ 排除:预览列固定 320px,resize 不影响 |
|
||||||
|
| 列表索引与框的 `data-idx` 对不上 | ✗ 排除:用 `indexOf` 保住原始序号,过滤后也不乱 |
|
||||||
|
|
||||||
|
**真因:截图与元素树分两次取,中间隔了 1~2 秒。**
|
||||||
|
|
||||||
|
| 取数方式 | 截图 | 元素树 | 两者时间差 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 现在(`/api/uiauto/screenshot` + `/api/uiauto/elements` 两次请求,走 uiautodev)| 0.4s | 1.8s | **约 1.8 秒** |
|
||||||
|
| 原生 u2 背靠背(同一个连接)| 0.3s | 1.4s | **约 1.4 秒** |
|
||||||
|
|
||||||
|
元素树的 dump 本身就要 1.3~1.8 秒(设备端 uiautomator 的开销,改不动)。
|
||||||
|
界面**只要在动**(抖音信息流、视频、加载动画…),两秒足够让元素位置全变 →
|
||||||
|
**框永远落在旧位置上** —— 这就是"老是错位",不是偶发。
|
||||||
|
|
||||||
|
### 解法(✅ 2026-09-13 已实现,见 `core/uiauto_helper.snapshot`)
|
||||||
|
|
||||||
|
1. **一次请求取齐**(推荐,也是用户说的"用原始 u2"):
|
||||||
|
新增 `GET /api/uiauto/snapshot?serial=X`,服务端用**一个 u2 连接**背靠背
|
||||||
|
`dump_hierarchy()` + `screenshot()`,返回 `{image, elements}`;
|
||||||
|
前端只调这一个接口 → 天然同源,间隔从 ~2.2s 降到 ~1.4s。
|
||||||
|
**并且抓取弹窗改成大图 1:1 预览**(原来预览列只有 320px、图片被缩到 294px,
|
||||||
|
框全挤在一起,看着就像"错位";现在铺满左侧、按原始分辨率显示)。
|
||||||
|
2. **双截图校验**(关键:让失败可见而不是悄悄错位):
|
||||||
|
抓取时前后各截一张,两张**不一致就明确提示**"界面在抓取过程中变化了,
|
||||||
|
请让设备停在目标界面再抓",而不是给一个已经错位的框。
|
||||||
|
3. 文档里明确写:**抓元素要让设备停在静止界面**(设置页、已加载完的页面),
|
||||||
|
别在视频播放/信息流滑动中抓。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、建议的改动(待排期)
|
||||||
|
|
||||||
|
1. **抓取器(`core/uiauto_helper.py`)**:同一 id 出现多个实例时,**优先产出
|
||||||
|
`//*[@resource-id="x" and @text="…"]`** 而不是 `(…)[k]`;抓取列表里把
|
||||||
|
`text`/`content-desc` 放在最显眼位置,让用户能看着选。
|
||||||
|
2. **抓取弹窗**:对**带序号**的选择器加醒目提示("这个选择器依赖界面元素个数,
|
||||||
|
界面一变就失效")。
|
||||||
|
3. **已存任务**:把 `(…)[k]` 型选择器扫一遍,能改成文字限定的自动改(脚本),
|
||||||
|
其余在编辑器里逐个复核。
|
||||||
|
4. **运行期**:`click` 未命中时,日志里顺带 dump 一下"同 id 现在有几个实例",
|
||||||
|
让"序号错位"当场可诊断(现在是干巴巴一句"未找到元素")。
|
||||||
|
5. **(针对第四节的"抓取错位")** 抓取接口合并成一个快照接口 + 双截图校验,
|
||||||
|
具体见第四节「解法」。
|
||||||
+170
-55
@@ -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">'+
|
||||||
@@ -839,8 +839,20 @@ var _stepEditor={
|
|||||||
'</div>'+
|
'</div>'+
|
||||||
'</div>'+
|
'</div>'+
|
||||||
'<div class="el-list-wrap">'+
|
'<div class="el-list-wrap">'+
|
||||||
'<input type="text" class="el-picker-search" id="el-search" placeholder="搜索 resource-id/text/description..." oninput="_stepEditor._filterEl(this.value)">'+
|
'<div class="el-tabs">'+
|
||||||
'<div class="el-picker-list" id="el-list"><div class="se-empty">加载元素树...</div></div>'+
|
'<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>'+
|
||||||
'</div>';
|
'</div>';
|
||||||
document.getElementById('el-picker-footer').innerHTML=
|
document.getElementById('el-picker-footer').innerHTML=
|
||||||
@@ -849,65 +861,133 @@ 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();
|
||||||
},
|
},
|
||||||
// 拉取元素树(进入页面和刷新时共用;带时间戳防缓存)
|
// 右侧页签切换
|
||||||
_loadElData:function(){
|
_tab:function(btn){
|
||||||
var serial=this._elSerial;
|
var pane=btn.dataset.pane;
|
||||||
if(!serial)return;
|
document.querySelectorAll('#el-picker-body .el-tab').forEach(function(b){
|
||||||
var listEl=document.getElementById('el-list');
|
b.classList.toggle('active', b===btn);
|
||||||
if(listEl)listEl.innerHTML='<div class="se-empty">加载元素树...</div>';
|
});
|
||||||
var self=this;
|
['tree','attr','color'].forEach(function(p){
|
||||||
fetch('/api/uiauto/elements?serial='+encodeURIComponent(serial)+'&_='+Date.now())
|
var el=document.getElementById('el-pane-'+p);
|
||||||
.then(function(r){return r.json();})
|
if(el)el.classList.toggle('active', p===pane);
|
||||||
.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(){
|
_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 rows=[['建议选择器', sg.value||'(无可靠选择器)'],
|
||||||
|
['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;
|
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?' · ⚠ 界面在变化':'');
|
||||||
|
}
|
||||||
|
// 图片显示尺寸一变(窗口缩放、面板宽度变化、滚动条出现…)就重算所有框 ——
|
||||||
|
// 这是"完全不错位"的保证:框永远跟着图片走,不留旧坐标
|
||||||
|
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(){
|
_renderOverlays:function(){
|
||||||
var container=document.getElementById('el-shot-container');
|
var container=document.getElementById('el-shot-container');
|
||||||
@@ -947,6 +1027,32 @@ 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;}}
|
||||||
|
});
|
||||||
|
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){
|
container.onclick=function(e){
|
||||||
var im=container.querySelector('img');
|
var im=container.querySelector('img');
|
||||||
if(!im)return;
|
if(!im)return;
|
||||||
@@ -971,6 +1077,8 @@ var _stepEditor={
|
|||||||
},
|
},
|
||||||
// 高亮元素(截图框 + 列表项联动)
|
// 高亮元素(截图框 + 列表项联动)
|
||||||
_highlightEl:function(i){
|
_highlightEl:function(i){
|
||||||
|
this._renderAttrs(i);
|
||||||
|
this._renderColors(i);
|
||||||
// 清除旧高亮(只在 el-picker-overlay 范围内查找)
|
// 清除旧高亮(只在 el-picker-overlay 范围内查找)
|
||||||
var picker=document.getElementById('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-shot-overlay.active').forEach(function(o){o.classList.remove('active');});
|
||||||
@@ -1009,8 +1117,10 @@ var _stepEditor={
|
|||||||
? '<button class="btn btn-xs" title="用将填入的选择器在设备上真跑一次 click,验证是否命中" '
|
? '<button class="btn btn-xs" title="用将填入的选择器在设备上真跑一次 click,验证是否命中" '
|
||||||
+'onclick="event.stopPropagation();_stepEditor._testSelector('+idx+')">✓ 测选择器</button>'
|
+'onclick="event.stopPropagation();_stepEditor._testSelector('+idx+')">✓ 测选择器</button>'
|
||||||
: '')
|
: '')
|
||||||
|
+'<button class="btn btn-xs" title="把这条的选择器填入步骤" '
|
||||||
|
+'onclick="event.stopPropagation();_stepEditor._fillEl('+idx+')">✓ 填入</button>'
|
||||||
+'</div>';
|
+'</div>';
|
||||||
return '<div class="el-picker-item" data-idx="'+idx+'" title="点击填入: '+esc(sg.value||'')+'" onclick="_stepEditor._pickEl('+idx+')">'+
|
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>'+
|
'<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-info"><div class="ep-name">'+indent+esc(el.name||el.class||'')+'</div>'+
|
||||||
'<div class="ep-attrs">'+attrs.join('')+'</div></div>'+
|
'<div class="ep-attrs">'+attrs.join('')+'</div></div>'+
|
||||||
@@ -1062,7 +1172,12 @@ var _stepEditor={
|
|||||||
});
|
});
|
||||||
this._renderEl(f);
|
this._renderEl(f);
|
||||||
},
|
},
|
||||||
|
// 点层级条目 = **选中**(看属性/颜色/高亮),不关窗不回填
|
||||||
_pickEl:function(i){
|
_pickEl:function(i){
|
||||||
|
this._highlightEl(i);
|
||||||
|
},
|
||||||
|
// 「✓ 填入」才回填选择器并关窗
|
||||||
|
_fillEl:function(i){
|
||||||
var el=this._elData[i];
|
var el=this._elData[i];
|
||||||
if(!el||!this._elTargetInput)return;
|
if(!el||!this._elTargetInput)return;
|
||||||
if(el.suggested&&el.suggested.invalid){
|
if(el.suggested&&el.suggested.invalid){
|
||||||
|
|||||||
@@ -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,15 +297,27 @@ 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;outline:2px solid rgba(248,113,113,.9);outline-offset:-1px;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{outline:2px solid #f87171;outline-offset:-1px;background:rgba(248,113,113,.22)}
|
||||||
.el-list-wrap{flex:1;display:flex;flex-direction:column;overflow:hidden}
|
.el-list-wrap{flex:0 0 460px;display:flex;flex-direction:column;overflow:hidden;border-left:1px solid var(--border)}
|
||||||
|
.el-tabs{display:flex;border-bottom:1px solid var(--border);background:var(--ink-2);flex:none}
|
||||||
|
.el-tab{padding:7px 14px;font-size:12.5px;color:var(--text-light);cursor:pointer;border:none;background:none;border-bottom:2px solid transparent}
|
||||||
|
.el-tab.active{color:var(--primary);border-bottom-color:var(--primary);font-weight:600}
|
||||||
|
.el-pane{flex:1;overflow:auto;display:none}
|
||||||
|
.el-pane.active{display:block}
|
||||||
|
.el-attr{display:flex;gap:8px;padding:5px 12px;border-bottom:1px solid var(--border);font-size:12px}
|
||||||
|
.el-attr b{color:var(--text-light);font-weight:500;flex:0 0 96px;font-family:var(--mono)}
|
||||||
|
.el-attr span{color:var(--text);word-break:break-all;flex:1}
|
||||||
|
.el-color-row{display:flex;align-items:center;gap:8px;padding:6px 12px;font-size:12px;border-bottom:1px solid var(--border)}
|
||||||
|
.el-swatch{width:22px;height:22px;border-radius:4px;border:1px solid var(--border);flex:none}
|
||||||
.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}
|
||||||
.el-picker-item:hover{background:var(--primary-soft)}
|
.el-picker-item:hover{background:var(--primary-soft)}
|
||||||
.el-picker-item.active{background:var(--primary-soft);border-left:3px solid var(--primary)}
|
.el-picker-item.active{background:var(--primary-soft);box-shadow:inset 3px 0 0 var(--primary)}
|
||||||
.el-picker-item:last-child{border-bottom:none}
|
.el-picker-item:last-child{border-bottom:none}
|
||||||
.el-picker-item .ep-depth{color:var(--text-light);font-family:var(--mono);flex-shrink:0;width:28px}
|
.el-picker-item .ep-depth{color:var(--text-light);font-family:var(--mono);flex-shrink:0;width:28px}
|
||||||
.el-picker-item .ep-info{flex:1;min-width:0}
|
.el-picker-item .ep-info{flex:1;min-width:0}
|
||||||
@@ -1042,12 +1054,12 @@ 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:1800px;width:98vw">
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body" id="el-picker-body" style="max-height:75vh;padding:0;overflow:hidden;display:flex;flex-direction:column"></div>
|
<div class="modal-body" id="el-picker-body" style="height:88vh;padding:0;overflow:hidden;display:flex;flex-direction:column"></div>
|
||||||
<div class="modal-footer" id="el-picker-footer"></div>
|
<div class="modal-footer" id="el-picker-footer"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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