feat: 新增通用步骤任务+元素抓取,完善全部项目文档

- 新增 generic_steps 通用步骤任务(可视化步骤编辑器编排流程,支持 open_app/click/swipe/input_text/wait/loop/group)

- 新增 core/uiauto_helper.py 封装 uiautodev 元素抓取客户端

- web_server 新增元素抓取/截图/设备列表等 API

- monitor.html 新增步骤编辑器、元素抓取模态框、独立关闭逻辑

- 新增 README.md 项目总览(快速上手/架构/配置/FAQ)

- 新增 doc/ARCHITECTURE.md 架构详解、doc/DEPLOY.md 部署指南、doc/API.md 接口文档

- 修复 doc/TASK_DEV.md:移除已删除的 comment 引用,补充 generic 包,更新注册示例

- .gitignore 忽略 .claude/ 工具产物
This commit is contained in:
2026-08-08 10:40:06 +08:00
parent 186bded9d9
commit 468e86f0a9
17 changed files with 3202 additions and 146 deletions
+178
View File
@@ -0,0 +1,178 @@
"""uiauto2 (uiautodev) 本地服务客户端封装。
uiautodev 启动后本地监听 http://localhost:20242,提供设备元素树查询。
前端步骤编辑器"抓取元素"按钮通过本模块拉取当前设备 UI 树供选择。
依赖:
- 用户需先 `pip install uiautodev` 并运行 `uiauto.dev` 启动本地服务
- 未运行时本模块函数返回友好错误,不抛异常
API 参考(uiautodev 0.14):
- GET /api/info — 服务信息(用于探测是否运行)
- GET /api/android/{serial}/dump_hierarchy — 元素树 JSON
"""
import requests
from core.logger import get_logger
_log = get_logger("core.uiauto")
# uiautodev 默认本地端口(固定 20242)
_UIAUTO_BASE = "http://localhost:20242"
# 请求超时(秒)。connect 超时短,避免前端等太久
_TIMEOUT = (1, 8)
def is_running():
"""探测 uiauto2 本地服务是否在运行。"""
try:
r = requests.get(f"{_UIAUTO_BASE}/api/info", timeout=_TIMEOUT)
return r.status_code == 200
except Exception:
return False
def list_devices():
"""获取 uiauto2 已连接的设备列表。
返回 (ok, data_or_error):
ok=True — data 是设备列表 [{serial, model, product, name, status}, ...]
ok=False — data 是错误消息字符串
"""
try:
r = requests.get(f"{_UIAUTO_BASE}/api/android/list", timeout=_TIMEOUT)
r.raise_for_status()
data = r.json()
return True, data
except requests.exceptions.ConnectionError:
return False, "uiauto2 未启动,请运行 `uiauto.dev`"
except Exception as e:
_log.warning(f"list_devices 异常: {e}")
return False, f"获取设备列表失败: {e}"
def get_screenshot(serial):
"""获取设备截图(通过 uiauto2 服务)。
返回 (ok, data_or_error):
ok=True — data 是 JPEG 二进制数据
ok=False — data 是错误消息字符串
"""
if not serial:
return False, "缺少 serial"
try:
r = requests.get(
f"{_UIAUTO_BASE}/api/android/{serial}/screenshot/0",
timeout=_TIMEOUT,
)
r.raise_for_status()
return True, r.content
except requests.exceptions.ConnectionError:
return False, "uiauto2 未启动"
except Exception as e:
_log.warning(f"get_screenshot 异常: {e}")
return False, f"截图失败: {e}"
def get_elements(serial):
"""获取指定设备的 UI 元素树。
返回 (ok, data_or_error):
ok=True — data 是元素列表 [{name, attrs:{...}, children:[...]}, ...]
ok=False — data 是错误消息字符串
元素树由 uiautodev 的 U2AndroidDriver.dump_hierarchy 返回(JSON)。
我们递归提取每个节点的关键属性(resource-id/text/description/class/...),
供前端列表展示和选择。
"""
if not serial:
return False, "缺少 serial"
try:
r = requests.get(
f"{_UIAUTO_BASE}/api/android/{serial}/hierarchy",
timeout=_TIMEOUT,
)
if r.status_code == 404:
return False, f"设备 {serial} 未连接到 uiauto2"
r.raise_for_status()
data = r.json()
# uiautodev hierarchy 返回 Node 树:{key, name, bounds, rect, properties, children}
# 提取成扁平的可选列表(保留层级缩进信息)
elements = []
_flatten(data, elements, depth=0)
if not elements:
return False, "当前界面未抓取到元素"
return True, elements
except requests.exceptions.ConnectionError:
return False, "uiauto2 未启动,请运行 `uiauto.dev`"
except requests.exceptions.Timeout:
return False, "uiauto2 请求超时"
except Exception as e:
_log.warning(f"get_elements 异常: {e}")
return False, f"抓取失败: {e}"
def _flatten(node, out, depth=0, path=""):
"""递归扁平化元素树,提取关键属性供前端选择。
uiautodev dump_hierarchy 返回 Node 格式:
{key, name, bounds(归一化浮点), rect({x,y,width,height}), properties({所有属性}), children}
其中 properties 包含原始 XML 属性(resource-id/text/content-desc/class/bounds 字符串等)。
"""
if not isinstance(node, dict):
return
props = node.get("properties") or {}
name = node.get("name") or props.get("class") or ""
# bounds:优先取 properties 中的原始字符串 "[x1,y1][x2,y2]"(前端渲染 overlay 用)
bounds_str = props.get("bounds", "")
if not bounds_str and node.get("rect"):
r = node["rect"]
bounds_str = f"[{r['x']},{r['y']}][{r['x']+r['width']},{r['y']+r['height']}]"
# 关键属性(前端选择器用)
item = {
"depth": depth,
"path": path,
"name": name,
"resource_id": props.get("resource-id", ""),
"text": props.get("text", ""),
"description": props.get("content-desc", ""),
"class": props.get("class", ""),
"package": props.get("package", ""),
"clickable": props.get("clickable", ""),
"bounds": bounds_str,
# 推荐选择器(优先级:resource-id > text > description > class)
"suggested": _suggest_selector(props),
}
out.append(item)
# 递归子节点
children = node.get("children") or []
for i, child in enumerate(children):
_flatten(child, out, depth + 1, f"{path}/{i}")
def _suggest_selector(attrs):
"""根据属性推荐最佳选择器(前端一键填入用)。
优先返回 xpath(最稳定,uiautomator2 原生支持),
其次 resourceId / text / description / class。
"""
cls = attrs.get("class", "")
rid = attrs.get("resource-id", "")
text = attrs.get("text", "")
desc = attrs.get("content-desc", "")
# 优先生成 xpath:resource-id 最稳定
if rid:
# 简短类名(去掉包前缀)
short_cls = cls.split(".")[-1] if cls else "*"
return {"type": "xpath",
"value": f'//*[@resource-id="{rid}"]'}
if text:
return {"type": "xpath",
"value": f'//*[@text="{text}"]'}
if desc:
return {"type": "xpath",
"value": f'//*[@content-desc="{desc}"]'}
if cls:
return {"type": "xpath",
"value": f'//{cls}'}
return {"type": "xpath", "value": "//*"}