5 Commits
Author SHA1 Message Date
butubb 4ea777a55a fix(抓取): 元素框跟着图片走(ResizeObserver)+ 界面放大 + 右侧层级/属性/颜色三页签
用户报的准确现象:**点某些元素会让画面变大,但框没跟着变大 → 错位**。

根因:预览图是 `max-width:100%` —— 窗口够宽时恒为原始尺寸(我这儿复现不出),
但窗口窄时图片被压小;此时任何**布局微动**都会改变预览列宽度 → 图片重新缩放,
而框是上一次算好的**像素坐标**,不会跟着变。触发布局微动的正是"点元素":
`.el-picker-item.active` 用 `border-left:3px`(占宽度)→ 列表变宽 → 预览变窄 →
图片变小 ✗。实测(视口压到 900):图片从 720 → 363,框仍停在 720 的位置。

修法:
1. **ResizeObserver 盯着图片**:显示尺寸一变就重算所有框 —— 窗口缩放、面板宽度
   变化、滚动条出现…都会自动跟上,这是"完全不错位"的兜底保证。
2. 高亮一律改用 **outline / box-shadow**(不占布局)→ 选中元素不再引发任何重排。
3. 弹窗放大到 **98vw × 88vh**(用户要"整个界面大一点"),图片在宽窗口下 1:1。
4. 右侧改成和 uiauto.dev 一样的**三页签**:
   - **层级**:元素树(原列表,缩进 + 搜索)
   - **属性**:选中元素的全部属性(建议选择器/class/resource-id/text/desc/package/
     clickable/bounds/深度)
   - **颜色**:鼠标在图上移动实时取**像素色**;选中元素显示**元素中心色**
5. 交互对齐云检查器:**点层级条目 = 选中**(看属性/颜色/高亮,不关窗),
   回填改由每行的「✓ 填入」按钮触发。

实测(视口 900 窄窗口):图片 720→363、框同步变 363 ✓;点元素不再错位 ✓;
三页签可用、属性/取色正确(选中元素中心 #f7f7f7)✓;无 JS 报错 ✓。
2026-09-13 21:30:41 +08:00
butubb 37a2b1c59b 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。
2026-09-13 21:17:12 +08:00
butubb accd06df4b docs(research): 章节编号理顺(三·补 → 四,建议改动 → 五) 2026-09-13 21:02:01 +08:00
butubb ee559786e7 docs(research): 补「抓取弹窗老是错位」的真因——截图与元素树不是同一时刻(附实测)
用户澄清"是抓取的窗口老是会错位"。把几种常见猜测逐一实测排除:
两条通道元素树不一致(✗ 逐项相同)、CSS 缩放没跟着算(✗ 缩放比正确、
框位置与理论值一致)、窗口 resize(✗ 预览列固定 320px)、列表索引错位
(✗ indexOf 保住原始序号)。

真因是取数时序:
  /api/uiauto/screenshot 0.4s + /api/uiauto/elements 1.8s(两次请求)= 间隔约 1.8s;
  换成原生 u2 背靠背也仍有约 1.4s(dump 本身就要 1.3~1.8s,设备端开销改不动)。
界面只要在动(信息流/视频/动画),这两秒就足以让元素位置全变 → 框永远落在旧位置。
因为是**每次都发生**,所以表现为"老是错位"而不是偶发。

解法(写进文档待排期):①新增 /api/uiauto/snapshot 一个请求取齐(也正是
"用原始 u2"的做法,间隔降到 ~1.4s);②抓取前后各截一张做校验,不一致就
明确提示"界面在变化中,请停在静止界面再抓",而不是悄悄给一个错位的框。
2026-09-13 21:01:53 +08:00
butubb a94f63f0bf docs: 元素选择器研究——「点不到按钮」的根因是序号型选择器(附实测证据与解法)
用户反馈任务步骤老是点不到元素,怀疑"执行用的 u2"和"抓取用的 uiautodev"两条
通道不一致。建 research 分支实测,结论与假设相反:

1) **两条通道其实是一致的**:同设备同屏各 dump 一次,节点数 330/330、
   id 个数逐项相同 —— 都是同一份 UiAutomation 树,不存在"看到的不一样"。
   顺带纠正一处过时注释:uiautodev 的 `rect` 就是像素(`bounds` 才是归一化)。

2) **真凶是序号型选择器**:复现「抖音→我页面」,底部 `0qf` 只有 **3 个**
   (首页/消息/我 —— 「朋友」tab 是灰度功能,有的账号/设备没有),
   而任务里写死 `(…0qf…)[4]` → 第 4 个不存在 → 必然点空。
   同一选择器在 4 tab 设备上碰巧对、在 3 tab 设备上必错 —— 这就是"时好时坏"。

3) **解法(实测有效)**:同一 id 多实例时用**文字/描述限定**:
   `//*[@resource-id="…0qf" and @text="我"]` → 命中并把设备带进我页面(jy- 出现)。

文档里还列了同类限定条件的优先级与四条待排期改动(抓取器优先产语义选择器、
对带序号的选择器加提示、存量任务批量复核、运行期 dump 同 id 实例数)。
2026-09-13 20:56:37 +08:00
7 changed files with 440 additions and 62 deletions
+85
View File
@@ -258,3 +258,88 @@ def _extract(root, out):
count_attrs(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)}
+1
View File
@@ -127,6 +127,7 @@
| GET | `/api/uiauto/status` | L | uiautodev 服务是否在跑 |
| GET | `/api/uiauto/devices` | D | 抓元素可选设备 |
| GET | `/api/uiauto/elements` | D | 设备 UI 元素树 |
| GET | `/api/uiauto/snapshot` | D | **一次取齐**截图 + 元素树(原生 u2,抓取弹窗用) |
| GET | `/api/uiauto/screenshot` | D | uiautodev 截图(JPEG) |
| POST | `/api/steps/test` | D | 真机试执行单个步骤 |
+1
View File
@@ -24,6 +24,7 @@
| [STF_REMOVAL.md](STF_REMOVAL.md) | **历史记录**:摘除 OpenSTF 的迁移过程(阶段 0-3) | 追溯背景时参考 |
| [staffdeck/KNOWLEDGE_BASE.md](staffdeck/KNOWLEDGE_BASE.md) | 给 StaffDeck 数字员工的知识库(MCP 接入/工具/约定/红线) | 外部 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) | 已确认但暂缓的待办(含已知问题) | 所有开发者 |
项目根目录的 [README.md](../README.md) 是**项目总览与快速上手**(面向第一次接触项目的人),细节都在本目录。
+140
View File
@@ -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
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">'+
@@ -839,8 +839,20 @@ var _stepEditor={
'</div>'+
'</div>'+
'<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-picker-list" id="el-list"><div class="se-empty">加载元素树...</div></div>'+
'<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=
@@ -849,65 +861,133 @@ 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>';
});
// 右侧页签切换
_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);
});
},
// 刷新截图
_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;
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?' · ⚠ 界面在变化':'');
}
// 图片显示尺寸一变(窗口缩放、面板宽度变化、滚动条出现…)就重算所有框 ——
// 这是"完全不错位"的保证:框永远跟着图片走,不留旧坐标
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');
@@ -947,6 +1027,32 @@ 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;}}
});
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;
@@ -971,6 +1077,8 @@ var _stepEditor={
},
// 高亮元素(截图框 + 列表项联动)
_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');});
@@ -1009,8 +1117,10 @@ var _stepEditor={
? '<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+')">'+
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>'+
@@ -1062,7 +1172,12 @@ var _stepEditor={
});
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){
+19 -7
View File
@@ -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-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-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 .btn{padding:3px 10px;font-size:12px;background:#39414d;color:#e5e7eb;border:none;box-shadow:none}
.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 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-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.active{border:2px solid #f87171;background:rgba(248,113,113,.22)}
.el-list-wrap{flex:1;display:flex;flex-direction:column;overflow:hidden}
.el-shot-overlay.active{outline:2px solid #f87171;outline-offset:-1px;background:rgba(248,113,113,.22)}
.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-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: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 .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}
@@ -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 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">
<h3 id="el-picker-title">抓取元素</h3>
<button class="close" onclick="closeElPicker()">&times;</button>
</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>
</div>
+24
View File
@@ -358,6 +358,30 @@ def api_uiauto_devices():
_log.info(f"抓取元素设备列表补全 {added} 台在线设备: {list(seen)}")
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")
@perm_required(PERM_DEVICES)
def api_uiauto_screenshot():