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:
@@ -22,6 +22,9 @@ data/*.migrated
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Trae / AI 工具产物
|
||||
.claude/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
# 设备自动化后台(platform-tools)
|
||||
|
||||
基于 **STF(Smartphone Test Farm)+ uiautomator2 + Flask** 的 Android 多设备自动化任务执行平台。
|
||||
|
||||
提供 Web 管理后台,支持多设备并发任务执行、定时调度、设备分组管理、APK 批量安装、UI 元素抓取等功能。内置抖音养号任务和通用步骤任务,可扩展任意 App 的自动化操作。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [快速上手](#快速上手)
|
||||
- [项目结构](#项目结构)
|
||||
- [核心概念](#核心概念)
|
||||
- [配置说明](#配置说明)
|
||||
- [Web 管理后台](#web-管理后台)
|
||||
- [任务系统](#任务系统)
|
||||
- [日志系统](#日志系统)
|
||||
- [常用脚本](#常用脚本)
|
||||
- [常见问题](#常见问题)
|
||||
- [更多文档](#更多文档)
|
||||
|
||||
---
|
||||
|
||||
## 快速上手
|
||||
|
||||
### 环境要求
|
||||
|
||||
- **Python 3.10+**(推荐 3.12)
|
||||
- **OpenSTF 服务**:已部署并运行,设备已接入 STF
|
||||
- **Windows / Linux / macOS**均可(adb 二进制需放对应平台版本到 `bin/adb/`)
|
||||
- 设备需开启 **USB 调试**或通过 **adb 网络连接**(IP:5555)接入
|
||||
|
||||
### 三步启动
|
||||
|
||||
```bash
|
||||
# 1. 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 2. 修改配置(STF 地址、token、adb 路径等)
|
||||
# 编辑 config.py 中的 STF_URL 和 STF_TOKEN
|
||||
|
||||
# 3. 启动 Web 后台
|
||||
python web_server.py
|
||||
```
|
||||
|
||||
启动后访问 **http://localhost:18050/**,默认账号 `admin` / `admin123`。
|
||||
|
||||
> Windows 用户也可双击 `start_web.bat` 启动(自动提权 + 添加防火墙规则,支持局域网访问)。
|
||||
|
||||
### 验证启动
|
||||
|
||||
控制台看到以下日志即表示启动成功:
|
||||
|
||||
```
|
||||
[INFO] [core.worker] 心跳看门狗已启动
|
||||
[INFO] [core.tm] 从数据库加载 X 个分组, X 个任务
|
||||
[INFO] [web] 管理后台: http://localhost:18050/ (admin/admin123)
|
||||
[INFO] [web] 启动服务: http://localhost:18050/
|
||||
```
|
||||
|
||||
### 首次使用流程
|
||||
|
||||
1. **登录后台** → 用 `admin/admin123` 登录,建议立即修改密码
|
||||
2. **查看设备** → 首页"监控"Tab 自动展示 STF 设备池状态
|
||||
3. **创建分组**(可选)→ "分组"Tab 按批次/项目给设备分组
|
||||
4. **创建任务** → "任务"Tab 新建任务,选择任务类型、目标设备、参数、调度
|
||||
5. **执行任务** → 任务列表点"立即执行",或在"监控"Tab 勾选设备批量操作
|
||||
6. **查看日志** → "日志"Tab 实时查看运行日志
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
platform-tools/
|
||||
├── config.py # 根配置(STF地址/token、adb路径、web端口)
|
||||
├── web_server.py # Flask 入口(API + 登录 + 单页应用)
|
||||
├── main.py # 命令行入口(单设备跑一次,不走 web)
|
||||
├── create_task.py # 一次性脚本(创建定时任务示例)
|
||||
├── requirements.txt # Python 依赖清单
|
||||
├── start_web.bat # Windows 一键启动(提权 + 防火墙)
|
||||
│
|
||||
├── core/ # 核心基础设施层
|
||||
│ ├── logger.py # 统一日志(分文件、10MB 滚动)
|
||||
│ ├── stf_client.py # STF REST API 封装(占用/释放/远程连接)
|
||||
│ ├── adb_helper.py # adb 命令封装(全局锁,绝不 kill-server)
|
||||
│ ├── device_worker.py # BaseWorker 基类 + STFDevice + 心跳看门狗
|
||||
│ ├── task_manager.py # TaskManager 调度器 + 前台App扫描器
|
||||
│ ├── u2_helper.py # uiautomator2 通用辅助函数
|
||||
│ ├── uiauto_helper.py # uiautodev 元素抓取客户端
|
||||
│ ├── apk_manager.py # APK 上传/解析/批量安装
|
||||
│ ├── models.py # SQLAlchemy 数据模型 + 数据库初始化
|
||||
│ └── actions/
|
||||
│ ├── __init__.py # 全局 Action 框架导出
|
||||
│ └── base.py # BaseAction 基类 + 注册机制
|
||||
│
|
||||
├── tasks/ # 任务定义层(每个 App 一个子包)
|
||||
│ ├── __init__.py # 全局任务注册表
|
||||
│ ├── base.py # BaseTask 基类 + @register_task 装饰器
|
||||
│ ├── douyin/ # 抖音养号任务
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── task.py # DEFAULT_PARAMS + Worker + Task
|
||||
│ │ └── actions/ # 抖音专属操作(点赞等)
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── base.py # 抖音 ACTIONS 注册表
|
||||
│ │ └── like.py # 点赞操作(找红心/双击回退)
|
||||
│ └── generic/ # 通用步骤任务(可视化编辑器编排)
|
||||
│ ├── __init__.py
|
||||
│ └── task.py # 步骤执行引擎(open_app/click/swipe/loop...)
|
||||
│
|
||||
├── templates/admin/ # 前端页面
|
||||
│ ├── monitor.html # 单页应用(监控/任务/分组/日志/用户 5 Tab)
|
||||
│ └── login.html # 登录页
|
||||
│
|
||||
├── static/admin/
|
||||
│ └── custom.css # 自定义样式
|
||||
│
|
||||
├── data/ # 运行时数据
|
||||
│ ├── users.db # SQLite(用户/分组/任务/自定义动作/APK记录)
|
||||
│ └── apks/ # 上传的 APK 文件存储
|
||||
│
|
||||
├── logs/ # 日志文件(自动生成,10MB 滚动保留 5 份)
|
||||
│ ├── core.log # 核心程序日志
|
||||
│ ├── task.log # 任务执行日志
|
||||
│ ├── web.log # Web 请求日志
|
||||
│ └── action.log # 操作执行日志
|
||||
│
|
||||
├── bin/adb/ # adb 可执行文件(Windows: adb.exe + dll)
|
||||
├── scripts/ # 实用脚本
|
||||
│ ├── cleanup.py # 清理 STF 残留设备占用
|
||||
│ └── pack.py # 打包项目为 zip(排除运行时产物)
|
||||
└── doc/ # 项目文档
|
||||
├── TASK_DEV.md # 任务开发指南(新增 App 任务模板)
|
||||
├── ARCHITECTURE.md # 架构详解
|
||||
├── DEPLOY.md # 部署指南
|
||||
└── API.md # API 接口文档
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心概念
|
||||
|
||||
### 设备生命周期
|
||||
|
||||
```
|
||||
STF occupy(占用设备)
|
||||
↓
|
||||
STF remoteConnect(建立远程 ADB 隧道)
|
||||
↓
|
||||
adb connect(连接远程设备)
|
||||
↓
|
||||
u2.connect(连接 uiautomator2,自动推送 atx-agent)
|
||||
↓
|
||||
Worker.run_task(执行业务逻辑)
|
||||
↓
|
||||
adb disconnect + STF release(释放设备)
|
||||
```
|
||||
|
||||
> 直连模式:当设备 serial 为 `IP:5555` 格式时,直接 adb connect,不走 STF 桥接,更稳定。
|
||||
|
||||
### Worker — 单设备执行线程
|
||||
|
||||
每台设备对应一个 `Worker` 线程,继承 `BaseWorker`(`core/device_worker.py`)。基类已封装:
|
||||
|
||||
- STF 设备占用/释放(try/finally 保证释放)
|
||||
- adb 连接 + u2.connect(带 30 秒超时保护)
|
||||
- 状态上报(实时推送到前端监控大屏)
|
||||
- 异常捕获(设备离线不重试,其他异常按策略重试)
|
||||
- stop 停止信号(循环里检查 `self.stopped()`)
|
||||
- 心跳看门狗(120 秒无心跳自动标记卡死)
|
||||
|
||||
子类只需实现 `run_task(d)` 方法专注业务逻辑。
|
||||
|
||||
### TaskType — 任务类型
|
||||
|
||||
| 任务类型 | task_type | 说明 |
|
||||
|---------|-----------|------|
|
||||
| 抖音养号 | `douyin_nurture` | 自动看视频 + 随机点赞,被退出自动重连 |
|
||||
| 通用步骤 | `generic_steps` | 可视化步骤编辑器编排流程,支持循环/点击/滑动等 |
|
||||
|
||||
### TaskJob — 任务计划
|
||||
|
||||
一个 TaskJob 描述"什么时候、在哪些设备上、用什么参数执行什么任务":
|
||||
|
||||
| 字段 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `task_type` | 任务类型 | `"douyin_nurture"` |
|
||||
| `target` | 目标设备 | `{"mode": "all"}` 或 `{"mode": "group", "group_name": "A组"}` 或 `{"mode": "serial", "serial": "192.168.1.100:5555"}` |
|
||||
| `params` | 任务参数(与默认值深合并) | `{"watch_count": 50, "actions": {"like": {"params": {"rate": 0.5}}}}` |
|
||||
| `schedule` | 调度策略 | `{"mode": "once"}` 或 `{"mode": "cron", "cron": "0 9 * * *"}` 或 `{"mode": "cron_stop", "cron": "0 9 * * *", "stop_cron": "0 18 * * *"}` |
|
||||
| `retry` | 重试策略 | `{"max_attempts": 3, "delay": 60}` |
|
||||
| `enabled` | 是否启用 | `true` |
|
||||
|
||||
### 调度模式
|
||||
|
||||
| 模式 | 行为 |
|
||||
|------|------|
|
||||
| `once` | 手动执行(前端点"立即执行") |
|
||||
| `cron` | 定时启动:到 cron 时间点自动启动所有目标设备 |
|
||||
| `cron_stop` | 定时启停:启动 cron 到点启动,停止 cron 到点停止本任务 worker |
|
||||
|
||||
cron 表达式为标准 5 段格式:`分 时 日 月 周`(如 `0 9 * * *` = 每天 9:00)
|
||||
|
||||
### 设备分组
|
||||
|
||||
设备分组存于 SQLite,便于按批次/项目分组下发任务。一个 Job 指定 `target.mode="group"` 时,调度器展开为组内全部设备。
|
||||
|
||||
### 进度上报
|
||||
|
||||
Worker 通过 `self.set_progress()` 上报通用进度字段,前端统一解析展示:
|
||||
|
||||
```python
|
||||
self.set_progress(done=5, total=80, unit="视频",
|
||||
action_counts={"like": 3})
|
||||
```
|
||||
|
||||
前端展示:进度条 + `5/80 视频` + `点赞 3` 徽章。
|
||||
|
||||
---
|
||||
|
||||
## 配置说明
|
||||
|
||||
所有核心配置在 [config.py](file:///d:/platform-tools/config.py),**任务参数不放在这里**(放各自 `tasks/xxx.py` 顶部)。
|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
|-------|--------|------|
|
||||
| `STF_URL` | `http://192.168.20.220:7100` | OpenSTF 服务地址 |
|
||||
| `STF_TOKEN` | (需替换) | STF API Token,在 STF 个人设置里生成 |
|
||||
| `ADB_PATH` | 自动识别 | adb 二进制路径,自动区分 Windows/Linux |
|
||||
| `WEB_HOST` | `0.0.0.0` | Web 监听地址(0.0.0.0 支持局域网访问) |
|
||||
| `WEB_PORT` | `18050` | Web 端口(避开 Windows 动态端口范围) |
|
||||
| `DATA_DIR` | `data/` | 持久化数据目录 |
|
||||
| `APK_DIR` | `data/apks/` | APK 文件存储目录 |
|
||||
|
||||
**必须修改的配置**:`STF_URL` 和 `STF_TOKEN`,改成你自己的 STF 服务地址和 Token。
|
||||
|
||||
---
|
||||
|
||||
## Web 管理后台
|
||||
|
||||
### 页面结构
|
||||
|
||||
单页应用(`templates/admin/monitor.html`),5 个 Tab:
|
||||
|
||||
| Tab | 功能 |
|
||||
|-----|------|
|
||||
| 监控 | 设备状态大屏:在线/离线、运行任务、当前动作、进度条、前台 App、截图、停止 |
|
||||
| 任务 | 任务计划 CRUD:新建/编辑/删除/启用停用/立即执行 |
|
||||
| 分组 | 设备分组管理:创建/编辑/删除分组 |
|
||||
| 日志 | 实时日志查看:按模块切换(core/task/web/action) |
|
||||
| 用户 | 用户管理:创建/删除/修改密码 |
|
||||
|
||||
### 前台 App 扫描
|
||||
|
||||
监控页"扫描前台App"按钮,获取所有设备当前前台 App。**不打扰设备**:
|
||||
|
||||
| 设备状态 | 处理方式 |
|
||||
|---------|---------|
|
||||
| worker 运行中 | 复用已有 ADB 连接查询 |
|
||||
| 自己占用 | 通过 STF remoteConnect 隧道查询(不 occupy/release) |
|
||||
| 完全空闲 | 返回"空闲"(不主动 adb connect,避免 STF 误判离线) |
|
||||
| 他人占用 | 标记"(他人占用)" |
|
||||
|
||||
### 截图功能
|
||||
|
||||
监控页每台设备可查看实时截图。用 `adb exec-out screencap -p`,只读操作,**任务运行中也能安全调用**(不抢占 u2 的 atx-agent 通道)。
|
||||
|
||||
### 元素抓取
|
||||
|
||||
任务编辑器的"抓取元素"按钮可拉取设备当前 UI 元素树,点击元素一键回填选择器。依赖本地运行的 uiautodev 服务(端口 20242),`web_server.py` 启动时会自动拉起。
|
||||
|
||||
---
|
||||
|
||||
## 任务系统
|
||||
|
||||
### 抖音养号(douyin_nurture)
|
||||
|
||||
自动观看抖音视频,按配置随机执行点赞操作。参数(在 `tasks/douyin/task.py` 顶部):
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `watch_count` | 80 | 观看视频数量(0=不限,靠时长停止) |
|
||||
| `watch_min` / `watch_max` | 5.0 / 35.0 | 单视频观看时长范围(秒) |
|
||||
| `max_duration` | 0 | 最大运行时长(秒),0=不限时 |
|
||||
| `swipe_min` / `swipe_max` | 0.25 / 0.50 | 上滑手势时长范围(秒) |
|
||||
| `gap_min` / `gap_max` | 1.0 / 3.0 | 视频间隔时长范围(秒) |
|
||||
| `actions.like.enabled` | true | 是否启用点赞 |
|
||||
| `actions.like.params.rate` | 0.3 | 点赞概率(0~1) |
|
||||
| `actions.like.params.method` | by_element | 点赞方式:by_element(找红心) / double_click(双击) |
|
||||
|
||||
终止条件(哪个先到就停):`watch_count` 数量到 / `max_duration` 时长到 / 手动停止。
|
||||
|
||||
### 通用步骤(generic_steps)
|
||||
|
||||
通过可视化步骤编辑器编排任务流程,worker 按步骤顺序执行。支持的步骤类型:
|
||||
|
||||
| 步骤类型 | 说明 |
|
||||
|---------|------|
|
||||
| `open_app` | 启动 App(指定包名,可选等待首页) |
|
||||
| `swipe` | 滑动(上/下/左/右,可配置时长) |
|
||||
| `click` | 点击元素(支持 xpath/description/resourceId/text 选择器) |
|
||||
| `input_text` | 输入文字(随机候选/固定文字) |
|
||||
| `wait` | 等待(可配置时长范围) |
|
||||
| `loop` | 循环块(含子步骤,可配置循环次数) |
|
||||
| `group` | 动作组(含子步骤,按序执行一次) |
|
||||
|
||||
### 新增 App 任务
|
||||
|
||||
参照 `tasks/douyin/` 结构,6 步即可新增一个 App 任务,详见 [doc/TASK_DEV.md](file:///d:/platform-tools/doc/TASK_DEV.md)。
|
||||
|
||||
---
|
||||
|
||||
## 日志系统
|
||||
|
||||
日志按模块分文件,自动滚动(10MB 一份,保留 5 份历史):
|
||||
|
||||
| 文件 | 模块前缀 | 内容 |
|
||||
|------|---------|------|
|
||||
| `logs/core.log` | `core.*` | STF/adb/worker/task_manager 核心程序 |
|
||||
| `logs/task.log` | `task.*` | 任务执行(worker 业务逻辑) |
|
||||
| `logs/web.log` | `web.*` | Web 请求/管理 |
|
||||
| `logs/action.log` | `action.*` | 操作执行(点赞/评论等) |
|
||||
|
||||
使用方式:
|
||||
|
||||
```python
|
||||
from core.logger import get_logger
|
||||
log = get_logger("task.douyin") # 写入 task.log
|
||||
log.info(f"[{self.serial}] 开始任务")
|
||||
```
|
||||
|
||||
Web 后台"日志"Tab 可实时查看各模块日志。
|
||||
|
||||
---
|
||||
|
||||
## 常用脚本
|
||||
|
||||
| 脚本 | 用途 |
|
||||
|------|------|
|
||||
| `python web_server.py` | 启动 Web 管理后台 |
|
||||
| `python main.py` | 命令行跑一次抖音养号(单设备,不走 web) |
|
||||
| `python create_task.py` | 创建"抖音定时8-9点"任务(示例脚本) |
|
||||
| `python scripts/cleanup.py` | 清理 STF 残留设备占用(强杀进程后用) |
|
||||
| `python scripts/pack.py` | 打包项目为 zip(排除日志/数据库/APK) |
|
||||
| `start_web.bat` | Windows 一键启动(提权 + 防火墙规则) |
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 端口被占用(WinError 10013/10048)
|
||||
|
||||
`web_server.py` 会自动重试候选端口(原端口 → 127.0.0.1:原端口 → 127.0.0.1:原端口+1~+5)。如果全部失败,检查 Windows 动态端口范围:
|
||||
|
||||
```bash
|
||||
netsh interface ipv4 show excludedportrange protocol=tcp
|
||||
```
|
||||
|
||||
修改 `config.py` 中的 `WEB_PORT` 到一个不在排除范围内的端口。
|
||||
|
||||
### STF 设备显示离线但实际在线
|
||||
|
||||
STF 状态有缓存,`present=True` 不代表设备真在线。用"扫描前台App"按钮复测,或直接尝试执行任务。
|
||||
|
||||
### 设备一直被占用无法释放
|
||||
|
||||
运行清理脚本:
|
||||
|
||||
```bash
|
||||
python scripts/cleanup.py
|
||||
```
|
||||
|
||||
会释放当前账户占用的所有设备。
|
||||
|
||||
### u2.connect 卡死
|
||||
|
||||
`u2.connect()` 在 atx-agent 无响应时会永久 hang。基类已用 `ThreadPoolExecutor + 30 秒超时` 保护,超时自动放弃。如果频繁超时,检查设备 atx-agent 是否正常(重启设备或重新推送 atx-agent)。
|
||||
|
||||
### 任务运行中看门狗误杀
|
||||
|
||||
看门狗 120 秒无心跳会标记卡死。长耗时操作(如长视频等待)需在循环内周期性调用 `self.heartbeat()`。
|
||||
|
||||
### 中文输入失败
|
||||
|
||||
uiautomator2 默认 IME 不支持中文,需切到 FastInput 输入法:
|
||||
|
||||
```python
|
||||
try:
|
||||
d.set_fastinput_ime(True)
|
||||
d.send_keys("中文内容")
|
||||
finally:
|
||||
d.set_fastinput_ime(False)
|
||||
```
|
||||
|
||||
### 修改 core/ 后不生效
|
||||
|
||||
`web_server.py` 以 `debug=False` 运行,Python 不会热重载。修改 `core/` 目录下的文件后**必须重启 web_server 进程**。
|
||||
|
||||
### 元素抓取按钮不可用
|
||||
|
||||
依赖 uiautodev 服务(端口 20242)。确保已安装 `pip install uiautodev`。`web_server.py` 启动时会自动拉起该服务。
|
||||
|
||||
---
|
||||
|
||||
## 更多文档
|
||||
|
||||
| 文档 | 内容 |
|
||||
|------|------|
|
||||
| [doc/TASK_DEV.md](file:///d:/platform-tools/doc/TASK_DEV.md) | 任务开发指南(新增 App 任务的完整模板和规范) |
|
||||
| [doc/ARCHITECTURE.md](file:///d:/platform-tools/doc/ARCHITECTURE.md) | 架构详解(分层设计、数据流、关键设计决策) |
|
||||
| [doc/DEPLOY.md](file:///d:/platform-tools/doc/DEPLOY.md) | 部署指南(环境准备、STF 配置、生产部署) |
|
||||
| [doc/API.md](file:///d:/platform-tools/doc/API.md) | API 接口文档(全部 HTTP 接口说明) |
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 组件 | 用途 |
|
||||
|------|------|
|
||||
| Flask + Flask-Login | Web 后台 + 用户认证 |
|
||||
| Flask-SQLAlchemy | SQLite 数据持久化 |
|
||||
| APScheduler | 定时任务调度 |
|
||||
| uiautomator2 | Android UI 自动化 |
|
||||
| uiautodev | UI 元素抓取(步骤编辑器"抓取元素") |
|
||||
| pyaxmlparser | APK 元信息解析 |
|
||||
| OpenSTF | 设备农场管理(占用/释放/远程 ADB) |
|
||||
@@ -111,6 +111,35 @@ class TaskJob(db.Model):
|
||||
return f"<TaskJob {self.name}>"
|
||||
|
||||
|
||||
class CustomAction(db.Model):
|
||||
"""自定义动作(把一系列步骤打包成一个可复用的动作)。
|
||||
|
||||
steps 字段存 JSON 数组,格式同 generic_steps 的 step schema。
|
||||
前端拖拽到画布时,展开为 group 步骤(type='group')。
|
||||
"""
|
||||
id = db.Column(db.String(32), primary_key=True)
|
||||
name = db.Column(db.String(120), nullable=False)
|
||||
icon = db.Column(db.String(4), default="📦")
|
||||
steps = db.Column(db.Text, default="[]") # JSON 数组
|
||||
created_at = db.Column(db.String(20), default="")
|
||||
|
||||
def get_steps(self):
|
||||
try:
|
||||
return json.loads(self.steps or "[]")
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def set_steps(self, v):
|
||||
self.steps = json.dumps(v or [], ensure_ascii=False)
|
||||
|
||||
def to_dict(self):
|
||||
return {"id": self.id, "name": self.name, "icon": self.icon or "📦",
|
||||
"steps": self.get_steps(), "created_at": self.created_at or ""}
|
||||
|
||||
def __repr__(self):
|
||||
return f"<CustomAction {self.name}>"
|
||||
|
||||
|
||||
class ApkFile(db.Model):
|
||||
"""上传的 APK 文件元信息(应用管理功能)。"""
|
||||
id = db.Column(db.String(32), primary_key=True) # uuid 前 8 位
|
||||
|
||||
+96
-71
@@ -302,6 +302,7 @@ class TaskManager:
|
||||
self.groups = {} # name -> DeviceGroup(内存业务对象)
|
||||
self.jobs = {} # id -> TaskJob(内存业务对象)
|
||||
self._running = {} # serial -> {"worker", "job_id", "started_at", "attempt"}
|
||||
self._stop_requested = set() # serial 集合:用户请求停止,阻止后续重试
|
||||
self._lock = threading.Lock()
|
||||
self._fg_scanner = _ForegroundScanner(self.stf)
|
||||
self._load()
|
||||
@@ -557,88 +558,112 @@ class TaskManager:
|
||||
异常分类:
|
||||
DeviceOfflineError — 设备掉线,立即放弃不重试(换设备也没用)
|
||||
其他异常 — 按 max_attempts 重试
|
||||
用户停止(stop_device)会加入 _stop_requested,阻止任何后续重试。
|
||||
"""
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
# 同一 serial 同时只能一个 worker
|
||||
try:
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
# 用户已请求停止 → 不再启动新 attempt
|
||||
with self._lock:
|
||||
if serial in self._stop_requested:
|
||||
_log.info(f"{serial} 用户已请求停止,取消重试 (job={job.name})")
|
||||
return
|
||||
# 同一 serial 同时只能一个 worker
|
||||
with self._lock:
|
||||
if serial in self._running:
|
||||
_log.warning(f"{serial} 已有任务在跑,跳过 (job={job.name})")
|
||||
return
|
||||
self._running[serial] = {"job_id": job.id, "started_at": time.time(),
|
||||
"attempt": attempt, "task_type": job.task_type}
|
||||
_update_status(serial, task_job=job.name, attempt=attempt,
|
||||
max_attempts=max_attempts)
|
||||
|
||||
worker = None
|
||||
try:
|
||||
worker = task.create_worker(self.stf, serial, job.params)
|
||||
with self._lock:
|
||||
self._running[serial]["worker"] = worker
|
||||
_log.info(f"{serial} 开始任务 {job.name} (第{attempt}/{max_attempts}次)")
|
||||
worker.start()
|
||||
worker.join() # 等待 worker 结束
|
||||
# worker 正常结束(done 或被 stop)
|
||||
with self._lock:
|
||||
self._running.pop(serial, None)
|
||||
# 判断是否成功:看 status
|
||||
with _WORKERS_LOCK:
|
||||
st = _WORKERS.get(serial, {}).get("status")
|
||||
# 清除 task_job 标记,避免前端误判"运行中"
|
||||
_update_status(serial, task_job="")
|
||||
if st == "done":
|
||||
_log.info(f"{serial} 任务 {job.name} 成功完成")
|
||||
return
|
||||
# 用户请求停止(无论 attempt 第几次、status 是什么)→ 不重试
|
||||
with self._lock:
|
||||
if serial in self._stop_requested:
|
||||
_log.info(f"{serial} 用户已请求停止,不再重试 (job={job.name})")
|
||||
return
|
||||
_log.warning(f"{serial} 任务未成功(status={st})")
|
||||
except DeviceOfflineError as e:
|
||||
# 设备掉线,立即放弃,不重试
|
||||
_log.error(f"{serial} 设备离线,放弃任务 {job.name}: {e}")
|
||||
with self._lock:
|
||||
self._running.pop(serial, None)
|
||||
_update_status(serial, status="failed", task_job="",
|
||||
last_error=f"设备离线: {e}")
|
||||
return
|
||||
except Exception as e:
|
||||
_log.error(f"{serial} 执行异常: {e}", exc_info=True)
|
||||
with self._lock:
|
||||
self._running.pop(serial, None)
|
||||
_update_status(serial, task_job="")
|
||||
|
||||
if attempt < max_attempts:
|
||||
# 用户在 sleep 期间点停止也能中断
|
||||
with self._lock:
|
||||
if serial in self._stop_requested:
|
||||
_log.info(f"{serial} 用户已请求停止,取消重试 (job={job.name})")
|
||||
return
|
||||
# 检查是否是端口耗尽类临时错误,需要更长退避等端口释放
|
||||
with _WORKERS_LOCK:
|
||||
err = _WORKERS.get(serial, {}).get("last_error", "")
|
||||
if err.startswith("[transient]"):
|
||||
# Windows TCP 端口耗尽,TIME_WAIT 默认 2-4 分钟,等 120 秒
|
||||
extra_delay = max(delay, 120)
|
||||
_log.info(f"{serial} ADB 连接临时错误(端口耗尽),{extra_delay}s 后重试 ({attempt+1}/{max_attempts})")
|
||||
time.sleep(extra_delay)
|
||||
else:
|
||||
_log.info(f"{serial} {delay}s 后重试 ({attempt+1}/{max_attempts})")
|
||||
time.sleep(delay)
|
||||
|
||||
_log.error(f"{serial} 任务 {job.name} 重试耗尽,放弃")
|
||||
_update_status(serial, status="failed", last_error=f"{job.name} 重试{max_attempts}次失败")
|
||||
finally:
|
||||
# 清除停止标志:整个重试循环结束(成功/失败/停止)后允许下次任务
|
||||
with self._lock:
|
||||
if serial in self._running:
|
||||
_log.warning(f"{serial} 已有任务在跑,跳过 (job={job.name})")
|
||||
return
|
||||
self._running[serial] = {"job_id": job.id, "started_at": time.time(),
|
||||
"attempt": attempt, "task_type": job.task_type}
|
||||
_update_status(serial, task_job=job.name, attempt=attempt,
|
||||
max_attempts=max_attempts)
|
||||
|
||||
worker = None
|
||||
try:
|
||||
worker = task.create_worker(self.stf, serial, job.params)
|
||||
with self._lock:
|
||||
self._running[serial]["worker"] = worker
|
||||
_log.info(f"{serial} 开始任务 {job.name} (第{attempt}/{max_attempts}次)")
|
||||
worker.start()
|
||||
worker.join() # 等待 worker 结束
|
||||
# worker 正常结束(done 或被 stop)
|
||||
with self._lock:
|
||||
self._running.pop(serial, None)
|
||||
# 判断是否成功:看 status
|
||||
with _WORKERS_LOCK:
|
||||
st = _WORKERS.get(serial, {}).get("status")
|
||||
# 清除 task_job 标记,避免前端误判"运行中"
|
||||
_update_status(serial, task_job="")
|
||||
if st == "done":
|
||||
_log.info(f"{serial} 任务 {job.name} 成功完成")
|
||||
return
|
||||
if st == "released" and attempt == 1:
|
||||
# 被手动停止,不重试
|
||||
return
|
||||
_log.warning(f"{serial} 任务未成功(status={st})")
|
||||
except DeviceOfflineError as e:
|
||||
# 设备掉线,立即放弃,不重试
|
||||
_log.error(f"{serial} 设备离线,放弃任务 {job.name}: {e}")
|
||||
with self._lock:
|
||||
self._running.pop(serial, None)
|
||||
_update_status(serial, status="failed", task_job="",
|
||||
last_error=f"设备离线: {e}")
|
||||
return
|
||||
except Exception as e:
|
||||
_log.error(f"{serial} 执行异常: {e}", exc_info=True)
|
||||
with self._lock:
|
||||
self._running.pop(serial, None)
|
||||
_update_status(serial, task_job="")
|
||||
|
||||
if attempt < max_attempts:
|
||||
# 检查是否是端口耗尽类临时错误,需要更长退避等端口释放
|
||||
with _WORKERS_LOCK:
|
||||
err = _WORKERS.get(serial, {}).get("last_error", "")
|
||||
if err.startswith("[transient]"):
|
||||
# Windows TCP 端口耗尽,TIME_WAIT 默认 2-4 分钟,等 120 秒
|
||||
extra_delay = max(delay, 120)
|
||||
_log.info(f"{serial} ADB 连接临时错误(端口耗尽),{extra_delay}s 后重试 ({attempt+1}/{max_attempts})")
|
||||
time.sleep(extra_delay)
|
||||
else:
|
||||
_log.info(f"{serial} {delay}s 后重试 ({attempt+1}/{max_attempts})")
|
||||
time.sleep(delay)
|
||||
|
||||
_log.error(f"{serial} 任务 {job.name} 重试耗尽,放弃")
|
||||
_update_status(serial, status="failed", last_error=f"{job.name} 重试{max_attempts}次失败")
|
||||
self._stop_requested.discard(serial)
|
||||
|
||||
# ---- 运行控制 ----
|
||||
def stop_device(self, serial):
|
||||
"""停止指定设备的 worker。"""
|
||||
"""停止指定设备的 worker,并阻止后续重试。
|
||||
|
||||
无论 worker 当前在运行还是在重试 sleep 中,都会阻止下一次重试。
|
||||
"""
|
||||
with self._lock:
|
||||
self._stop_requested.add(serial)
|
||||
info = self._running.get(serial)
|
||||
if not info:
|
||||
return False
|
||||
w = info.get("worker")
|
||||
if w and w.is_alive():
|
||||
w.stop()
|
||||
return True
|
||||
return False
|
||||
if info:
|
||||
w = info.get("worker")
|
||||
if w and w.is_alive():
|
||||
w.stop()
|
||||
return True
|
||||
# worker 已结束但重试循环可能还在 sleep —— 仍然返回 True 表示已阻止重试
|
||||
return True
|
||||
|
||||
def stop_all(self):
|
||||
"""停止所有运行中的 worker。"""
|
||||
"""停止所有运行中的 worker,并阻止后续重试。"""
|
||||
with self._lock:
|
||||
items = list(self._running.items())
|
||||
for s, _ in items:
|
||||
self._stop_requested.add(s)
|
||||
stopped = []
|
||||
for serial, info in items:
|
||||
w = info.get("worker")
|
||||
|
||||
@@ -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": "//*"}
|
||||
+556
@@ -0,0 +1,556 @@
|
||||
# API 接口文档
|
||||
|
||||
`platform-tools` Web 后台提供 JSON API,所有接口需登录后访问(Flask-Login session 认证)。
|
||||
|
||||
**Base URL**:`http://localhost:18050`
|
||||
|
||||
**认证方式**:Cookie Session(先 POST `/login` 获取 session cookie,后续请求带上)
|
||||
|
||||
**通用响应格式**:
|
||||
```json
|
||||
{"ok": true, "data": "..."}
|
||||
{"ok": false, "error": "错误信息"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 认证
|
||||
|
||||
### POST /login
|
||||
|
||||
用户登录。
|
||||
|
||||
**请求**(form-data):
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| username | string | 用户名 |
|
||||
| password | string | 密码 |
|
||||
|
||||
**响应**:成功重定向到 `/`,失败返回登录页(含 error 信息)。
|
||||
|
||||
### GET /logout
|
||||
|
||||
登出,重定向到登录页。
|
||||
|
||||
---
|
||||
|
||||
## 2. 页面路由
|
||||
|
||||
### GET /
|
||||
|
||||
单页应用首页(需登录)。响应头设置 `Cache-Control: no-store` 防止缓存。
|
||||
|
||||
### GET /login
|
||||
|
||||
登录页面(GET)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 设备状态
|
||||
|
||||
### GET /api/status
|
||||
|
||||
获取设备池 + Worker 综合状态(带 5 秒缓存)。
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"server_time": 1700000000.0,
|
||||
"fg_scanning": false,
|
||||
"fg_last_scan": 1700000000.0,
|
||||
"devices": [
|
||||
{
|
||||
"serial": "192.168.1.100:5555",
|
||||
"model": "Pixel 6",
|
||||
"device_name": "测试机1",
|
||||
"present": true,
|
||||
"ready": true,
|
||||
"stf_occupied": false,
|
||||
"owner": "",
|
||||
"worker_status": "idle",
|
||||
"foreground_app": "空闲",
|
||||
"progress": {"done": 5, "total": 80, "unit": "视频", "action_counts": {"like": 3}},
|
||||
"current_action": "观看视频 6",
|
||||
"last_error": "",
|
||||
"running_job": "",
|
||||
"task_job": "",
|
||||
"attempt": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**worker_status 取值**:`idle` / `connecting` / `running` / `done` / `error` / `failed` / `released`
|
||||
|
||||
### POST /api/scan_foreground
|
||||
|
||||
手动触发前台 App 扫描(后台异步执行,不打扰设备)。
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{"ok": true, "msg": "扫描已启动"}
|
||||
{"ok": false, "error": "已有扫描在进行中"}
|
||||
```
|
||||
|
||||
### GET /api/devices
|
||||
|
||||
返回所有在线设备 serial 列表(供分组表单勾选用)。
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{"ok": true, "devices": ["192.168.1.100:5555", "192.168.1.101:5555"]}
|
||||
```
|
||||
|
||||
### GET /api/device/screenshot
|
||||
|
||||
获取设备当前画面截图(PNG)。
|
||||
|
||||
**参数**:
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| serial | string | 设备 serial |
|
||||
| t | int | 时间戳(避免缓存,前端自动加) |
|
||||
|
||||
**响应**:成功返回 `image/png`,失败返回 JSON 错误。
|
||||
|
||||
> 用 `adb exec-out screencap -p`,只读操作,任务运行中调用安全。
|
||||
|
||||
---
|
||||
|
||||
## 4. 任务类型
|
||||
|
||||
### GET /api/task_types
|
||||
|
||||
返回所有已注册任务类型。
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"task_types": [
|
||||
{
|
||||
"task_type": "douyin_nurture",
|
||||
"name": "抖音养号",
|
||||
"description": "自动观看抖音视频...",
|
||||
"default_params": {"watch_count": 80, "...": "..."}
|
||||
},
|
||||
{
|
||||
"task_type": "generic_steps",
|
||||
"name": "通用步骤",
|
||||
"description": "通过步骤编辑器编排...",
|
||||
"default_params": {"steps": [...]}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/actions
|
||||
|
||||
返回指定任务类型支持的专属操作。
|
||||
|
||||
**参数**:
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| task_type | string | 任务类型 |
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"actions": [
|
||||
{"action_type": "like", "name": "点赞", "description": "...", "default_params": {"rate": 0.3}}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 任务计划 CRUD
|
||||
|
||||
### GET /api/jobs
|
||||
|
||||
列出所有任务计划。
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"jobs": [{"id": "abc123", "name": "抖音养号", "task_type": "douyin_nurture", "...": "..."}],
|
||||
"task_types": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/jobs
|
||||
|
||||
创建任务计划。
|
||||
|
||||
**请求**(JSON):
|
||||
```json
|
||||
{
|
||||
"name": "抖音养号",
|
||||
"task_type": "douyin_nurture",
|
||||
"target": {"mode": "all"},
|
||||
"params": {"watch_count": 50, "actions": {"like": {"params": {"rate": 0.5}}}},
|
||||
"schedule": {"mode": "once"},
|
||||
"retry": {"max_attempts": 3, "delay": 60},
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{"ok": true, "msg": "任务已创建", "job": {"id": "abc123", "...": "..."}}
|
||||
```
|
||||
|
||||
### PUT /api/jobs/:job_id
|
||||
|
||||
更新任务计划。只需传要更新的字段。
|
||||
|
||||
**请求**(JSON):
|
||||
```json
|
||||
{"params": {"watch_count": 100}}
|
||||
```
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{"ok": true, "msg": "任务已更新", "job": {"...": "..."}}
|
||||
```
|
||||
|
||||
### DELETE /api/jobs/:job_id
|
||||
|
||||
删除任务计划。
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{"ok": true, "msg": "任务已删除"}
|
||||
```
|
||||
|
||||
### POST /api/jobs/:job_id/run
|
||||
|
||||
立即执行任务(异步,不阻塞)。
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{"ok": true, "msg": "任务 抖音养号 已触发"}
|
||||
```
|
||||
|
||||
### POST /api/jobs/:job_id/toggle
|
||||
|
||||
启用/停用任务。
|
||||
|
||||
**请求**(JSON):
|
||||
```json
|
||||
{"enabled": false}
|
||||
```
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{"ok": true, "msg": "任务已停用"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 设备分组
|
||||
|
||||
### GET /api/groups
|
||||
|
||||
列出所有分组。
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"groups": [
|
||||
{"name": "A组", "serials": ["192.168.1.100:5555"], "description": "测试组"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/groups
|
||||
|
||||
创建分组。
|
||||
|
||||
**请求**(JSON):
|
||||
```json
|
||||
{"name": "A组", "serials": ["192.168.1.100:5555"], "description": "测试组"}
|
||||
```
|
||||
|
||||
### PUT /api/groups/:name
|
||||
|
||||
更新分组。
|
||||
|
||||
**请求**(JSON):
|
||||
```json
|
||||
{"serials": ["192.168.1.100:5555", "192.168.1.101:5555"], "description": "更新描述"}
|
||||
```
|
||||
|
||||
### DELETE /api/groups/:name
|
||||
|
||||
删除分组。
|
||||
|
||||
---
|
||||
|
||||
## 7. 运行控制
|
||||
|
||||
### POST /api/stop_device
|
||||
|
||||
停止单台设备的 worker(并阻止后续重试)。
|
||||
|
||||
**请求**(JSON):
|
||||
```json
|
||||
{"serial": "192.168.1.100:5555"}
|
||||
```
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{"ok": true, "msg": "已发送停止信号给 192.168.1.100:5555"}
|
||||
```
|
||||
|
||||
### POST /api/stop_all
|
||||
|
||||
停止所有运行中的 worker。
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{"ok": true, "stopped": ["192.168.1.100:5555", "192.168.1.101:5555"]}
|
||||
```
|
||||
|
||||
### POST /api/release
|
||||
|
||||
释放当前账户占用的所有 STF 设备(清理用)。
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{"ok": true, "released": ["192.168.1.100:5555"]}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 用户管理
|
||||
|
||||
### GET /api/users
|
||||
|
||||
列出所有用户。
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{"ok": true, "users": [{"id": 1, "username": "admin", "is_admin": true}]}
|
||||
```
|
||||
|
||||
### POST /api/users
|
||||
|
||||
创建用户。
|
||||
|
||||
**请求**(JSON):
|
||||
```json
|
||||
{"username": "user1", "password": "pass123", "is_admin": false}
|
||||
```
|
||||
|
||||
### PUT /api/users/:uid
|
||||
|
||||
更新用户(修改密码/管理员权限)。
|
||||
|
||||
**请求**(JSON):
|
||||
```json
|
||||
{"password": "newpass", "is_admin": true}
|
||||
```
|
||||
|
||||
### DELETE /api/users/:uid
|
||||
|
||||
删除用户(不能删除 admin 和当前登录用户)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 日志
|
||||
|
||||
### GET /api/logs
|
||||
|
||||
查看日志文件内容。
|
||||
|
||||
**参数**:
|
||||
| 参数 | 类型 | 默认 | 说明 |
|
||||
|------|------|------|------|
|
||||
| file | string | `core.log` | 日志文件名 |
|
||||
| lines | int | 300 | 返回最后 N 行 |
|
||||
|
||||
**可选文件**:`core.log` / `task.log` / `web.log` / `action.log`
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"content": "2026-08-08 10:00:00 [INFO] [core.worker] ...",
|
||||
"file": "core.log",
|
||||
"files": {"core": "core.log", "task": "task.log", "web": "web.log", "action": "action.log"}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 自定义动作
|
||||
|
||||
### GET /api/custom_actions
|
||||
|
||||
列出所有自定义动作(步骤打包)。
|
||||
|
||||
### POST /api/custom_actions
|
||||
|
||||
创建自定义动作。
|
||||
|
||||
**请求**(JSON):
|
||||
```json
|
||||
{"name": "登录流程", "icon": "📦", "steps": [{"type": "click", "...": "..."}]}
|
||||
```
|
||||
|
||||
### PUT /api/custom_actions/:action_id
|
||||
|
||||
更新自定义动作。
|
||||
|
||||
### DELETE /api/custom_actions/:action_id
|
||||
|
||||
删除自定义动作。
|
||||
|
||||
---
|
||||
|
||||
## 11. 元素抓取(uiauto2)
|
||||
|
||||
### GET /api/uiauto/status
|
||||
|
||||
探测 uiauto2 本地服务是否运行。
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{"ok": true, "running": true}
|
||||
```
|
||||
|
||||
### GET /api/uiauto/devices
|
||||
|
||||
获取 uiauto2 已连接的设备列表。
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{"ok": true, "devices": [{"serial": "192.168.1.100:5555", "model": "Pixel 6"}]}
|
||||
```
|
||||
|
||||
### GET /api/uiauto/screenshot
|
||||
|
||||
通过 uiauto2 获取设备截图(JPEG)。
|
||||
|
||||
**参数**:
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| serial | string | 设备 serial |
|
||||
|
||||
**响应**:成功返回 `image/jpeg`,失败返回 JSON 错误。
|
||||
|
||||
### GET /api/uiauto/elements
|
||||
|
||||
获取设备 UI 元素树。
|
||||
|
||||
**参数**:
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| serial | string | 设备 serial |
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"elements": [
|
||||
{
|
||||
"depth": 0,
|
||||
"name": "android.widget.FrameLayout",
|
||||
"resource_id": "",
|
||||
"text": "",
|
||||
"description": "",
|
||||
"class": "android.widget.FrameLayout",
|
||||
"bounds": "[0,0][1080,2400]",
|
||||
"suggested": {"type": "xpath", "value": "//*"}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 应用管理(APK)
|
||||
|
||||
### GET /api/apks
|
||||
|
||||
列出所有已上传的 APK。
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"apks": [
|
||||
{
|
||||
"id": "abc123",
|
||||
"display_name": "抖音",
|
||||
"package_name": "com.ss.android.ugc.aweme",
|
||||
"version_name": "25.0.0",
|
||||
"version_code": 2500,
|
||||
"size": 104857600,
|
||||
"upload_time": "2026-08-08 10:00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/apks/upload
|
||||
|
||||
上传 APK 文件(自动解析包名/版本/应用名)。
|
||||
|
||||
**请求**(multipart/form-data):
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| file | file | APK 文件 |
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{"ok": true, "apk": {"...": "..."}, "msg": "上传成功: 抖音"}
|
||||
```
|
||||
|
||||
### DELETE /api/apks/:apk_id
|
||||
|
||||
删除 APK 文件和记录。
|
||||
|
||||
### POST /api/apks/install
|
||||
|
||||
批量安装 APK 到指定设备。
|
||||
|
||||
**请求**(JSON):
|
||||
```json
|
||||
{"apk_id": "abc123", "serials": ["192.168.1.100:5555", "192.168.1.101:5555"]}
|
||||
```
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{"ok": true, "msg": "开始安装 抖音 到 2 台设备"}
|
||||
```
|
||||
|
||||
### GET /api/apks/install/status
|
||||
|
||||
获取安装任务实时状态。
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"status": {
|
||||
"apk_name": "抖音",
|
||||
"finished": false,
|
||||
"total": 2,
|
||||
"success": 1,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
"installing": 1,
|
||||
"pending": 0,
|
||||
"items": {
|
||||
"192.168.1.100:5555": {"name": "Pixel 6", "status": "success", "msg": "安装成功(已验证)"},
|
||||
"192.168.1.101:5555": {"name": "Pixel 7", "status": "installing", "msg": "正在安装..."}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,340 @@
|
||||
# 架构详解
|
||||
|
||||
本文面向想深入理解 `platform-tools` 内部设计的开发者。如果你只想使用,看 [README.md](file:///d:/platform-tools/README.md) 即可。
|
||||
|
||||
---
|
||||
|
||||
## 1. 分层设计
|
||||
|
||||
平台按"配置 / 核心 / 任务 / 前端 / 数据 / 日志 / 工具"分层,职责清晰、互不交叉:
|
||||
|
||||
| 层 | 路径 | 职责 |
|
||||
|----|------|------|
|
||||
| 配置层 | `config.py` | 项目根配置:STF 地址、adb 路径、web 端口等基础设施。**不放任务参数** |
|
||||
| 核心层 | `core/` | 框架运行时:日志、STF 客户端、adb 操作、Worker 基类、任务管理器、u2 辅助、Action 基类 |
|
||||
| 任务层 | `tasks/` | 每个 App 一个子包,自包含 `task.py` + `actions/`,互不依赖 |
|
||||
| 前端层 | `templates/admin/` | 单页应用(纯 HTML+CSS+JS,无框架) |
|
||||
| 数据层 | `data/` | SQLite 持久化 |
|
||||
| 日志层 | `logs/` | 四类日志,10MB 滚动保留 5 份 |
|
||||
| 工具层 | `bin/adb/` | adb 可执行文件 |
|
||||
| 脚本层 | `scripts/` | 实用脚本 |
|
||||
|
||||
### 分层原则
|
||||
|
||||
- **任务自包含**:每个任务的参数、Worker、操作都放在 `tasks/<app>/` 下,不污染全局
|
||||
- **核心不依赖任务**:`core/` 不 import `tasks/`,任务通过注册机制接入
|
||||
- **配置最小化**:`config.py` 只放基础设施配置,任务参数在各自 `task.py` 顶部
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据流
|
||||
|
||||
```
|
||||
┌──────────────┐ 创建/编辑任务 ┌─────────────┐ 分发 worker ┌──────────────┐
|
||||
│ 单页应用前端 │ ───────────────► │ TaskManager │ ─────────────► │ Worker(设备) │
|
||||
│ (monitor.html│ └─────────────┘ └──────────────┘
|
||||
│ fetch+DOM) │ ▲ │
|
||||
└──────────────┘ │ 状态/心跳 │ u2 操作
|
||||
│ │ ▼
|
||||
│ JSON API │ ┌─────────────────┐
|
||||
▼ ┌──────────────┐ │ STF Device / adb │
|
||||
┌──────────────┐ │ 看门狗监控 │ └─────────────────┘
|
||||
│ web_server │ └──────────────┘
|
||||
│ (Flask API) │
|
||||
└──────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ data/users.db│ SQLite 持久化(用户/分组/任务/自定义动作/APK记录)
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
### 任务执行流程
|
||||
|
||||
1. 前端创建 TaskJob(HTTP POST `/api/jobs`)
|
||||
2. `TaskManager` 保存到 SQLite,如启用 cron 则注册到 APScheduler
|
||||
3. 手动执行或 cron 触发时,`_run_job` 解析目标设备列表
|
||||
4. 每台设备起一个线程 `_run_with_retry`,含重试循环
|
||||
5. 线程内 `task.create_worker()` 创建 Worker,`worker.start()` 启动
|
||||
6. `BaseWorker.run()` 执行设备生命周期:占用 → 连接 → setup → run_task → teardown → 释放
|
||||
7. Worker 通过 `_update_status()` 实时上报状态到全局 `_WORKERS` 字典
|
||||
8. 前端轮询 `/api/status`(5 秒缓存)获取设备 + Worker 状态
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心模块详解
|
||||
|
||||
### 3.1 STFClient(`core/stf_client.py`)
|
||||
|
||||
OpenSTF REST API 封装,负责设备池管理。
|
||||
|
||||
**关键设计**:
|
||||
- 所有请求带 `timeout=(5, 15)`,避免 STF 网关 504 时卡死
|
||||
- `remote_connect` 带重试(3 次),STF provider 慢启动时需要
|
||||
- 错误分类:`DeviceOfflineError`(不重试)/ `STFNetworkError` / `DeviceConflictError`(自动重试)
|
||||
- 占用冲突自动清理重试(偶发 "already in use" 脏状态)
|
||||
|
||||
**核心方法**:
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `list_all_devices()` | 返回 STF 上所有设备(含状态) |
|
||||
| `list_free_devices()` | 返回可占用的空闲设备 |
|
||||
| `list_my_devices()` | 返回当前账户已占用的设备 |
|
||||
| `occupy(serial)` | 占用设备(冲突自动重试) |
|
||||
| `release(serial)` | 释放设备(含远程断开) |
|
||||
| `remote_connect(serial)` | 建立远程 ADB 隧道,返回 remoteConnectUrl |
|
||||
| `remote_disconnect(serial)` | 断开远程 ADB 隧道 |
|
||||
|
||||
### 3.2 STFDevice + BaseWorker(`core/device_worker.py`)
|
||||
|
||||
**STFDevice** — 单设备生命周期管理:
|
||||
- `acquire()`:占用 → remoteConnect → adb connect
|
||||
- `release()`:adb disconnect → remoteDisconnect → STF release
|
||||
- 直连模式:serial 为 `IP:5555` 时直接 adb connect,不走 STF 桥接
|
||||
|
||||
**BaseWorker** — 通用 Worker 基类(继承 threading.Thread):
|
||||
|
||||
```
|
||||
run() 主循环(不要重写):
|
||||
1. acquire 设备
|
||||
2. u2.connect(30s 超时保护)
|
||||
3. setup(d) ← 子类可选钩子
|
||||
4. run_task(d) ← 子类必须实现
|
||||
5. teardown(d) ← 子类可选钩子
|
||||
6. finally: release 设备
|
||||
```
|
||||
|
||||
**超时保护**:
|
||||
- `u2.connect()` 用 `ThreadPoolExecutor + 30s 超时`,防止 atx-agent 无响应永久 hang
|
||||
- `d.info` 用 `ThreadPoolExecutor + 10s 超时`
|
||||
|
||||
**心跳看门狗**(`_Watchdog`):
|
||||
- 后台线程,每 30 秒扫描一次
|
||||
- Worker 超过 120 秒无心跳 → 标记 `error`
|
||||
- 防止设备被占用却不干活
|
||||
|
||||
**全局状态注册表**(`_WORKERS`):
|
||||
- `serial -> status dict`,线程安全(`_WORKERS_LOCK`)
|
||||
- 供 `web_server` 读取实时状态,前端通过 `/api/status` 展示
|
||||
|
||||
### 3.3 TaskManager(`core/task_manager.py`)
|
||||
|
||||
统一管理:任务类型注册、设备分组、任务计划、定时调度、重试、持久化。
|
||||
|
||||
**核心组成**:
|
||||
|
||||
| 组件 | 说明 |
|
||||
|------|------|
|
||||
| `scheduler` | APScheduler BackgroundScheduler,cron 触发任务 |
|
||||
| `groups` | 设备分组(内存业务对象,持久化到 SQLite) |
|
||||
| `jobs` | 任务计划(内存业务对象,持久化到 SQLite) |
|
||||
| `_running` | 运行中的 worker(serial -> worker 信息) |
|
||||
| `_stop_requested` | 用户请求停止的 serial 集合(阻止后续重试) |
|
||||
| `_fg_scanner` | 前台 App 扫描器(不打扰设备) |
|
||||
|
||||
**调度模式**:
|
||||
- `once`:不注册 cron,手动执行
|
||||
- `cron`:注册启动 cron,到点启动所有目标设备
|
||||
- `cron_stop`:注册启动 cron + 停止 cron,到点停止本任务 worker
|
||||
|
||||
**重试策略**:
|
||||
- `DeviceOfflineError`:立即放弃,不重试(设备掉线短时间不会自愈)
|
||||
- 其他异常:按 `retry.max_attempts` 重试,间隔 `retry.delay`
|
||||
- 临时错误(端口耗尽):退避 max(delay, 120s)
|
||||
- 用户停止:加入 `_stop_requested`,阻止任何后续重试
|
||||
|
||||
**并发控制**:同一 serial 同时只允许一个 worker,避免冲突。
|
||||
|
||||
**状态缓存**:`get_status()` 带 5 秒缓存,STF 请求慢时不阻塞前端。
|
||||
|
||||
### 3.4 前台 App 扫描器(`_ForegroundScanner`)
|
||||
|
||||
**设计原则:不打扰设备**,扫描不会让设备退出当前 App。
|
||||
|
||||
| 设备状态 | 处理方式 | 是否打扰 |
|
||||
|---------|---------|---------|
|
||||
| worker 运行中 | 复用已有 ADB 连接查询 | 否 |
|
||||
| 自己占用无 worker | STF remoteConnect 隧道查询 | 否 |
|
||||
| 完全空闲 | 返回"空闲"(不 adb connect,避免 STF 误判离线) | 否 |
|
||||
| 他人占用 | 标记"(他人占用)" | 否 |
|
||||
|
||||
> **为什么不扫描空闲设备的前台 App**:STF provider 内部通过 IP:5555 维持 adb 连接。外部 adb connect/disconnect 会让 adb server 断开该 transport,连带 STF provider 的连接断开,STF 误判设备 offline 并触发重连。
|
||||
|
||||
### 3.5 ADB 操作(`core/adb_helper.py`)
|
||||
|
||||
**全局锁串行化**:`_ADB_LOCK` 确保所有 adb 调用串行执行,避免多线程竞争 adb server。
|
||||
|
||||
**铁律:绝不 kill-server**:
|
||||
- `adb kill-server` 会断开所有设备的 adb transport
|
||||
- 导致 STF provider 对全部设备误判离线并触发重连
|
||||
- 影响所有运行中的任务
|
||||
|
||||
| 函数 | 说明 |
|
||||
|------|------|
|
||||
| `adb_connect(url, retries=5)` | adb connect(带重试,绝不 kill-server) |
|
||||
| `adb_connect_light(url)` | 轻量 connect(单次尝试,扫描专用) |
|
||||
| `adb_disconnect(url)` | adb disconnect |
|
||||
| `screenshot(serial)` | 截图(adb exec-out screencap -p,只读安全) |
|
||||
| `get_foreground_app(url)` | 获取前台 App 包名(dumpsys window) |
|
||||
| `identify_device(serial)` | 让设备响铃识别 |
|
||||
|
||||
### 3.6 数据模型(`core/models.py`)
|
||||
|
||||
SQLAlchemy 模型,存于 `data/users.db`:
|
||||
|
||||
| 模型 | 表名 | 说明 |
|
||||
|------|------|------|
|
||||
| `User` | user | 后台用户(Flask-Login 认证,SHA256 密码) |
|
||||
| `DeviceGroup` | device_group | 设备分组(serials 存 JSON) |
|
||||
| `TaskJob` | task_job | 任务计划(target/params/schedule/retry 存 JSON) |
|
||||
| `CustomAction` | custom_action | 自定义动作(步骤打包,steps 存 JSON) |
|
||||
| `ApkFile` | apk_file | APK 文件元信息 |
|
||||
|
||||
**数据库初始化**(`init_db`):
|
||||
- 创建所有表
|
||||
- 首次启动创建默认管理员 `admin/admin123`
|
||||
- 自动迁移旧 `groups.json` / `jobs.json` 到 SQLite(迁移后归档为 `.migrated`)
|
||||
|
||||
### 3.7 APK 管理(`core/apk_manager.py`)
|
||||
|
||||
APK 上传/解析/批量安装。
|
||||
|
||||
**设备连接策略(直连,绕过 STF)**:
|
||||
- 直接 `adb connect serial`(serial 是 IP:5555)
|
||||
- 不经过 STF occupy/release,避免 STF release 触发 agent 清理卸载 app
|
||||
- 安装后不主动 disconnect(STF provider 共享该 adb transport)
|
||||
|
||||
**安装流程**:
|
||||
1. 上传 APK → 保存到 `data/apks/` → pyaxmlparser 解析包名/版本 → 入库
|
||||
2. 批量安装 → 后台线程 → 每台设备直连 adb install → 验证包名
|
||||
3. 跳过 worker 运行中的设备(避免打断任务)
|
||||
|
||||
### 3.8 元素抓取(`core/uiauto_helper.py`)
|
||||
|
||||
封装 uiautodev 本地服务(端口 20242)的客户端。
|
||||
|
||||
| 函数 | 说明 |
|
||||
|------|------|
|
||||
| `is_running()` | 探测 uiauto2 服务是否运行 |
|
||||
| `list_devices()` | 获取 uiauto2 已连接的设备列表 |
|
||||
| `get_screenshot(serial)` | 获取设备截图(JPEG) |
|
||||
| `get_elements(serial)` | 获取设备 UI 元素树(扁平化列表) |
|
||||
|
||||
元素树解析:递归提取每个节点的 `resource-id/text/content-desc/class/bounds` 等属性,并推荐最佳选择器(优先 xpath)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 任务系统设计
|
||||
|
||||
### 4.1 注册机制
|
||||
|
||||
```
|
||||
tasks/__init__.py
|
||||
├── from .base import BaseTask, register_task, list_task_types, get_task_class
|
||||
├── from .douyin import task # 触发 @register_task
|
||||
└── from .generic import task # 触发 @register_task
|
||||
```
|
||||
|
||||
`@register_task` 装饰器将 Task 类注册到全局 `_TASK_TYPES` 字典,key 为 `task_type` 字符串。
|
||||
|
||||
### 4.2 参数深合并
|
||||
|
||||
Job 下发时只传需要覆盖的字段,调度器做三层合并:
|
||||
|
||||
1. **顶层字段**:Job params 覆盖 DEFAULT_PARAMS
|
||||
2. **actions 字段**:参数级深合并
|
||||
- 前端没传的 action → 用默认
|
||||
- 前端传了 → `enabled` 和 `params` 分别合并
|
||||
- `params` 再深合并一层(保留前端没传的子参数)
|
||||
|
||||
示例:只想改点赞概率,Job params 只需:
|
||||
```json
|
||||
{"actions": {"like": {"params": {"rate": 0.5}}}}
|
||||
```
|
||||
|
||||
### 4.3 Action 系统
|
||||
|
||||
每个 App 有独立的 Action 注册表(`create_action_registry()`),互不污染。
|
||||
|
||||
```
|
||||
core/actions/base.py — BaseAction 全局基类 + should_trigger + register_action
|
||||
tasks/douyin/actions/base.py — ACTIONS = create_action_registry() + list/get 函数
|
||||
tasks/douyin/actions/like.py — @register_action(ACTIONS) LikeAction
|
||||
```
|
||||
|
||||
**循环导入坑**:`actions/__init__.py` 必须先 `from .base import ACTIONS`,再 `from . import like`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 前端设计
|
||||
|
||||
### 5.1 单页应用
|
||||
|
||||
`templates/admin/monitor.html` 是纯 HTML+CSS+JS 单页应用,无框架依赖。
|
||||
|
||||
- **Tab 切换**:5 个 Tab(监控/任务/分组/日志/用户),纯 DOM 操作
|
||||
- **数据获取**:`fetch()` 调 JSON API,5 秒轮询 `/api/status`
|
||||
- **状态渲染**:设备表格、任务卡片、进度条、徽章,纯 DOM 操作
|
||||
|
||||
### 5.2 步骤编辑器
|
||||
|
||||
`generic_steps` 任务的步骤编辑器:
|
||||
- 左侧操作库(拖拽源)
|
||||
- 中间画布(步骤卡片列表,HTML5 Drag API 排序)
|
||||
- 每个步骤卡片可展开参数表单
|
||||
- 选择器字段旁有"抓取元素"按钮(独立模态框)
|
||||
|
||||
### 5.3 元素抓取模态框
|
||||
|
||||
独立的第二层模态框(`el-picker-overlay`,z-index 1100),不影响任务编辑窗口:
|
||||
1. 选择设备 → 2. 加载截图 + 元素树 → 3. 点击元素/边界框 → 4. 回填选择器
|
||||
|
||||
---
|
||||
|
||||
## 6. 关键设计决策
|
||||
|
||||
### 6.1 为什么用 SQLite 而不是 JSON 文件
|
||||
|
||||
- 支持用户/分组/任务的关系存储
|
||||
- 并发安全(WAL 模式)
|
||||
- 迁移旧 JSON 时归档为 `.migrated`,避免删空后重启又复原
|
||||
|
||||
### 6.2 为什么用 threading 而不是 asyncio
|
||||
|
||||
- uiautomator2 是同步阻塞库,不适合 asyncio
|
||||
- 多设备并发用多线程即可,每台设备一个 Worker 线程
|
||||
- Flask `threaded=True` 处理并发 HTTP 请求
|
||||
|
||||
### 6.3 为什么绝不 kill-server
|
||||
|
||||
`adb kill-server` 会断开所有设备的 adb transport,导致 STF provider 误判全部设备离线并触发重连。连接失败就返回 False,由调用方处理。
|
||||
|
||||
### 6.4 为什么状态查询带缓存
|
||||
|
||||
STF API 响应慢(设备多时 2-5 秒),每次 `/api/status` 都打 STF 会阻塞 Flask。带 5 秒缓存,Worker 状态实时读内存(无 IO)。
|
||||
|
||||
### 6.5 为什么 DeviceOfflineError 不重试
|
||||
|
||||
设备掉线后短时间内不会自愈,重试只会浪费配额并阻塞调度器。让设备进入冷却,由运维/STF 恢复后再启用。
|
||||
|
||||
---
|
||||
|
||||
## 7. 线程模型
|
||||
|
||||
```
|
||||
主线程(Flask)
|
||||
├── HTTP 请求处理(threaded=True,每请求一线程)
|
||||
├── APScheduler 线程(cron 触发)
|
||||
├── 看门狗线程(_Watchdog,30s 间隔)
|
||||
├── uiautodev 子进程
|
||||
└── Worker 线程(每台设备一个)
|
||||
├── _run_with_retry 线程(重试循环)
|
||||
└── BaseWorker 线程(设备生命周期 + run_task)
|
||||
```
|
||||
|
||||
**线程安全**:
|
||||
- `_WORKERS_LOCK`:保护全局 worker 状态字典
|
||||
- `_ADB_LOCK`:串行化所有 adb 调用
|
||||
- `TaskManager._lock`:保护运行中任务字典
|
||||
- `_ForegroundScanner._cache_lock`:保护前台 App 缓存
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
# 部署指南
|
||||
|
||||
本文介绍如何从零部署 `platform-tools` 设备自动化后台。
|
||||
|
||||
---
|
||||
|
||||
## 1. 环境准备
|
||||
|
||||
### 1.1 Python 环境
|
||||
|
||||
- **Python 3.10+**(推荐 3.12)
|
||||
- 安装后确认 `python --version` 和 `pip` 可用
|
||||
|
||||
```bash
|
||||
# 验证
|
||||
python --version # 应输出 3.10+
|
||||
pip --version
|
||||
```
|
||||
|
||||
### 1.2 OpenSTF 服务
|
||||
|
||||
本项目依赖 OpenSTF 管理设备池。如已有 STF 服务,跳过本节。
|
||||
|
||||
STF 部署参考官方文档:https://github.com/DeviceFarmer/stf
|
||||
|
||||
**需要获取的信息**:
|
||||
- STF 服务地址(如 `http://192.168.20.220:7100`)
|
||||
- STF API Token(在 STF 个人设置 → API Keys 里生成)
|
||||
|
||||
### 1.3 adb 工具
|
||||
|
||||
项目自带 adb 二进制在 `bin/adb/` 目录:
|
||||
|
||||
- **Windows**:`bin/adb/adb.exe` + 依赖 dll(已包含)
|
||||
- **Linux**:`bin/adb/adb`(需 `chmod +x`)
|
||||
- **macOS**:`bin/adb/adb`(需 `chmod +x`)
|
||||
|
||||
如需替换为自己的 adb 版本,把对应平台的 adb 放进 `bin/adb/` 即可,`config.py` 会自动识别操作系统。
|
||||
|
||||
### 1.4 设备准备
|
||||
|
||||
设备需满足:
|
||||
- 开启 **USB 调试**(设置 → 开发者选项)
|
||||
- 或通过 **adb 网络连接**(设置 → 开发者选项 → 无线调试,获取 IP:5555)
|
||||
- 已接入 STF 设备池(STF 显示 present=True, ready=True)
|
||||
|
||||
---
|
||||
|
||||
## 2. 安装部署
|
||||
|
||||
### 2.1 获取代码
|
||||
|
||||
```bash
|
||||
# 方式一:直接拷贝项目目录
|
||||
# 方式二:解压打包文件(python scripts/pack.py 生成的 zip)
|
||||
```
|
||||
|
||||
### 2.2 安装依赖
|
||||
|
||||
```bash
|
||||
cd platform-tools
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
**依赖清单**(`requirements.txt`):
|
||||
|
||||
| 包 | 版本 | 用途 |
|
||||
|----|------|------|
|
||||
| Flask | >=2.3,<4.0 | Web 框架 |
|
||||
| Flask-Login | >=0.6 | 用户认证 |
|
||||
| Flask-SQLAlchemy | >=3.0,<4.0 | SQLite ORM |
|
||||
| APScheduler | >=3.10,<4.0 | 定时调度 |
|
||||
| requests | >=2.28 | HTTP 客户端 |
|
||||
| uiautomator2 | >=3.0 | Android UI 自动化 |
|
||||
| uiautodev | >=0.14 | UI 元素抓取 |
|
||||
| pyaxmlparser | >=0.3.27 | APK 元信息解析 |
|
||||
|
||||
> uiautomator2 首次连接设备时会自动推送 atx-agent 到设备,无需手动安装。
|
||||
|
||||
### 2.3 修改配置
|
||||
|
||||
编辑 `config.py`,**必须修改**以下两项:
|
||||
|
||||
```python
|
||||
STF_URL = "http://你的STF地址:端口"
|
||||
STF_TOKEN = "你的STF_API_Token"
|
||||
```
|
||||
|
||||
其他配置按需调整:
|
||||
|
||||
| 配置项 | 默认值 | 何时修改 |
|
||||
|-------|--------|---------|
|
||||
| `WEB_HOST` | `0.0.0.0` | 仅本机访问改为 `127.0.0.1` |
|
||||
| `WEB_PORT` | `18050` | 端口冲突时修改 |
|
||||
| `ADB_PATH` | 自动识别 | 用自定义 adb 时修改 |
|
||||
|
||||
### 2.4 启动服务
|
||||
|
||||
**方式一:命令行启动**
|
||||
|
||||
```bash
|
||||
python web_server.py
|
||||
```
|
||||
|
||||
**方式二:Windows 一键启动**
|
||||
|
||||
双击 `start_web.bat`:
|
||||
- 自动提权到管理员
|
||||
- 添加防火墙规则(支持局域网访问)
|
||||
- 启动 web_server
|
||||
|
||||
### 2.5 验证部署
|
||||
|
||||
1. 控制台看到 `启动服务: http://localhost:18050/` 即成功
|
||||
2. 浏览器访问 `http://localhost:18050/`
|
||||
3. 用 `admin/admin123` 登录
|
||||
4. 监控页应显示 STF 设备池中的设备
|
||||
|
||||
---
|
||||
|
||||
## 3. 生产部署建议
|
||||
|
||||
### 3.1 进程守护
|
||||
|
||||
用进程守护工具确保服务自动重启:
|
||||
|
||||
**Windows(NSSM)**:
|
||||
```bat
|
||||
nssm install platform-tools "C:\Python312\python.exe" "D:\platform-tools\web_server.py"
|
||||
nssm start platform-tools
|
||||
```
|
||||
|
||||
**Linux(systemd)**:
|
||||
```ini
|
||||
# /etc/systemd/system/platform-tools.service
|
||||
[Unit]
|
||||
Description=Platform Tools Web Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www
|
||||
WorkingDirectory=/opt/platform-tools
|
||||
ExecStart=/usr/bin/python3 web_server.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
### 3.2 反向代理(可选)
|
||||
|
||||
如需 HTTPS 或 80 端口,用 Nginx 反向代理:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name auto.example.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:18050;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 安全加固
|
||||
|
||||
- **修改默认密码**:登录后立即在"用户"Tab 修改 admin 密码
|
||||
- **修改 SECRET_KEY**:编辑 `web_server.py`,把 `app.config["SECRET_KEY"]` 改成随机字符串
|
||||
- **限制访问**:生产环境把 `WEB_HOST` 改为 `127.0.0.1`,配合反向代理
|
||||
- **防火墙**:只开放必要端口
|
||||
|
||||
### 3.4 日志管理
|
||||
|
||||
- 日志自动滚动(10MB 一份,保留 5 份)
|
||||
- 日志目录 `logs/`,可在"日志"Tab 在线查看
|
||||
- 长期运行建议定期清理或配置 logrotate
|
||||
|
||||
### 3.5 数据备份
|
||||
|
||||
- 数据库 `data/users.db` 包含用户/分组/任务数据
|
||||
- APK 文件在 `data/apks/`
|
||||
- 建议定期备份 `data/` 目录
|
||||
|
||||
---
|
||||
|
||||
## 4. 网络配置
|
||||
|
||||
### 4.1 端口说明
|
||||
|
||||
| 端口 | 服务 | 说明 |
|
||||
|------|------|------|
|
||||
| 18050 | Web 后台 | 主服务端口(config.py 可改) |
|
||||
| 20242 | uiautodev | 元素抓取服务(自动启动,固定端口) |
|
||||
| 7100 | STF | STF 服务端口(STF 自己的配置) |
|
||||
| 5555 | adb | 设备 adb 网络端口(设备端) |
|
||||
|
||||
### 4.2 Windows 端口问题
|
||||
|
||||
Windows 可能将某些端口范围划为动态排除范围,导致绑定失败(WinError 10013)。
|
||||
|
||||
```bash
|
||||
# 查看排除的端口范围
|
||||
netsh interface ipv4 show excludedportrange protocol=tcp
|
||||
```
|
||||
|
||||
如果 18050 在排除范围内,修改 `config.py` 的 `WEB_PORT` 到一个不在排除范围内的端口。
|
||||
|
||||
或运行 `scripts/fix_web.bat`(关闭系统代理 + 添加防火墙规则 + 刷新 DNS)。
|
||||
|
||||
### 4.3 局域网访问
|
||||
|
||||
- `WEB_HOST = "0.0.0.0"` 允许局域网访问
|
||||
- 需要添加防火墙入站规则(`start_web.bat` 会自动处理)
|
||||
- 局域网其他机器访问 `http://部署机IP:18050/`
|
||||
|
||||
---
|
||||
|
||||
## 5. 更新升级
|
||||
|
||||
### 5.1 代码更新
|
||||
|
||||
```bash
|
||||
# 1. 停止服务
|
||||
# 2. 替换代码文件(或解压新的 zip)
|
||||
# 3. 重新安装依赖(如有新增)
|
||||
pip install -r requirements.txt
|
||||
# 4. 启动服务
|
||||
python web_server.py
|
||||
```
|
||||
|
||||
### 5.2 数据库迁移
|
||||
|
||||
- SQLite 表结构变化时,`init_db()` 会自动 `db.create_all()` 创建新表
|
||||
- 旧 `groups.json` / `jobs.json` 首次启动自动迁移到 SQLite
|
||||
- 迁移后 JSON 文件归档为 `.migrated`(保留备份,不再迁移)
|
||||
|
||||
### 5.3 注意事项
|
||||
|
||||
- **修改 `core/` 目录下的文件后必须重启 web_server**(`debug=False` 不热重载)
|
||||
- **修改 `templates/` 下的 HTML 文件**:Flask 模板默认不缓存,但建议重启确保生效
|
||||
- **修改 `tasks/` 下的文件后必须重启**(任务注册在启动时完成)
|
||||
|
||||
---
|
||||
|
||||
## 6. 故障排查
|
||||
|
||||
### 6.1 启动失败
|
||||
|
||||
| 现象 | 原因 | 解决 |
|
||||
|------|------|------|
|
||||
| `ModuleNotFoundError: No module named 'flask'` | 依赖未安装 | `pip install -r requirements.txt` |
|
||||
| `WinError 10013` | 端口被排除/权限不足 | 改端口或用管理员运行 |
|
||||
| `WinError 10048` | 端口被占用 | 改端口或杀占用进程 |
|
||||
| STF 获取设备列表失败 | STF 地址/token 错误 | 检查 `config.py` 的 `STF_URL` 和 `STF_TOKEN` |
|
||||
|
||||
### 6.2 设备连接失败
|
||||
|
||||
| 现象 | 原因 | 解决 |
|
||||
|------|------|------|
|
||||
| `DeviceOfflineError` | 设备掉线/STF provider 卡死 | 检查设备网络/STF 状态 |
|
||||
| `u2.connect 超时` | atx-agent 无响应 | 重启设备/重新推送 atx-agent |
|
||||
| `adb connect failed` | 设备网络不通/端口未开放 | 检查设备 IP 和 5555 端口 |
|
||||
| STF 设备显示离线 | STF 状态缓存 | 用"扫描前台App"复测 |
|
||||
|
||||
### 6.3 任务不执行
|
||||
|
||||
| 现象 | 原因 | 解决 |
|
||||
|------|------|------|
|
||||
| 任务列表有任务但不执行 | 任务未启用 / cron 未到点 | 检查 `enabled` 和 `schedule` |
|
||||
| 立即执行无反应 | 无可用设备 | 检查设备池是否有空闲设备 |
|
||||
| worker 状态 error | 查看日志的 `last_error` | 查看 `logs/core.log` 和 `logs/task.log` |
|
||||
| 看门狗误杀 | 长操作未心跳 | 在长循环内加 `self.heartbeat()` |
|
||||
|
||||
### 6.4 日志查看
|
||||
|
||||
```bash
|
||||
# 查看核心日志
|
||||
# 方式一:Web 后台"日志"Tab
|
||||
# 方式二:直接看文件
|
||||
# logs/core.log — STF/adb/worker/task_manager
|
||||
# logs/task.log — 任务执行
|
||||
# logs/web.log — Web 请求
|
||||
# logs/action.log — 操作执行
|
||||
```
|
||||
+33
-61
@@ -42,16 +42,18 @@ platform-tools/
|
||||
│ ├── __init__.py # create_action_registry / register_action / should_trigger
|
||||
│ └── base.py # BaseAction 全局基类
|
||||
├── tasks/ # 任务定义层
|
||||
│ ├── __init__.py # 全局 _TASK_TYPES 注册表
|
||||
│ ├── __init__.py # 全局 _TASK_TYPES 注册表(import 各任务包触发注册)
|
||||
│ ├── base.py # BaseTask 基类
|
||||
│ └── douyin/ # 抖音养号(示例)
|
||||
│ ├── douyin/ # 抖音养号(示例)
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── task.py # DEFAULT_PARAMS + Worker + Task + @register_task
|
||||
│ │ └── actions/
|
||||
│ │ ├── __init__.py # 先 from .base import ACTIONS,再 from . import like
|
||||
│ │ ├── base.py # ACTIONS = create_action_registry()
|
||||
│ │ └── like.py # @register_action(ACTIONS) LikeAction
|
||||
│ └── generic/ # 通用步骤任务(可视化步骤编辑器编排)
|
||||
│ ├── __init__.py
|
||||
│ ├── task.py # DEFAULT_PARAMS + Worker + Task + @register_task
|
||||
│ └── actions/
|
||||
│ ├── __init__.py # 先 from .base import ACTIONS,再 from . import like, comment
|
||||
│ ├── base.py # ACTIONS = create_action_registry()
|
||||
│ ├── like.py # @register_action(ACTIONS) LikeAction
|
||||
│ └── comment.py # @register_action(ACTIONS) CommentAction
|
||||
│ └── task.py # STEP_TYPES + Worker + Task(按 steps 顺序执行)
|
||||
├── templates/admin/
|
||||
│ ├── monitor.html # 单页应用(5 Tab,纯前端渲染)
|
||||
│ └── login.html # 登录页
|
||||
@@ -135,7 +137,7 @@ def get_task_class(task_type):
|
||||
|
||||
### 2.5 Action — 操作
|
||||
|
||||
`Action` 是任务循环里执行的"原子操作"(点赞 / 评论 / 滑动 / 关注)。每个 app 有**独立的 Action 注册表**(通过 `create_action_registry()` 创建),互不污染。全局基类 `core/actions/base.py::BaseAction` 提供通用能力。
|
||||
`Action` 是任务循环里执行的"原子操作"(点赞 / 关注 / 滑动等)。每个 app 有**独立的 Action 注册表**(通过 `create_action_registry()` 创建),互不污染。全局基类 `core/actions/base.py::BaseAction` 提供通用能力。
|
||||
|
||||
### 2.6 进度上报(通用,适配任意 app)
|
||||
|
||||
@@ -326,7 +328,7 @@ from .base import (
|
||||
|
||||
# 再导入各操作模块,触发 @register_action(ACTIONS) 注册
|
||||
from . import like # noqa: F401
|
||||
# from . import comment # 新增 action 在此 import
|
||||
# 新增 action 在此 import,例如:from . import follow
|
||||
|
||||
__all__ = [
|
||||
"BaseAction", "register_action", "create_action_registry",
|
||||
@@ -342,7 +344,7 @@ __all__ = [
|
||||
"""快手养号任务定义。
|
||||
|
||||
本文件自包含所有快手养号参数,不依赖 core 的业务配置。
|
||||
快手专属操作(点赞/评论)在 actions/ 子包里,xpath 只适用于快手。
|
||||
快手专属操作(点赞/关注等)在 actions/ 子包里,xpath 只适用于快手。
|
||||
"""
|
||||
import time
|
||||
import random
|
||||
@@ -509,8 +511,9 @@ from . import task # noqa: F401 触发 @register_task 注册
|
||||
```python
|
||||
# tasks/__init__.py
|
||||
from .base import BaseTask, register_task, list_task_types, get_task_class
|
||||
from . import douyin # noqa: F401
|
||||
from . import kuaishou # noqa: F401 ← 新增这一行
|
||||
from .douyin import task # noqa: F401
|
||||
from .generic import task # noqa: F401
|
||||
from .kuaishou import task # noqa: F401 ← 新增这一行
|
||||
```
|
||||
|
||||
完成。重启 web 后,前端任务类型下拉自动出现 `kuaishou_nurture`。
|
||||
@@ -672,8 +675,10 @@ def run_task(self, d):
|
||||
|
||||
### 5.3 完整 Action 模板
|
||||
|
||||
下面以"关注"操作为例,展示一个完整 Action 的写法(多策略定位 + 概率触发 + 异常兜底):
|
||||
|
||||
```python
|
||||
# tasks/xxx/actions/comment.py
|
||||
# tasks/xxx/actions/follow.py
|
||||
import time
|
||||
import random
|
||||
|
||||
@@ -681,17 +686,16 @@ from core.actions import BaseAction, register_action, should_trigger
|
||||
from core.logger import get_logger
|
||||
from . import ACTIONS # 从 __init__ 导入本 app 注册表
|
||||
|
||||
_log = get_logger("action.xxx.comment")
|
||||
_log = get_logger("action.xxx.follow")
|
||||
|
||||
|
||||
@register_action(ACTIONS)
|
||||
class CommentAction(BaseAction):
|
||||
action_type = "comment"
|
||||
name = "评论"
|
||||
description = "看完视频后随机发一条评论"
|
||||
class FollowAction(BaseAction):
|
||||
action_type = "follow"
|
||||
name = "关注"
|
||||
description = "看完视频后随机关注作者"
|
||||
default_params = {
|
||||
"rate": 0.1,
|
||||
"texts": ["不错", "666", "学到了"],
|
||||
}
|
||||
|
||||
def execute(self, d, params, worker):
|
||||
@@ -699,51 +703,19 @@ class CommentAction(BaseAction):
|
||||
if not should_trigger(rate):
|
||||
return False
|
||||
|
||||
texts = params.get("texts") or ["不错"]
|
||||
text = random.choice(texts)
|
||||
|
||||
# 定位评论按钮(多策略组合,失败回退)
|
||||
for desc in ("评论", "未评论", "comment"):
|
||||
# 定位关注按钮(多策略组合,失败回退)
|
||||
for desc in ("关注", "未关注", "follow"):
|
||||
el = d(description=desc)
|
||||
if el.exists:
|
||||
el.click()
|
||||
break
|
||||
else:
|
||||
_log.info("未找到评论按钮")
|
||||
_log.info("未找到关注按钮")
|
||||
return False
|
||||
|
||||
time.sleep(1.5)
|
||||
|
||||
# 找输入框
|
||||
for rid in ("com.xxx:id/comment_input",):
|
||||
el = d(resourceId=rid)
|
||||
if el.exists:
|
||||
el.click()
|
||||
break
|
||||
else:
|
||||
d.press("back")
|
||||
return False
|
||||
|
||||
time.sleep(0.8)
|
||||
|
||||
# 输入文本(中文需切输入法)
|
||||
try:
|
||||
d.set_fastinput_ime(True)
|
||||
except Exception:
|
||||
pass
|
||||
d.send_keys(text)
|
||||
time.sleep(0.6)
|
||||
|
||||
# 发送
|
||||
el = d(text="发送")
|
||||
if el.exists:
|
||||
el.click()
|
||||
_log.info(f"评论成功: {text}")
|
||||
time.sleep(1.0)
|
||||
d.press("back")
|
||||
return True
|
||||
d.press("back")
|
||||
return False
|
||||
time.sleep(1.0)
|
||||
_log.info("关注成功")
|
||||
return True
|
||||
```
|
||||
|
||||
> **返回值约定**:`True`=成功执行;`False`=主动跳过(概率未中、元素不存在等);抛异常=执行失败,由 Worker 捕获并记录。
|
||||
@@ -931,10 +903,10 @@ d.xpath('//FrameLayout[2]/LinearLayout[1]/TextView[3]').click()
|
||||
```python
|
||||
# tasks/xxx/actions/__init__.py
|
||||
from .base import ACTIONS, ... # 1. 先建注册表
|
||||
from . import like, comment # 2. 再导入各 action,触发 @register_action
|
||||
from . import like # 2. 再导入各 action,触发 @register_action
|
||||
```
|
||||
|
||||
`tasks/__init__.py` 同理:先 `from .base import BaseTask`,再 `from . import douyin`。
|
||||
`tasks/__init__.py` 同理:先 `from .base import BaseTask`,再 `from . import douyin`、`from . import generic`。
|
||||
|
||||
### 10.2 中文输入
|
||||
|
||||
@@ -1036,7 +1008,7 @@ self.set_progress(videos_watched=5, round_idx=3)
|
||||
- [ ] `tasks/<app>/actions/__init__.py` 先 `from .base import ACTIONS` 再导入各 action
|
||||
- [ ] 每个 Action 有 `action_type` / `name` / `default_params` / `execute`,返回 `True/False`
|
||||
- [ ] `Task.create_worker` 做 actions 参数级深合并(照抄抖音模板)
|
||||
- [ ] `tasks/__init__.py` 已 `from . import <app>` 注册
|
||||
- [ ] `tasks/__init__.py` 已 `from .<app> import task` 注册
|
||||
- [ ] 日志用 `get_logger("task.<app>")` / `get_logger("action.<app>.<name>")`,无 `print`
|
||||
- [ ] 定位元素优先 `description` / `descriptionContains`,resourceId 多候选,xpath 用相对定位
|
||||
- [ ] 中文输入用 `set_fastinput_ime`,加 try/except
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
直接运行:python main.py
|
||||
- 单设备跑一次养号,不走 web/调度
|
||||
- 任务参数默认值在 tasks/douyin_nurture.py 顶部,可通过环境变量 WATCH_COUNT 覆盖视频数
|
||||
- 任务参数默认值在 tasks/douyin/task.py 顶部,可通过环境变量 WATCH_COUNT 覆盖视频数
|
||||
|
||||
web 管理后台请运行:python web_server.py
|
||||
清理 STF 残留占用:python scripts/cleanup.py
|
||||
@@ -11,9 +11,9 @@ web 管理后台请运行:python web_server.py
|
||||
config.py — 项目根配置(STF 地址/token、adb 路径、web 端口、数据目录)
|
||||
bin/adb/ — adb.exe 及依赖 dll
|
||||
core/ — 核心程序(STF 客户端、adb 工具、BaseWorker 基类、任务管理器)
|
||||
tasks/ — 任务定义(每个任务一个 .py,自带参数默认值,继承 BaseTask + @register_task)
|
||||
web/ — 前端页面(index.html)
|
||||
data/ — 持久化数据(分组、任务计划)
|
||||
tasks/ — 任务定义(每个 App 一个子包,自带参数默认值,继承 BaseTask + @register_task)
|
||||
templates/ — 前端页面(admin/monitor.html 单页应用)
|
||||
data/ — 持久化数据(SQLite:用户/分组/任务)
|
||||
scripts/ — 实用脚本
|
||||
"""
|
||||
import os
|
||||
|
||||
@@ -21,5 +21,8 @@ requests>=2.28
|
||||
# Android UI 自动化(uiautomator2)
|
||||
uiautomator2>=3.0
|
||||
|
||||
# UI 元素抓取(步骤编辑器"抓取元素"功能,本地服务端口 20242)
|
||||
uiautodev>=0.14
|
||||
|
||||
# APK 元信息解析(应用管理功能:自动读取包名/版本/应用名)
|
||||
pyaxmlparser>=0.3.27
|
||||
|
||||
+4
-5
@@ -1,16 +1,15 @@
|
||||
"""清理 STF 上残留的设备占用(强杀进程导致没正常释放时用)。
|
||||
|
||||
运行:python scripts/cleanup.py
|
||||
(在 auto/ 目录下运行也可:python -m scripts.cleanup)
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 让脚本既能直接运行(python scripts/cleanup.py)又能作为模块运行。
|
||||
# scripts/ 的上一级 auto/ 是 core 包的父目录,需加入 sys.path。
|
||||
_AUTO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if _AUTO_DIR not in sys.path:
|
||||
sys.path.insert(0, _AUTO_DIR)
|
||||
# scripts/ 的上一级是项目根目录(core 包的父目录),需加入 sys.path。
|
||||
_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if _PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, _PROJECT_ROOT)
|
||||
|
||||
import requests
|
||||
from config import STF_URL, STF_TOKEN
|
||||
|
||||
@@ -8,5 +8,6 @@ from .base import BaseTask, register_task, list_task_types, get_task_class
|
||||
|
||||
# 导入所有任务包,触发 @register_task 注册
|
||||
from .douyin import task # noqa: F401
|
||||
from .generic import task # noqa: F401
|
||||
|
||||
__all__ = ["BaseTask", "register_task", "list_task_types", "get_task_class"]
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""通用步骤任务包。
|
||||
|
||||
通过前端步骤编辑器编排任务流程,worker 按 steps 顺序执行。
|
||||
与 douyin_nurture 共存,互不影响。
|
||||
"""
|
||||
from . import task # noqa: F401 触发 @register_task 注册通用步骤任务
|
||||
@@ -0,0 +1,275 @@
|
||||
"""通用步骤任务定义。
|
||||
|
||||
前端步骤编辑器编排步骤链,保存为 params.steps(JSON 数组)。
|
||||
worker 按 steps 顺序执行,支持 loop 步骤循环、停止信号、进度上报。
|
||||
|
||||
步骤 schema:
|
||||
{
|
||||
"id": "step_1", # 唯一 id(前端生成)
|
||||
"type": "open_app", # 步骤类型
|
||||
"label": "打开抖音", # 用户可读名称
|
||||
"params": { ... } # 步骤参数(按 type 不同)
|
||||
}
|
||||
|
||||
支持的步骤类型:
|
||||
open_app - 启动 app
|
||||
swipe - 滑动(方向/时长)
|
||||
click - 点击元素(选择器)
|
||||
input_text - 输入文字(随机候选/指定文字)
|
||||
wait - 等待时长
|
||||
loop - 循环块(含 children 步骤列表 + max_iterations)
|
||||
group - 动作组(含 children 步骤列表,按序执行一次,可折叠复用)
|
||||
"""
|
||||
import random
|
||||
|
||||
from tasks.base import BaseTask, register_task
|
||||
from core.device_worker import BaseWorker, _update_status
|
||||
from core.u2_helper import ensure_app_running, wait_for_app_home, random_sleep
|
||||
from core.logger import get_logger
|
||||
|
||||
_log = get_logger("task.generic")
|
||||
|
||||
|
||||
# ================== 步骤类型定义(前端操作库 + 后端执行共用)==================
|
||||
STEP_TYPES = [
|
||||
{"type": "open_app", "label": "打开App", "icon": "▶",
|
||||
"params": {"package": "", "wait_home": False, "home_feature": ""}},
|
||||
{"type": "swipe", "label": "滑动", "icon": "↕",
|
||||
"params": {"direction": "up", "duration_min": 0.25, "duration_max": 0.50}},
|
||||
{"type": "click", "label": "点击元素", "icon": "✦",
|
||||
"params": {"selector_type": "description", "selector_value": "", "wait_timeout": 2}},
|
||||
{"type": "input_text", "label": "输入文字", "icon": "⌨",
|
||||
"params": {"mode": "random", "texts": "你好\n有趣\n支持", "fixed_text": ""}},
|
||||
{"type": "wait", "label": "等待", "icon": "⏱",
|
||||
"params": {"min": 1.0, "max": 3.0}},
|
||||
{"type": "loop", "label": "循环块", "icon": "↻",
|
||||
"params": {"max_iterations": 10, "children": []}},
|
||||
{"type": "group", "label": "动作组", "icon": "📦",
|
||||
"params": {"children": []}},
|
||||
]
|
||||
|
||||
|
||||
DEFAULT_PARAMS = {
|
||||
"max_duration": 0, # 最大运行时长(秒),0=不限时
|
||||
"steps": [ # 默认示例:打开抖音→循环(看视频→滑动)
|
||||
{
|
||||
"id": "step_1", "type": "open_app", "label": "打开抖音",
|
||||
"params": {"package": "com.ss.android.ugc.aweme", "wait_home": True, "home_feature": "拍摄"},
|
||||
},
|
||||
{
|
||||
"id": "step_2", "type": "loop", "label": "循环看视频",
|
||||
"params": {"max_iterations": 20, "children": [
|
||||
{"id": "step_2_1", "type": "wait", "label": "观看视频",
|
||||
"params": {"min": 5.0, "max": 35.0}},
|
||||
{"id": "step_2_2", "type": "swipe", "label": "上滑下一个",
|
||||
"params": {"direction": "up", "duration_min": 0.25, "duration_max": 0.50}},
|
||||
]},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class GenericStepsWorker(BaseWorker):
|
||||
"""通用步骤 worker:按 params.steps 顺序执行步骤链。
|
||||
|
||||
复用 BaseWorker 的设备生命周期(STF 占用/释放、u2 连接、异常、状态上报、stop)。
|
||||
只实现 run_task,按步骤类型分发到 _exec_<type> 方法。
|
||||
"""
|
||||
|
||||
def __init__(self, stf_client, serial, params=None):
|
||||
super().__init__(stf_client, serial, params)
|
||||
p = {**DEFAULT_PARAMS, **(self.params or {})}
|
||||
self.max_duration = int(p.get("max_duration", 0))
|
||||
self.steps = p.get("steps", [])
|
||||
self._action_counts = {} # 步骤执行计数 {step_label: count}
|
||||
|
||||
def run_task(self, d):
|
||||
"""按 steps 顺序执行。loop 步骤递归执行其 children。"""
|
||||
self._start_timer()
|
||||
self.set_action("开始执行步骤链")
|
||||
self.set_progress(done=0, total=len(self.steps), unit="步骤",
|
||||
action_counts=dict(self._action_counts))
|
||||
|
||||
try:
|
||||
self._exec_steps(d, self.steps)
|
||||
finally:
|
||||
elapsed = self.elapsed()
|
||||
summary = f"完成步骤链,运行 {elapsed//60}m{elapsed%60}s"
|
||||
_update_status(self.serial, douyin_running=False, current_action=summary)
|
||||
|
||||
def _exec_steps(self, d, steps, depth=0):
|
||||
"""执行步骤列表。depth 防止无限嵌套。"""
|
||||
if depth > 5:
|
||||
_log.warning(f"[{self.serial}] 步骤嵌套深度超限(>5),跳过")
|
||||
return
|
||||
for step in steps:
|
||||
if self.stopped() or self.is_time_up():
|
||||
return
|
||||
self._exec_one(d, step, depth)
|
||||
|
||||
def _exec_one(self, d, step, depth=0):
|
||||
"""执行单个步骤。"""
|
||||
stype = step.get("type", "")
|
||||
label = step.get("label", stype)
|
||||
params = step.get("params", {})
|
||||
self.set_action(f"执行: {label}")
|
||||
_log.info(f"[{self.serial}] 步骤: {label}({stype}) params={params}")
|
||||
|
||||
handler = getattr(self, f"_exec_{stype}", None)
|
||||
if handler:
|
||||
try:
|
||||
handler(d, params, depth)
|
||||
except Exception as e:
|
||||
_log.error(f"[{self.serial}] 步骤 {label}({stype}) 异常: {e}")
|
||||
else:
|
||||
_log.warning(f"[{self.serial}] 未知步骤类型: {stype}")
|
||||
self._action_counts[label] = self._action_counts.get(label, 0) + 1
|
||||
elapsed = self.elapsed()
|
||||
self.set_progress(done=sum(self._action_counts.values()),
|
||||
total=len(self.steps), unit="步骤",
|
||||
action_counts=dict(self._action_counts), elapsed=elapsed)
|
||||
|
||||
# ================== 步骤执行器 ==================
|
||||
def _exec_open_app(self, d, params, depth=0):
|
||||
package = params.get("package", "")
|
||||
if not package:
|
||||
_log.warning("open_app 缺少 package 参数")
|
||||
return
|
||||
wait_home = params.get("wait_home", False)
|
||||
home_feature = params.get("home_feature", "")
|
||||
d.app_start(package, wait=True)
|
||||
if wait_home:
|
||||
home_check = (lambda d: d(descriptionContains=home_feature).exists) if home_feature else (lambda d: True)
|
||||
if not wait_for_app_home(d, package, home_check, timeout=40):
|
||||
self.set_action(f"{package} 首页加载超时,继续")
|
||||
|
||||
def _exec_swipe(self, d, params, depth=0):
|
||||
direction = params.get("direction", "up")
|
||||
dur_min = float(params.get("duration_min", 0.25))
|
||||
dur_max = float(params.get("duration_max", 0.50))
|
||||
dur = random.uniform(dur_min, dur_max)
|
||||
info = d.info
|
||||
w, h = info["displayWidth"], info["displayHeight"]
|
||||
cx = int(w * 0.5)
|
||||
if direction == "up":
|
||||
d.swipe(cx, int(h * 0.8), cx, int(h * 0.2), dur)
|
||||
elif direction == "down":
|
||||
d.swipe(cx, int(h * 0.2), cx, int(h * 0.8), dur)
|
||||
elif direction == "left":
|
||||
d.swipe(int(w * 0.8), int(h * 0.5), int(w * 0.2), int(h * 0.5), dur)
|
||||
elif direction == "right":
|
||||
d.swipe(int(w * 0.2), int(h * 0.5), int(w * 0.8), int(h * 0.5), dur)
|
||||
|
||||
def _exec_click(self, d, params, depth=0):
|
||||
sel_type = params.get("selector_type", "xpath")
|
||||
sel_val = params.get("selector_value", "")
|
||||
timeout = float(params.get("wait_timeout", 2))
|
||||
if not sel_val:
|
||||
_log.warning(f"[{self.serial}] click 跳过:selector_value 为空,请在步骤编辑器中填写或抓取元素")
|
||||
return
|
||||
# 兼容旧配置:selector_type 不是 xpath 但值是 XPath(以 // 开头)
|
||||
if sel_type != "xpath" and sel_val.strip().startswith("//"):
|
||||
sel_type = "xpath"
|
||||
try:
|
||||
if sel_type == "xpath":
|
||||
# u2 的 d.xpath() 返回 XPathSelector,exists 是属性需用 wait()
|
||||
el = d.xpath(sel_val)
|
||||
if el.wait(timeout=timeout):
|
||||
el.click()
|
||||
_log.info(f"[{self.serial}] click 命中: xpath={sel_val}")
|
||||
else:
|
||||
_log.warning(f"[{self.serial}] click 未找到元素: xpath={sel_val} (等待{timeout}s)")
|
||||
else:
|
||||
# UiSelector 风格:d(description=xxx) 等,exists() 支持 timeout 参数
|
||||
el = d(**{sel_type: sel_val})
|
||||
if el.exists(timeout=timeout):
|
||||
el.click()
|
||||
_log.info(f"[{self.serial}] click 命中: {sel_type}={sel_val}")
|
||||
else:
|
||||
_log.warning(f"[{self.serial}] click 未找到元素: {sel_type}={sel_val} (等待{timeout}s)")
|
||||
except Exception as e:
|
||||
_log.warning(f"[{self.serial}] click 异常: {e}")
|
||||
|
||||
def _exec_wait(self, d, params, depth=0):
|
||||
min_s = float(params.get("min", 1.0))
|
||||
max_s = float(params.get("max", 3.0))
|
||||
random_sleep(min_s, max_s)
|
||||
|
||||
def _exec_loop(self, d, params, depth=0):
|
||||
max_iter = int(params.get("max_iterations", 10))
|
||||
children = params.get("children", [])
|
||||
if not children:
|
||||
return
|
||||
for i in range(max_iter):
|
||||
if self.stopped() or self.is_time_up():
|
||||
return
|
||||
self.set_action(f"循环第 {i+1}/{max_iter} 轮")
|
||||
_log.info(f"[{self.serial}] loop 第 {i+1}/{max_iter} 轮")
|
||||
self._exec_steps(d, children, depth + 1)
|
||||
|
||||
def _exec_group(self, d, params, depth=0):
|
||||
"""动作组:按序执行 children 一次(类似 loop 但不循环)。
|
||||
|
||||
用于把一系列步骤打包成一个可复用的动作。
|
||||
"""
|
||||
children = params.get("children", [])
|
||||
if not children:
|
||||
return
|
||||
self._exec_steps(d, children, depth + 1)
|
||||
|
||||
def _exec_input_text(self, d, params, depth=0):
|
||||
"""在当前焦点输入框输入文字。
|
||||
|
||||
需先用"点击元素"步骤定位到输入框,本步骤只负责输入。
|
||||
mode=random: 从 texts(换行分隔)随机选一条
|
||||
mode=fixed: 输入 fixed_text
|
||||
"""
|
||||
mode = params.get("mode", "random")
|
||||
if mode == "fixed":
|
||||
text = params.get("fixed_text", "")
|
||||
else:
|
||||
raw = params.get("texts", "")
|
||||
candidates = [t.strip() for t in raw.splitlines() if t.strip()]
|
||||
if not candidates:
|
||||
_log.warning("input_text 无候选文字")
|
||||
return
|
||||
text = random.choice(candidates)
|
||||
if not text:
|
||||
return
|
||||
try:
|
||||
# 优先用 u2 的 send_keys(对当前焦点元素输入)
|
||||
d.send_keys(text)
|
||||
_log.info(f"[{self.serial}] input_text: {text}")
|
||||
except Exception as e:
|
||||
# 兜底:找 EditText 设置文本
|
||||
try:
|
||||
edit = d(className="android.widget.EditText")
|
||||
if edit.exists:
|
||||
edit.set_text(text)
|
||||
_log.info(f"[{self.serial}] input_text(set_text): {text}")
|
||||
else:
|
||||
_log.warning(f"[{self.serial}] input_text 未找到输入框: {e}")
|
||||
except Exception as e2:
|
||||
_log.warning(f"[{self.serial}] input_text 失败: {e2}")
|
||||
|
||||
|
||||
@register_task
|
||||
class GenericStepsTask(BaseTask):
|
||||
"""通用步骤任务:前端编辑器编排步骤链,worker 按顺序执行。"""
|
||||
task_type = "generic_steps"
|
||||
name = "通用步骤"
|
||||
description = "通过步骤编辑器编排任务流程,支持循环/点击/滑动/点赞/评论等"
|
||||
default_params = dict(DEFAULT_PARAMS)
|
||||
|
||||
@classmethod
|
||||
def list_action_types(cls):
|
||||
"""返回支持的步骤类型列表(前端操作库用)。"""
|
||||
return STEP_TYPES
|
||||
|
||||
@classmethod
|
||||
def get_action_class(cls, action_type):
|
||||
return None
|
||||
|
||||
def create_worker(self, stf, serial, params):
|
||||
merged = {**DEFAULT_PARAMS, **(params or {})}
|
||||
return GenericStepsWorker(stf, serial, params=merged)
|
||||
@@ -164,6 +164,92 @@ select.form-control{cursor:pointer}
|
||||
.table tbody tr.row-checked td{background:#eff6ff}
|
||||
.table .col-cb{width:36px;text-align:center}
|
||||
.table .col-cb input{width:14px;height:14px;cursor:pointer;vertical-align:middle}
|
||||
|
||||
/* 步骤编辑器 */
|
||||
.step-editor{display:none;border:1px solid var(--border);border-radius:10px;overflow:hidden;margin-top:8px}
|
||||
.step-editor.show{display:block}
|
||||
.step-editor .se-head{background:#f1f5f9;padding:10px 14px;font-weight:600;font-size:13px;display:flex;justify-content:space-between;align-items:center}
|
||||
.step-editor .se-body{display:grid;grid-template-columns:140px 1fr;min-height:280px}
|
||||
.step-editor .se-lib{border-right:1px solid var(--border);padding:8px;background:#f8fafc}
|
||||
.step-editor .se-lib .lib-title{font-size:11px;color:var(--text-light);font-weight:600;margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px}
|
||||
.step-editor .se-lib .lib-item{padding:6px 8px;border-radius:6px;cursor:grab;font-size:12px;color:var(--text);display:flex;align-items:center;gap:6px;margin-bottom:2px;transition:background .1s}
|
||||
.step-editor .se-lib .lib-item:hover{background:#e2e8f0}
|
||||
.step-editor .se-lib .lib-item:active{cursor:grabbing}
|
||||
.step-editor .se-lib .lib-item .ico{width:18px;height:18px;display:inline-flex;align-items:center;justify-content:center;background:var(--primary);color:#fff;border-radius:4px;font-size:11px;flex-shrink:0}
|
||||
.step-editor .se-canvas{padding:8px;overflow-y:auto;max-height:400px}
|
||||
.step-editor .se-empty{text-align:center;color:var(--text-light);padding:40px 20px;font-size:13px}
|
||||
.step-editor .se-empty .hint{font-size:11px;margin-top:4px}
|
||||
.step-card{background:#fff;border:1px solid var(--border);border-radius:8px;margin-bottom:6px;transition:box-shadow .15s}
|
||||
.step-card.drag-over{border-color:var(--primary);box-shadow:0 0 0 2px rgba(59,130,246,.15)}
|
||||
.step-card.dragging{opacity:.5}
|
||||
.step-card .sc-head{display:flex;align-items:center;gap:8px;padding:7px 10px;cursor:grab}
|
||||
.step-card .sc-head:active{cursor:grabbing}
|
||||
.step-card .sc-ico{width:22px;height:22px;display:inline-flex;align-items:center;justify-content:center;background:var(--primary);color:#fff;border-radius:5px;font-size:12px;flex-shrink:0}
|
||||
.step-card .sc-label{flex:1;font-size:13px;font-weight:500;color:var(--text)}
|
||||
.step-card .sc-type{font-size:11px;color:var(--text-light);background:#f1f5f9;padding:1px 6px;border-radius:3px}
|
||||
.step-card .sc-actions{display:flex;gap:2px}
|
||||
.step-card .sc-actions button{background:none;border:none;color:var(--text-light);cursor:pointer;padding:2px 5px;font-size:14px;border-radius:3px;line-height:1}
|
||||
.step-card .sc-actions button:hover{background:#f1f5f9;color:var(--text)}
|
||||
.step-card .sc-body{display:none;padding:8px 10px;border-top:1px solid var(--border);background:#f8fafc}
|
||||
.step-card.expanded .sc-body{display:block}
|
||||
.step-card .sc-body .form-row{grid-template-columns:1fr 1fr 1fr;gap:8px}
|
||||
.step-card .sc-body .form-group{margin-bottom:8px}
|
||||
.step-card .sc-body .form-group label{font-size:11px;margin-bottom:2px}
|
||||
.step-card .sc-body .form-control{padding:5px 8px;font-size:12px}
|
||||
.step-card.loop-block{border-left:3px solid var(--success)}
|
||||
.step-card.loop-block .sc-ico{background:var(--success)}
|
||||
.step-card.group-block{border-left:3px solid #8b5cf6}
|
||||
.step-card.group-block .sc-ico{background:#8b5cf6}
|
||||
.step-card.selected{border-color:var(--primary);box-shadow:0 0 0 2px rgba(59,130,246,.2)}
|
||||
.step-children{margin-left:20px;margin-top:6px;border-left:2px dashed var(--border);padding-left:8px}
|
||||
.sel-picker{display:flex;gap:4px;align-items:center}
|
||||
.sel-picker input{flex:1}
|
||||
.sel-picker .btn-pick{padding:5px 8px;font-size:11px;white-space:nowrap}
|
||||
.se-lib .lib-section{margin-top:8px;padding-top:6px;border-top:1px solid var(--border)}
|
||||
.se-lib .lib-item.custom .ico{background:#8b5cf6}
|
||||
.se-lib .lib-item.custom .del-btn{margin-left:auto;background:none;border:none;color:var(--text-light);cursor:pointer;font-size:13px;padding:0 2px}
|
||||
.se-lib .lib-item.custom .del-btn:hover{color:var(--danger)}
|
||||
.se-toolbar{display:flex;gap:6px;padding:6px 8px;border-bottom:1px solid var(--border);background:#f8fafc}
|
||||
.se-toolbar .btn{padding:4px 10px;font-size:12px}
|
||||
|
||||
/* 元素抓取弹窗(独立模态框,样式已内联在 HTML 中)*/
|
||||
/* 设备选择页 */
|
||||
.el-device-grid{padding:16px;overflow-y:auto;flex:1}
|
||||
.el-device-grid .egd-title{font-size:14px;font-weight:600;margin-bottom:12px;color:var(--text)}
|
||||
.el-device-card{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:14px;cursor:pointer;transition:all .15s;display:flex;align-items:center;gap:12px;margin-bottom:8px}
|
||||
.el-device-card:hover{border-color:var(--primary);box-shadow:0 2px 8px rgba(59,130,246,.12)}
|
||||
.el-device-card .edc-icon{width:36px;height:36px;background:#f1f5f9;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0}
|
||||
.el-device-card .edc-info{flex:1}
|
||||
.el-device-card .edc-serial{font-size:13px;font-weight:600;color:var(--text)}
|
||||
.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 8px;border-radius:4px;background:#dcfce7;color:#166534;flex-shrink:0}
|
||||
/* 截图+元素树页 */
|
||||
.el-picker-main{display:flex;flex:1;overflow:hidden}
|
||||
.el-shot-wrap{flex:0 0 320px;border-right:1px solid var(--border);background:#1e293b;display:flex;flex-direction:column}
|
||||
.el-shot-toolbar{padding:6px 10px;background:#334155;display:flex;gap:6px;align-items:center}
|
||||
.el-shot-toolbar .btn{padding:3px 10px;font-size:12px;background:#475569;color:#fff;border:none}
|
||||
.el-shot-toolbar .btn:hover{background:#64748b}
|
||||
.el-shot-toolbar .el-shot-info{color:#cbd5e1;font-size:11px;flex:1}
|
||||
.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:#94a3b8;font-size:13px;padding:40px}
|
||||
.el-shot-overlay{position:absolute;border:1px solid rgba(59,130,246,.3);background:transparent;cursor:pointer;border-radius:2px;transition:all .1s}
|
||||
.el-shot-overlay:hover{border-color:#3b82f6;background:rgba(59,130,246,.2)}
|
||||
.el-shot-overlay.active{border:2px solid #ef4444;background:rgba(239,68,68,.2)}
|
||||
/* 右侧元素列表 */
|
||||
.el-list-wrap{flex:1;display:flex;flex-direction:column;overflow:hidden}
|
||||
.el-picker-search{width:100%;padding:8px 12px;border:none;border-bottom:1px solid var(--border);font-size:13px;outline:none}
|
||||
.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:#eff6ff}
|
||||
.el-picker-item.active{background:#dbeafe}
|
||||
.el-picker-item:last-child{border-bottom:none}
|
||||
.el-picker-item .ep-depth{color:var(--text-light);font-family:monospace;flex-shrink:0;width:28px}
|
||||
.el-picker-item .ep-info{flex:1;min-width:0}
|
||||
.el-picker-item .ep-info .ep-name{color:var(--text);font-weight:500;word-break:break-all}
|
||||
.el-picker-item .ep-info .ep-attrs{color:var(--text-light);font-size:11px;margin-top:2px;word-break:break-all}
|
||||
.el-picker-item .ep-info .ep-attrs span{margin-right:8px}
|
||||
.el-picker-item .ep-sel{font-size:10px;color:var(--primary);background:#dbeafe;padding:1px 6px;border-radius:3px;flex-shrink:0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -330,6 +416,18 @@ select.form-control{cursor:pointer}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========== 元素抓取模态框(独立第二层,不覆盖任务编辑窗口)========== -->
|
||||
<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-header">
|
||||
<h3 id="el-picker-title">抓取元素</h3>
|
||||
<button class="close" onclick="closeElPicker()">×</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-footer" id="el-picker-footer"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========== Toast ========== -->
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
@@ -842,7 +940,7 @@ function openTaskModal(jobId){
|
||||
document.getElementById('modal-body').innerHTML=
|
||||
'<div class="form-row">'+
|
||||
'<div class="form-group"><label>任务名</label><input type="text" class="form-control" id="f-name" value="'+esc(j.name)+'" placeholder="如:抖音养号-上午"></div>'+
|
||||
'<div class="form-group"><label>任务类型</label><select class="form-control" id="f-task_type">'+tt+'</select></div>'+
|
||||
'<div class="form-group"><label>任务类型</label><select class="form-control" id="f-task_type" onchange="onTaskTypeChange()">'+tt+'</select></div>'+
|
||||
'</div>'+
|
||||
'<div class="form-group form-check"><input type="checkbox" id="f-enabled" '+(j.enabled?'checked':'')+'><label for="f-enabled">启用此任务</label></div>'+
|
||||
'<div class="form-group"><label>目标设备</label>'+
|
||||
@@ -873,7 +971,19 @@ function openTaskModal(jobId){
|
||||
'<div class="form-group"><label>重试次数</label><input type="number" class="form-control" id="f-retry_attempts" value="'+j.retry.max_attempts+'" min="1" max="10"></div>'+
|
||||
'<div class="form-group"><label>重试间隔(秒)</label><input type="number" class="form-control" id="f-retry_delay" value="'+j.retry.delay+'" min="10" max="3600"></div>'+
|
||||
'</div>'+
|
||||
'<div class="form-group"><label>任务参数 (JSON)</label><textarea class="form-control" id="f-params" rows="6">'+esc(JSON.stringify(j.params||{},null,2))+'</textarea><div class="help">可留空使用默认参数。抖音任务可设 max_duration(秒,如 1800=30分钟)限制单设备运行时长</div>';
|
||||
'<div class="form-group" id="f-params_wrap"><label>任务参数 (JSON)</label><textarea class="form-control" id="f-params" rows="6">'+esc(JSON.stringify(j.params||{},null,2))+'</textarea><div class="help">可留空使用默认参数。抖音任务可设 max_duration(秒,如 1800=30分钟)限制单设备运行时长</div></div>'+
|
||||
'<div class="step-editor" id="step-editor">'+
|
||||
'<div class="se-head"><span>步骤编辑器</span><span style="font-size:11px;color:var(--text-light);flex:1;margin-left:12px">用步骤卡片上的 ☑ 选中多个步骤,可打包成自定义动作</span>'+
|
||||
'<button class="btn btn-xs btn-primary" id="se-pack-btn" disabled onclick="_stepEditor.saveAsCustomAction()">打包选中步骤</button>'+
|
||||
'</div>'+
|
||||
'<div class="se-body">'+
|
||||
'<div class="se-lib" id="se-lib"><div class="lib-title">操作库</div></div>'+
|
||||
'<div class="se-canvas" id="se-canvas"></div>'+
|
||||
'</div>'+
|
||||
'</div>';
|
||||
|
||||
// 初始化步骤编辑器(generic_steps 时显示,其他隐藏)
|
||||
_stepEditor.init(j.params&&j.params.steps?j.params.steps:[],j.params&&j.params.max_duration?j.params.max_duration:0);
|
||||
|
||||
document.getElementById('modal-footer').innerHTML=
|
||||
'<button class="btn" onclick="closeModal()">取消</button>'+
|
||||
@@ -881,6 +991,7 @@ function openTaskModal(jobId){
|
||||
|
||||
toggleTargetFields();
|
||||
toggleScheduleFields();
|
||||
onTaskTypeChange();
|
||||
showModal();
|
||||
}
|
||||
|
||||
@@ -896,12 +1007,676 @@ function toggleScheduleFields(){
|
||||
document.getElementById('f-stop_cron_wrap').style.display=mode==='cron_stop'?'block':'none';
|
||||
}
|
||||
|
||||
function onTaskTypeChange(){
|
||||
const tt=document.getElementById('f-task_type').value;
|
||||
const isGeneric=tt==='generic_steps';
|
||||
document.getElementById('step-editor').classList.toggle('show',isGeneric);
|
||||
document.getElementById('f-params_wrap').style.display=isGeneric?'none':'block';
|
||||
}
|
||||
|
||||
// ================== 步骤编辑器 ==================
|
||||
var STEP_LIB=[
|
||||
{type:'open_app',label:'打开App',icon:'▶',params:{package:'',wait_home:false,home_feature:''}},
|
||||
{type:'swipe',label:'滑动',icon:'↕',params:{direction:'up',duration_min:0.25,duration_max:0.50}},
|
||||
{type:'click',label:'点击元素',icon:'✦',params:{selector_type:'xpath',selector_value:'',wait_timeout:2}},
|
||||
{type:'input_text',label:'输入文字',icon:'⌨',params:{mode:'random',texts:'你好\n有趣\n支持',fixed_text:''}},
|
||||
{type:'wait',label:'等待',icon:'⏱',params:{min:1.0,max:3.0}},
|
||||
{type:'loop',label:'循环块',icon:'↻',params:{max_iterations:10,children:[]}},
|
||||
{type:'group',label:'动作组',icon:'📦',params:{children:[]}}
|
||||
];
|
||||
var _customActions=[]; // 后端加载的自定义动作
|
||||
var _stepIdCounter=0;
|
||||
function _newStepId(){_stepIdCounter++;return 'step_'+Date.now().toString(36)+'_'+_stepIdCounter;}
|
||||
function _findLib(type){return STEP_LIB.find(function(s){return s.type===type;});}
|
||||
|
||||
// 加载自定义动作库
|
||||
function _loadCustomActions(){
|
||||
fetch('/api/custom_actions').then(function(r){return r.json();}).then(function(res){
|
||||
if(res.ok)_customActions=res.actions||[];
|
||||
if(typeof _stepEditor!=='undefined')_stepEditor._renderLib();
|
||||
}).catch(function(){});
|
||||
}
|
||||
|
||||
var _stepEditor={
|
||||
_steps:[],
|
||||
_selected:[],
|
||||
_dragSuppressClick:false,
|
||||
init:function(steps,maxDuration){
|
||||
this._steps=steps.length?JSON.parse(JSON.stringify(steps)):[];
|
||||
this._selected=[];
|
||||
this._dragSuppressClick=false;
|
||||
this._ensureIds(this._steps);
|
||||
this._renderLib();
|
||||
this._renderCanvas();
|
||||
_loadCustomActions();
|
||||
// max_duration 输入框(放在 se-head 旁边)
|
||||
var head=document.querySelector('#step-editor .se-head');
|
||||
if(head&&!document.getElementById('f-max_duration')){
|
||||
var md=document.createElement('span');
|
||||
md.style.cssText='font-size:11px;display:flex;align-items:center;gap:4px';
|
||||
md.innerHTML='运行时长上限(秒,0=不限) <input type="number" id="f-max_duration" value="'+(maxDuration||0)+'" min="0" style="width:70px;padding:2px 6px;border:1px solid var(--border);border-radius:4px;font-size:12px">';
|
||||
head.appendChild(md);
|
||||
}
|
||||
},
|
||||
_ensureIds:function(steps){
|
||||
var self=this;
|
||||
steps.forEach(function(s){
|
||||
if(!s.id)s.id=_newStepId();
|
||||
if(s.params&&s.params.children&&Array.isArray(s.params.children)){
|
||||
self._ensureIds(s.params.children);
|
||||
}
|
||||
});
|
||||
},
|
||||
_renderLib:function(){
|
||||
var lib=document.getElementById('se-lib');
|
||||
var items=STEP_LIB.map(function(s){
|
||||
return '<div class="lib-item" draggable="true" data-type="'+s.type+'">'+
|
||||
'<span class="ico">'+s.icon+'</span><span>'+s.label+'</span></div>';
|
||||
}).join('');
|
||||
// 自定义动作区
|
||||
var customHtml='';
|
||||
if(_customActions.length){
|
||||
customHtml='<div class="lib-section"><div class="lib-title">自定义动作</div>';
|
||||
customHtml+=_customActions.map(function(a){
|
||||
return '<div class="lib-item custom" draggable="true" data-action-id="'+a.id+'">'+
|
||||
'<span class="ico">'+(a.icon||'📦')+'</span><span>'+esc(a.name)+'</span>'+
|
||||
'<button class="del-btn" title="删除" onclick="event.stopPropagation();_stepEditor.delCustomAction(\''+a.id+'\')">✕</button></div>';
|
||||
}).join('');
|
||||
customHtml+='</div>';
|
||||
}
|
||||
lib.innerHTML='<div class="lib-title">操作库</div>'+items+customHtml;
|
||||
var self=this;
|
||||
// 绑定拖拽 + 点击(系统步骤)—— 用 _dragSuppressClick 防止拖拽后重复触发 click
|
||||
lib.querySelectorAll('.lib-item[data-type]').forEach(function(item){
|
||||
var type=item.dataset.type;
|
||||
item.addEventListener('dragstart',function(e){
|
||||
self._dragSuppressClick=true;
|
||||
e.dataTransfer.setData('text/plain','type:'+type);
|
||||
e.dataTransfer.effectAllowed='copy';
|
||||
});
|
||||
item.addEventListener('click',function(e){
|
||||
if(self._dragSuppressClick){self._dragSuppressClick=false;return;}
|
||||
self.addStep(type);
|
||||
});
|
||||
});
|
||||
// 绑定拖拽 + 点击(自定义动作)
|
||||
lib.querySelectorAll('.lib-item[data-action-id]').forEach(function(item){
|
||||
var actionId=item.dataset.actionId;
|
||||
item.addEventListener('dragstart',function(e){
|
||||
self._dragSuppressClick=true;
|
||||
e.dataTransfer.setData('text/plain','action:'+actionId);
|
||||
e.dataTransfer.effectAllowed='copy';
|
||||
});
|
||||
item.addEventListener('click',function(e){
|
||||
if(e.target.classList.contains('del-btn'))return;
|
||||
if(self._dragSuppressClick){self._dragSuppressClick=false;return;}
|
||||
self.addCustomAction(actionId);
|
||||
});
|
||||
});
|
||||
},
|
||||
_renderCanvas:function(){
|
||||
this._renderStepList(document.getElementById('se-canvas'),this._steps);
|
||||
},
|
||||
_renderStepList:function(container,steps){
|
||||
var self=this;
|
||||
if(!steps.length){
|
||||
container.innerHTML='<div class="se-empty">拖拽左侧操作到这里<div class="hint">或点击操作库直接添加</div></div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML='';
|
||||
steps.forEach(function(step,idx){
|
||||
container.appendChild(self._renderCard(step,idx,steps));
|
||||
});
|
||||
this._bindDropZone(container,steps);
|
||||
},
|
||||
_renderCard:function(step,idx,parentArr){
|
||||
var self=this;
|
||||
var lib=_findLib(step.type)||{icon:'?',label:step.type};
|
||||
var isContainer=(step.type==='loop'||step.type==='group');
|
||||
var card=document.createElement('div');
|
||||
card.className='step-card'+(step.type==='loop'?' loop-block':'')+(step.type==='group'?' group-block':'');
|
||||
card.draggable=true;
|
||||
card.dataset.idx=idx;
|
||||
card.dataset.stepId=step.id||'';
|
||||
if(this._selected&&this._selected.indexOf(step.id)>=0)card.classList.add('selected');
|
||||
// head
|
||||
var head=document.createElement('div');
|
||||
head.className='sc-head';
|
||||
head.innerHTML='<span class="sc-ico">'+lib.icon+'</span>'+
|
||||
'<span class="sc-label">'+esc(step.label||lib.label)+'</span>'+
|
||||
'<span class="sc-type">'+step.type+'</span>'+
|
||||
'<span class="sc-actions">'+
|
||||
'<button title="选中/取消选中(可多选后打包)" onclick="event.stopPropagation();_stepEditor.toggleSelect(\''+(step.id||'')+'\')">☑</button>'+
|
||||
'<button title="展开/收起参数" onclick="_stepEditor.toggleExpand(this)">⚙</button>'+
|
||||
'<button title="上移" onclick="_stepEditor.move('+idx+',-1,\''+this._path(parentArr)+'\')">↑</button>'+
|
||||
'<button title="下移" onclick="_stepEditor.move('+idx+',1,\''+this._path(parentArr)+'\')">↓</button>'+
|
||||
'<button title="复制" onclick="_stepEditor.duplicate('+idx+',\''+this._path(parentArr)+'\')">⧉</button>'+
|
||||
'<button title="删除" onclick="_stepEditor.remove('+idx+',\''+this._path(parentArr)+'\')">✕</button>'+
|
||||
'</span>';
|
||||
head.addEventListener('click',function(e){
|
||||
if(e.target.tagName==='BUTTON')return;
|
||||
self.toggleExpand(head.querySelector('button'));
|
||||
});
|
||||
card.appendChild(head);
|
||||
// body(参数表单)
|
||||
var body=document.createElement('div');
|
||||
body.className='sc-body';
|
||||
body.innerHTML=this._renderParams(step);
|
||||
card.appendChild(body);
|
||||
// 拖拽排序
|
||||
card.addEventListener('dragstart',function(e){
|
||||
e.dataTransfer.setData('text/plain','move:'+idx+':'+self._path(parentArr));
|
||||
e.dataTransfer.effectAllowed='move';
|
||||
card.classList.add('dragging');
|
||||
});
|
||||
card.addEventListener('dragend',function(){card.classList.remove('dragging');});
|
||||
card.addEventListener('dragover',function(e){
|
||||
e.preventDefault();
|
||||
card.classList.add('drag-over');
|
||||
});
|
||||
card.addEventListener('dragleave',function(){card.classList.remove('drag-over');});
|
||||
card.addEventListener('drop',function(e){
|
||||
e.preventDefault();
|
||||
card.classList.remove('drag-over');
|
||||
self._handleDrop(e,parentArr,idx);
|
||||
});
|
||||
// loop/group 子步骤
|
||||
if(isContainer&&step.params&&step.params.children){
|
||||
var childWrap=document.createElement('div');
|
||||
childWrap.className='step-children';
|
||||
this._renderStepList(childWrap,step.params.children);
|
||||
card.appendChild(childWrap);
|
||||
}
|
||||
return card;
|
||||
},
|
||||
_path:function(arr){
|
||||
// 用数组长度作为标识(顶层是 _steps,子层是某 loop 的 children)
|
||||
// 这里简化:顶层 path='root',子层 path='loop:<idx>'
|
||||
return arr===this._steps?'root':'child';
|
||||
},
|
||||
_renderParams:function(step){
|
||||
var p=step.params||{};
|
||||
var h='';
|
||||
if(step.type==='open_app'){
|
||||
h+='<div class="form-group"><label>包名</label><input type="text" class="form-control" data-key="package" value="'+esc(p.package||'')+'" placeholder="如 com.ss.android.ugc.aweme"></div>';
|
||||
h+='<div class="form-row"><div class="form-group form-check"><input type="checkbox" data-key="wait_home" '+(p.wait_home?'checked':'')+'><label>等待进入首页</label></div>';
|
||||
h+='<div class="form-group"><label>首页特征词</label><input type="text" class="form-control" data-key="home_feature" value="'+esc(p.home_feature||'')+'" placeholder="如 拍摄"></div></div>';
|
||||
}else if(step.type==='swipe'){
|
||||
h+='<div class="form-row"><div class="form-group"><label>方向</label><select class="form-control" data-key="direction">'+
|
||||
['up','down','left','right'].map(function(d){return '<option value="'+d+'"'+(p.direction===d?' selected':'')+'>'+d+'</option>';}).join('')+'</select></div>';
|
||||
h+='<div class="form-group"><label>最短时长(秒)</label><input type="number" class="form-control" data-key="duration_min" value="'+(p.duration_min||0.25)+'" step="0.05" min="0.05"></div>';
|
||||
h+='<div class="form-group"><label>最长时长(秒)</label><input type="number" class="form-control" data-key="duration_max" value="'+(p.duration_max||0.50)+'" step="0.05" min="0.05"></div></div>';
|
||||
}else if(step.type==='click'){
|
||||
h+='<div class="form-row"><div class="form-group"><label>选择器类型</label><select class="form-control" data-key="selector_type">'+
|
||||
['xpath','description','text','resourceId','descriptionContains','className'].map(function(d){return '<option value="'+d+'"'+(p.selector_type===d?' selected':'')+'>'+d+'</option>';}).join('')+'</select></div>';
|
||||
h+='<div class="form-group" style="grid-column:span 2"><label>选择器值</label><div class="sel-picker"><input type="text" class="form-control" data-key="selector_value" value="'+esc(p.selector_value||'')+'" placeholder="如 点赞"><button class="btn btn-xs btn-pick" onclick="_stepEditor.openPicker(this)">抓取元素</button></div></div></div>';
|
||||
h+='<div class="form-group"><label>等待超时(秒)</label><input type="number" class="form-control" data-key="wait_timeout" value="'+(p.wait_timeout||2)+'" step="0.5" min="0.5"></div>';
|
||||
}else if(step.type==='wait'){
|
||||
h+='<div class="form-row"><div class="form-group"><label>最短(秒)</label><input type="number" class="form-control" data-key="min" value="'+(p.min||1.0)+'" step="0.5" min="0.1"></div>';
|
||||
h+='<div class="form-group"><label>最长(秒)</label><input type="number" class="form-control" data-key="max" value="'+(p.max||3.0)+'" step="0.5" min="0.1"></div></div>';
|
||||
}else if(step.type==='input_text'){
|
||||
h+='<div class="form-group"><label>输入模式</label><select class="form-control" data-key="mode" onchange="_stepEditor._toggleInputMode(this)">'+
|
||||
'<option value="random"'+(p.mode==='random'?' selected':'')+'>随机候选</option>'+
|
||||
'<option value="fixed"'+(p.mode==='fixed'?' selected':'')+'>指定文字</option></select></div>';
|
||||
h+='<div class="form-group" data-mode="random"><label>候选文字(每行一个,随机选一条)</label><textarea class="form-control" data-key="texts" rows="4" style="font-family:monospace;min-height:80px">'+esc(p.texts||'你好\n有趣\n支持')+'</textarea></div>';
|
||||
h+='<div class="form-group" data-mode="fixed" style="display:none"><label>指定文字</label><input type="text" class="form-control" data-key="fixed_text" value="'+esc(p.fixed_text||'')+'"></div>';
|
||||
h+='<div class="help">需先用"点击元素"步骤定位到输入框,本步骤只负责输入文字</div>';
|
||||
}else if(step.type==='loop'){
|
||||
h+='<div class="form-group"><label>最大循环次数</label><input type="number" class="form-control" data-key="max_iterations" value="'+(p.max_iterations||10)+'" min="1" max="1000"></div>';
|
||||
h+='<div class="help">将下方子步骤(拖拽到本卡片内)循环执行指定次数</div>';
|
||||
}else if(step.type==='group'){
|
||||
h+='<div class="help">动作组:按顺序执行下方子步骤一次。把多个步骤打包成一个可复用的整体。</div>';
|
||||
h+='<div class="form-group"><button class="btn btn-xs btn-primary" onclick="_stepEditor.saveAsCustomAction()">存为自定义动作</button> <span class="help" style="display:inline">把本组步骤保存到左侧自定义动作库,方便复用</span></div>';
|
||||
}
|
||||
// 可编辑 label
|
||||
h+='<div class="form-group" style="margin-top:8px"><label>步骤名称(可选)</label><input type="text" class="form-control" data-key="_label" value="'+esc(step.label||'')+'" placeholder="留空用默认"></div>';
|
||||
return h;
|
||||
},
|
||||
_bindDropZone:function(container,arr){
|
||||
var self=this;
|
||||
// 画布空白处也能接收 drop
|
||||
container.addEventListener('dragover',function(e){e.preventDefault();});
|
||||
container.addEventListener('drop',function(e){
|
||||
e.preventDefault();
|
||||
if(e.target===container||e.target.classList.contains('se-empty')){
|
||||
self._handleDrop(e,arr,-1);
|
||||
}
|
||||
});
|
||||
},
|
||||
_handleDrop:function(e,arr,idx){
|
||||
var data=e.dataTransfer.getData('text/plain');
|
||||
if(!data)return;
|
||||
if(data.startsWith('move:')){
|
||||
// 内部排序
|
||||
var parts=data.split(':');
|
||||
var fromIdx=parseInt(parts[1]);
|
||||
var fromPath=parts[2];
|
||||
if(fromPath===this._path(arr)&&fromIdx!==idx){
|
||||
var moved=arr.splice(fromIdx,1)[0];
|
||||
if(idx<0)arr.push(moved);
|
||||
else if(fromIdx<idx)arr.splice(idx-1,0,moved);
|
||||
else arr.splice(idx,0,moved);
|
||||
this._renderCanvas();
|
||||
}
|
||||
}else if(data.startsWith('action:')){
|
||||
// 从自定义动作库拖入 → 展开为 group 步骤
|
||||
var actionId=data.substring(7);
|
||||
var action=_customActions.find(function(a){return a.id===actionId;});
|
||||
if(action&&action.steps&&action.steps.length){
|
||||
var ns=this._makeStep({type:'group',label:action.name,icon:action.icon||'📦',params:{children:[]}});
|
||||
ns.label=action.name;
|
||||
ns.params.children=JSON.parse(JSON.stringify(action.steps));
|
||||
this._ensureIds(ns.params.children);
|
||||
if(idx<0||idx>=arr.length)arr.push(ns);
|
||||
else arr.splice(idx,0,ns);
|
||||
this._renderCanvas();
|
||||
}
|
||||
}else if(data.startsWith('type:')){
|
||||
// 从系统操作库拖入
|
||||
var type=data.substring(5);
|
||||
var lib=_findLib(type);
|
||||
if(lib){
|
||||
var ns2=this._makeStep(lib);
|
||||
if(idx<0||idx>=arr.length)arr.push(ns2);
|
||||
else arr.splice(idx,0,ns2);
|
||||
this._renderCanvas();
|
||||
}
|
||||
}
|
||||
},
|
||||
_makeStep:function(lib){
|
||||
return {id:_newStepId(),type:lib.type,label:lib.label,params:JSON.parse(JSON.stringify(lib.params))};
|
||||
},
|
||||
addStep:function(type){
|
||||
var lib=_findLib(type);
|
||||
if(!lib)return;
|
||||
this._steps.push(this._makeStep(lib));
|
||||
this._renderCanvas();
|
||||
},
|
||||
addCustomAction:function(actionId){
|
||||
var action=_customActions.find(function(a){return a.id===actionId;});
|
||||
if(!action||!action.steps||!action.steps.length)return;
|
||||
var ns=this._makeStep({type:'group',label:action.name,icon:action.icon||'📦',params:{children:[]}});
|
||||
ns.label=action.name;
|
||||
ns.params.children=JSON.parse(JSON.stringify(action.steps));
|
||||
this._ensureIds(ns.params.children);
|
||||
this._steps.push(ns);
|
||||
this._renderCanvas();
|
||||
},
|
||||
delCustomAction:function(actionId){
|
||||
if(!confirm('确定删除这个自定义动作?'))return;
|
||||
fetch('/api/custom_actions/'+actionId,{method:'DELETE'}).then(function(r){return r.json();}).then(function(res){
|
||||
if(res.ok){_loadCustomActions();toast('已删除','success');}
|
||||
else toast(res.error||'删除失败','error');
|
||||
});
|
||||
},
|
||||
toggleSelect:function(stepId){
|
||||
if(!this._selected)this._selected=[];
|
||||
var i=this._selected.indexOf(stepId);
|
||||
if(i>=0)this._selected.splice(i,1);
|
||||
else this._selected.push(stepId);
|
||||
this._renderCanvas();
|
||||
// 更新工具栏按钮状态
|
||||
var bar=document.getElementById('se-pack-btn');
|
||||
if(bar)bar.disabled=this._selected.length<1;
|
||||
},
|
||||
saveAsCustomAction:function(){
|
||||
// 从 group 步骤的"存为自定义动作"按钮调用:收集当前展开的 group 步骤的 children
|
||||
// 或从选中步骤收集
|
||||
var stepsToSave=[];
|
||||
if(this._selected&&this._selected.length){
|
||||
// 从选中步骤收集
|
||||
stepsToSave=this._collectByIds(this._steps,this._selected);
|
||||
}
|
||||
if(!stepsToSave.length){
|
||||
toast('请先用 ☑ 选中要打包的步骤','error');
|
||||
return;
|
||||
}
|
||||
var name=prompt('输入动作名称:','我的动作');
|
||||
if(!name)return;
|
||||
var self=this;
|
||||
fetch('/api/custom_actions',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({name:name,steps:stepsToSave})})
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(res){
|
||||
if(res.ok){
|
||||
toast('已保存到自定义动作库','success');
|
||||
self._selected=[];
|
||||
_loadCustomActions();
|
||||
self._renderCanvas();
|
||||
}else toast(res.error||'保存失败','error');
|
||||
});
|
||||
},
|
||||
_collectByIds:function(steps,ids){
|
||||
// 收集选中的步骤(深拷贝,去掉 id)
|
||||
var result=[];
|
||||
steps.forEach(function(s){
|
||||
if(ids.indexOf(s.id)>=0){
|
||||
var copy=JSON.parse(JSON.stringify(s));
|
||||
result.push(copy);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
},
|
||||
toggleExpand:function(btn){
|
||||
var card=btn.closest('.step-card');
|
||||
card.classList.toggle('expanded');
|
||||
},
|
||||
_toggleInputMode:function(sel){
|
||||
var body=sel.closest('.sc-body');
|
||||
var mode=sel.value;
|
||||
body.querySelectorAll('[data-mode]').forEach(function(g){
|
||||
g.style.display=g.dataset.mode===mode?'block':'none';
|
||||
});
|
||||
},
|
||||
move:function(idx,dir,path){
|
||||
var arr=path==='root'?this._steps:this._findLoopChildren();
|
||||
if(!arr)return;
|
||||
var ni=idx+dir;
|
||||
if(ni<0||ni>=arr.length)return;
|
||||
var tmp=arr[ni];arr[ni]=arr[idx];arr[idx]=tmp;
|
||||
this._renderCanvas();
|
||||
},
|
||||
duplicate:function(idx,path){
|
||||
var arr=path==='root'?this._steps:this._findLoopChildren();
|
||||
if(!arr)return;
|
||||
var copy=JSON.parse(JSON.stringify(arr[idx]));
|
||||
copy.id=_newStepId();
|
||||
this._ensureIds([copy]);
|
||||
arr.splice(idx+1,0,copy);
|
||||
this._renderCanvas();
|
||||
},
|
||||
remove:function(idx,path){
|
||||
var arr=path==='root'?this._steps:this._findLoopChildren();
|
||||
if(!arr)return;
|
||||
arr.splice(idx,1);
|
||||
this._renderCanvas();
|
||||
},
|
||||
_findLoopChildren:function(){
|
||||
// 简化:找到第一个 loop 的 children(当前只支持单层 loop 编辑)
|
||||
for(var i=0;i<this._steps.length;i++){
|
||||
if(this._steps[i].type==='loop'&&this._steps[i].params&&this._steps[i].params.children){
|
||||
return this._steps[i].params.children;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
collect:function(){
|
||||
// 先把当前 DOM 上的参数值同步回 _steps
|
||||
var self=this;
|
||||
this._syncParams(document.getElementById('se-canvas'),this._steps);
|
||||
return JSON.parse(JSON.stringify(this._steps));
|
||||
},
|
||||
validate:function(steps){
|
||||
// 校验步骤配置,返回警告列表
|
||||
var warnings=[];
|
||||
function check(arr,prefix){
|
||||
arr.forEach(function(s,i){
|
||||
var name=prefix?(prefix+'.'+(i+1)):('步骤'+(i+1));
|
||||
var label=s.label||s.type;
|
||||
if(s.type==='click'){
|
||||
if(!s.params.selector_value){
|
||||
warnings.push(name+' "'+label+'": 选择器值为空,点击步骤会跳过。请用"抓取元素"按钮选择目标。');
|
||||
}
|
||||
}else if(s.type==='open_app'){
|
||||
if(!s.params.package){
|
||||
warnings.push(name+' "'+label+'": 包名为空,无法启动App');
|
||||
}
|
||||
}else if(s.type==='input_text'){
|
||||
if(s.params.mode==='fixed'&&!s.params.fixed_text){
|
||||
warnings.push(name+' "'+label+'": 指定文字为空');
|
||||
}else if(s.params.mode==='random'&&!s.params.texts){
|
||||
warnings.push(name+' "'+label+'": 候选文字为空');
|
||||
}
|
||||
}
|
||||
if(s.params&&s.params.children&&s.params.children.length){
|
||||
check(s.params.children,name+' 子步骤');
|
||||
}
|
||||
});
|
||||
}
|
||||
check(steps,'');
|
||||
return warnings;
|
||||
},
|
||||
_syncParams:function(container,steps){
|
||||
var self=this;
|
||||
var cards=container.querySelectorAll(':scope > .step-card');
|
||||
cards.forEach(function(card,i){
|
||||
if(!steps[i])return;
|
||||
var inputs=card.querySelectorAll('.sc-body [data-key]');
|
||||
inputs.forEach(function(inp){
|
||||
var key=inp.dataset.key;
|
||||
if(inp.type==='checkbox'){
|
||||
steps[i].params[key]=inp.checked;
|
||||
}else if(inp.type==='number'){
|
||||
steps[i].params[key]=parseFloat(inp.value)||0;
|
||||
}else{
|
||||
if(key==='_label'){
|
||||
steps[i].label=inp.value||steps[i].label;
|
||||
}else{
|
||||
steps[i].params[key]=inp.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
// 递归子步骤
|
||||
var childWrap=card.querySelector(':scope > .step-children');
|
||||
if(childWrap&&steps[i].params&&steps[i].params.children){
|
||||
self._syncParams(childWrap,steps[i].params.children);
|
||||
}
|
||||
});
|
||||
},
|
||||
// ================== 元素抓取 ==================
|
||||
openPicker:function(btn){
|
||||
var input=btn.previousElementSibling;
|
||||
this._elTargetInput=input;
|
||||
// 直接弹设备选择页(独立模态框,不影响任务编辑窗口)
|
||||
this._showDevicePicker();
|
||||
},
|
||||
// 步骤1:设备选择页
|
||||
_showDevicePicker:function(){
|
||||
document.getElementById('el-picker-title').textContent='抓取元素 - 选择设备';
|
||||
document.getElementById('el-picker-body').innerHTML='<div class="el-device-grid"><div class="egd-title">选择要抓取元素的设备</div><div id="el-device-list">加载中...</div></div>';
|
||||
document.getElementById('el-picker-footer').innerHTML='<button class="btn" onclick="closeElPicker()">取消</button>';
|
||||
document.getElementById('el-picker-overlay').classList.add('show');
|
||||
fetch('/api/uiauto/devices').then(function(r){return r.json();}).then(function(res){
|
||||
var list=document.getElementById('el-device-list');
|
||||
if(!res.ok){list.innerHTML='<div class="se-empty">'+esc(res.error||'获取设备失败')+'<br><button class="btn btn-sm" style="margin-top:10px" onclick="_stepEditor._showDevicePicker()">重试</button></div>';return;}
|
||||
var devices=res.devices||[];
|
||||
if(!devices.length){list.innerHTML='<div class="se-empty">uiauto2 未发现已连接设备</div>';return;}
|
||||
list.innerHTML=devices.map(function(d){
|
||||
return '<div class="el-device-card" onclick="_stepEditor._showElPicker(\''+esc(d.serial)+'\')">'+
|
||||
'<div class="edc-icon">📱</div>'+
|
||||
'<div class="edc-info"><div class="edc-serial">'+esc(d.serial)+'</div>'+
|
||||
'<div class="edc-model">'+esc(d.model||d.product||'')+'</div></div>'+
|
||||
'<span class="edc-status">'+esc(d.status||'device')+'</span></div>';
|
||||
}).join('');
|
||||
}).catch(function(e){
|
||||
document.getElementById('el-device-list').innerHTML='<div class="se-empty">请求失败: '+esc(e)+'<br><button class="btn btn-sm" style="margin-top:10px" onclick="_stepEditor._showDevicePicker()">重试</button></div>';
|
||||
});
|
||||
},
|
||||
// 步骤2:选中设备后,显示截图 + 元素树
|
||||
_showElPicker:function(serial){
|
||||
var self=this;
|
||||
self._elSerial=serial;
|
||||
document.getElementById('el-picker-title').textContent='抓取元素 - '+serial;
|
||||
document.getElementById('el-picker-body').innerHTML=
|
||||
'<div class="el-picker-main">'+
|
||||
'<div class="el-shot-wrap">'+
|
||||
'<div class="el-shot-toolbar">'+
|
||||
'<button class="btn" onclick="_stepEditor._refreshShot()">刷新截图</button>'+
|
||||
'<span class="el-shot-info" id="el-shot-info">加载中...</span>'+
|
||||
'</div>'+
|
||||
'<div class="el-shot-container" id="el-shot-container">'+
|
||||
'<div class="el-shot-loading">正在获取截图...</div>'+
|
||||
'</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>'+
|
||||
'</div>';
|
||||
document.getElementById('el-picker-footer').innerHTML=
|
||||
'<button class="btn" onclick="_stepEditor._showDevicePicker()">← 返回选设备</button>'+
|
||||
'<button class="btn" onclick="closeElPicker()">取消</button>';
|
||||
// 加载截图和元素树
|
||||
self._refreshShot();
|
||||
fetch('/api/uiauto/elements?serial='+encodeURIComponent(serial))
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(data){
|
||||
if(!data.ok){
|
||||
document.getElementById('el-list').innerHTML='<div class="se-empty">'+esc(data.error||'抓取失败')+'</div>';
|
||||
return;
|
||||
}
|
||||
self._elData=data.elements||[];
|
||||
self._renderEl(self._elData);
|
||||
self._renderOverlays();
|
||||
})
|
||||
.catch(function(e){
|
||||
document.getElementById('el-list').innerHTML='<div class="se-empty">请求失败: '+esc(e)+'</div>';
|
||||
});
|
||||
},
|
||||
// 刷新截图
|
||||
_refreshShot: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 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();
|
||||
},
|
||||
// 在截图上渲染元素边界框(可点击)
|
||||
_renderOverlays:function(){
|
||||
var container=document.getElementById('el-shot-container');
|
||||
var img=container.querySelector('img');
|
||||
if(!img||!this._elData)return;
|
||||
// 等图片加载完
|
||||
var self=this;
|
||||
function draw(){
|
||||
// 清除旧 overlay
|
||||
container.querySelectorAll('.el-shot-overlay').forEach(function(o){o.remove();});
|
||||
var scaleX=img.clientWidth/img.naturalWidth;
|
||||
var scaleY=img.clientHeight/img.naturalHeight;
|
||||
// 图片在 container 里的偏移
|
||||
var imgOffsetX=img.offsetLeft;
|
||||
var imgOffsetY=img.offsetTop;
|
||||
self._elData.forEach(function(el,i){
|
||||
if(!el.bounds)return;
|
||||
// bounds 格式 "[x1,y1][x2,y2]"
|
||||
var m=el.bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
|
||||
if(!m)return;
|
||||
var x1=parseInt(m[1]),y1=parseInt(m[2]),x2=parseInt(m[3]),y2=parseInt(m[4]);
|
||||
var w=(x2-x1)*scaleX;
|
||||
var h=(y2-y1)*scaleY;
|
||||
if(w<4||h<4)return; // 太小不画
|
||||
var ov=document.createElement('div');
|
||||
ov.className='el-shot-overlay';
|
||||
ov.style.left=(imgOffsetX+x1*scaleX)+'px';
|
||||
ov.style.top=(imgOffsetY+y1*scaleY)+'px';
|
||||
ov.style.width=w+'px';
|
||||
ov.style.height=h+'px';
|
||||
ov.title=el.name||el.class||'';
|
||||
ov.onclick=function(e){
|
||||
e.stopPropagation();
|
||||
self._highlightEl(i);
|
||||
};
|
||||
container.appendChild(ov);
|
||||
});
|
||||
}
|
||||
if(img.complete&&img.naturalWidth>0)draw();
|
||||
else img.onload=draw;
|
||||
},
|
||||
// 高亮元素(截图框 + 列表项联动)
|
||||
_highlightEl:function(i){
|
||||
// 清除旧高亮(只在 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-picker-item.active').forEach(function(o){o.classList.remove('active');});
|
||||
// 高亮当前(overlays 基于 _elData 原始索引)
|
||||
var overlays=picker.querySelectorAll('.el-shot-overlay');
|
||||
if(overlays[i])overlays[i].classList.add('active');
|
||||
// 列表项用 data-idx 查找(过滤后顺序可能不同)
|
||||
var item=picker.querySelector('.el-picker-item[data-idx="'+i+'"]');
|
||||
if(item){
|
||||
item.classList.add('active');
|
||||
item.scrollIntoView({block:'nearest',behavior:'smooth'});
|
||||
}
|
||||
},
|
||||
_renderEl:function(list){
|
||||
var self=this;
|
||||
var html=list.map(function(el){
|
||||
var idx=self._elData.indexOf(el);
|
||||
var attrs=[];
|
||||
if(el.resource_id)attrs.push('<span>id:'+esc(el.resource_id)+'</span>');
|
||||
if(el.text)attrs.push('<span>text:'+esc(el.text)+'</span>');
|
||||
if(el.description)attrs.push('<span>desc:'+esc(el.description)+'</span>');
|
||||
var indent=' '.repeat(Math.min(el.depth||0,6));
|
||||
return '<div class="el-picker-item" data-idx="'+idx+'" 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>'+
|
||||
'<span class="ep-sel">'+esc(el.suggested.type)+'</span>'+
|
||||
'</div>';
|
||||
}).join('');
|
||||
document.getElementById('el-list').innerHTML=html||'<div class="se-empty">无元素</div>';
|
||||
},
|
||||
_filterEl:function(kw){
|
||||
if(!kw){this._renderEl(this._elData);this._renderOverlays();return;}
|
||||
kw=kw.toLowerCase();
|
||||
var f=this._elData.filter(function(el){
|
||||
return (el.resource_id&&el.resource_id.toLowerCase().includes(kw))||
|
||||
(el.text&&el.text.toLowerCase().includes(kw))||
|
||||
(el.description&&el.description.toLowerCase().includes(kw));
|
||||
});
|
||||
this._renderEl(f);
|
||||
},
|
||||
_pickEl:function(i){
|
||||
var el=this._elData[i];
|
||||
if(!el||!this._elTargetInput)return;
|
||||
// 填入推荐选择器值,并切换选择器类型
|
||||
this._elTargetInput.value=el.suggested.value;
|
||||
var sel=this._elTargetInput.closest('.sc-body').querySelector('[data-key="selector_type"]');
|
||||
if(sel)sel.value=el.suggested.type;
|
||||
// 只关闭抓取元素弹窗,不影响任务编辑窗口
|
||||
closeElPicker();
|
||||
showToast('已填入: '+el.suggested.type+'='+el.suggested.value,'success');
|
||||
}
|
||||
};
|
||||
|
||||
async function saveTask(jobId){
|
||||
const name=document.getElementById('f-name').value.trim();
|
||||
if(!name){showToast('任务名不能为空','error');return;}
|
||||
let params={};
|
||||
try{params=JSON.parse(document.getElementById('f-params').value||'{}');}
|
||||
catch(e){showToast('参数 JSON 格式错误','error');return;}
|
||||
const tt=document.getElementById('f-task_type').value;
|
||||
if(tt==='generic_steps'){
|
||||
// 通用步骤任务:从步骤编辑器收集
|
||||
const steps=_stepEditor.collect();
|
||||
if(!steps.length){showToast('请至少添加一个步骤','error');return;}
|
||||
// 校验:click 步骤的 selector_value 不能为空
|
||||
var warnings=_stepEditor.validate(steps);
|
||||
if(warnings.length){
|
||||
if(!confirm('步骤配置有问题:\n\n'+warnings.join('\n')+'\n\n确定要保存吗?'))return;
|
||||
}
|
||||
const mdEl=document.getElementById('f-max_duration');
|
||||
params={max_duration:mdEl?parseInt(mdEl.value)||0:0,steps:steps};
|
||||
}else{
|
||||
try{params=JSON.parse(document.getElementById('f-params').value||'{}');}
|
||||
catch(e){showToast('参数 JSON 格式错误','error');return;}
|
||||
}
|
||||
|
||||
const targetMode=document.getElementById('f-target_mode').value;
|
||||
const target={mode:targetMode};
|
||||
@@ -1329,6 +2104,14 @@ function closeModal(){
|
||||
}catch(e){console.warn('closeModal cleanup error:',e);}
|
||||
}
|
||||
|
||||
// 关闭元素抓取弹窗(不影响任务编辑窗口)
|
||||
function closeElPicker(){
|
||||
var ov=document.getElementById('el-picker-overlay');
|
||||
if(ov)ov.classList.remove('show');
|
||||
// 清理状态
|
||||
try{ _stepEditor._elData=null; _stepEditor._elSerial=''; }catch(e){}
|
||||
}
|
||||
|
||||
// ================== 行内按钮操作(停止/执行/启停等,自动 toast + 刷新)==================
|
||||
async function apiAction(url,body){
|
||||
const r=await apiPost(url,body);
|
||||
|
||||
+173
-1
@@ -13,7 +13,12 @@
|
||||
用户数据存 data/users.db(SQLite)。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
import atexit
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Flask, jsonify, request, redirect, url_for, render_template, Response, make_response
|
||||
from flask_login import (LoginManager, login_user, logout_user,
|
||||
@@ -22,9 +27,10 @@ from flask_login import (LoginManager, login_user, logout_user,
|
||||
from core.stf_client import STFClient
|
||||
from core.task_manager import TaskManager
|
||||
from core.logger import get_logger, _LOG_DIR, _MODULE_FILES
|
||||
from core.models import db, init_db, User, DeviceGroup, TaskJob
|
||||
from core.models import db, init_db, User, DeviceGroup, TaskJob, CustomAction
|
||||
from core.apk_manager import ApkManager
|
||||
from core.adb_helper import screenshot
|
||||
from core import uiauto_helper
|
||||
from tasks import list_task_types, get_task_class
|
||||
|
||||
_log = get_logger("web")
|
||||
@@ -364,6 +370,121 @@ def api_device_screenshot():
|
||||
return jsonify({"ok": False, "error": data}), 500
|
||||
|
||||
|
||||
# ================== 自定义动作(步骤打包) ==================
|
||||
@app.route("/api/custom_actions")
|
||||
@login_required
|
||||
def api_custom_actions_list():
|
||||
rows = CustomAction.query.order_by(CustomAction.created_at.desc()).all()
|
||||
return jsonify({"ok": True, "actions": [r.to_dict() for r in rows]})
|
||||
|
||||
|
||||
@app.route("/api/custom_actions", methods=["POST"])
|
||||
@login_required
|
||||
def api_custom_actions_create():
|
||||
data = request.json or {}
|
||||
name = (data.get("name") or "").strip()
|
||||
if not name:
|
||||
return jsonify({"ok": False, "error": "动作名不能为空"}), 400
|
||||
steps = data.get("steps", [])
|
||||
if not steps:
|
||||
return jsonify({"ok": False, "error": "至少需要1个步骤"}), 400
|
||||
row = CustomAction(id=uuid.uuid4().hex[:8], name=name,
|
||||
icon=data.get("icon") or "📦",
|
||||
created_at=datetime.now().strftime("%Y-%m-%d %H:%M"))
|
||||
row.set_steps(steps)
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
return jsonify({"ok": True, "msg": "动作已保存", "action": row.to_dict()})
|
||||
|
||||
|
||||
@app.route("/api/custom_actions/<action_id>", methods=["PUT"])
|
||||
@login_required
|
||||
def api_custom_actions_update(action_id):
|
||||
row = CustomAction.query.get(action_id)
|
||||
if not row:
|
||||
return jsonify({"ok": False, "error": "动作不存在"}), 404
|
||||
data = request.json or {}
|
||||
if "name" in data:
|
||||
name = (data["name"] or "").strip()
|
||||
if not name:
|
||||
return jsonify({"ok": False, "error": "动作名不能为空"}), 400
|
||||
row.name = name
|
||||
if "icon" in data:
|
||||
row.icon = data["icon"] or "📦"
|
||||
if "steps" in data:
|
||||
if not data["steps"]:
|
||||
return jsonify({"ok": False, "error": "至少需要1个步骤"}), 400
|
||||
row.set_steps(data["steps"])
|
||||
db.session.commit()
|
||||
return jsonify({"ok": True, "msg": "已更新", "action": row.to_dict()})
|
||||
|
||||
|
||||
@app.route("/api/custom_actions/<action_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
def api_custom_actions_delete(action_id):
|
||||
row = CustomAction.query.get(action_id)
|
||||
if not row:
|
||||
return jsonify({"ok": False, "error": "动作不存在"}), 404
|
||||
db.session.delete(row)
|
||||
db.session.commit()
|
||||
return jsonify({"ok": True, "msg": "已删除"})
|
||||
|
||||
|
||||
@app.route("/api/uiauto/elements")
|
||||
@login_required
|
||||
def api_uiauto_elements():
|
||||
"""获取设备当前 UI 元素树(供步骤编辑器"抓取元素"用)。
|
||||
|
||||
依赖本地运行的 uiautodev 服务(端口 20242)。
|
||||
?serial=xxx 设备 serial
|
||||
返回:
|
||||
200 — {ok:true, elements:[...]}
|
||||
503 — {ok:false, error:"..."}(uiauto2 未启动)
|
||||
"""
|
||||
serial = request.args.get("serial", "")
|
||||
if not serial:
|
||||
return jsonify({"ok": False, "error": "缺少 serial"}), 400
|
||||
ok, data = uiauto_helper.get_elements(serial)
|
||||
if ok:
|
||||
return jsonify({"ok": True, "elements": data})
|
||||
return jsonify({"ok": False, "error": data}), 503
|
||||
|
||||
|
||||
@app.route("/api/uiauto/status")
|
||||
@login_required
|
||||
def api_uiauto_status():
|
||||
"""探测 uiauto2 本地服务是否运行(前端按钮禁启用)。"""
|
||||
return jsonify({"ok": True, "running": uiauto_helper.is_running()})
|
||||
|
||||
|
||||
@app.route("/api/uiauto/devices")
|
||||
@login_required
|
||||
def api_uiauto_devices():
|
||||
"""获取 uiauto2 已连接的设备列表(供抓取元素时选择设备)。"""
|
||||
ok, data = uiauto_helper.list_devices()
|
||||
if ok:
|
||||
return jsonify({"ok": True, "devices": data})
|
||||
return jsonify({"ok": False, "error": data}), 503
|
||||
|
||||
|
||||
@app.route("/api/uiauto/screenshot")
|
||||
@login_required
|
||||
def api_uiauto_screenshot():
|
||||
"""通过 uiauto2 获取设备截图(供抓取元素时显示设备画面)。
|
||||
|
||||
?serial=xxx 设备 serial
|
||||
直接返回 image/jpeg,前端用 <img> 加载。
|
||||
"""
|
||||
serial = request.args.get("serial", "")
|
||||
if not serial:
|
||||
return jsonify({"ok": False, "error": "缺少 serial"}), 400
|
||||
ok, data = uiauto_helper.get_screenshot(serial)
|
||||
if ok:
|
||||
return Response(data, mimetype="image/jpeg",
|
||||
headers={"Cache-Control": "no-store"})
|
||||
return jsonify({"ok": False, "error": data}), 503
|
||||
|
||||
|
||||
# ================== API:应用管理(APK 上传/安装)==================
|
||||
@app.route("/api/apks")
|
||||
@login_required
|
||||
@@ -417,6 +538,54 @@ def api_apks_install_status():
|
||||
return jsonify({"ok": True, "status": status})
|
||||
|
||||
|
||||
# ================== uiautodev 自动启动 ==================
|
||||
_uiauto_proc = None
|
||||
|
||||
|
||||
def _ensure_uiauto_running():
|
||||
"""确保 uiautodev 本地服务在运行(元素抓取功能依赖,端口 20242)。
|
||||
|
||||
已在运行则跳过;未运行则用子进程启动 `uiautodev server --no-browser`。
|
||||
启动失败只记日志,不影响主服务。
|
||||
"""
|
||||
global _uiauto_proc
|
||||
if uiauto_helper.is_running():
|
||||
_log.info("uiauto2 服务已在运行 (端口 20242)")
|
||||
return
|
||||
try:
|
||||
_log.info("正在自动启动 uiautodev 服务 (端口 20242)...")
|
||||
kwargs = dict(stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
if sys.platform == "win32":
|
||||
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||||
_uiauto_proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uiautodev", "server", "--no-browser"],
|
||||
**kwargs,
|
||||
)
|
||||
_log.info(f"uiautodev 服务已启动 (PID={_uiauto_proc.pid})")
|
||||
atexit.register(_stop_uiauto)
|
||||
except FileNotFoundError:
|
||||
_log.warning("uiautodev 未安装,元素抓取功能不可用。请运行: pip install uiautodev")
|
||||
except Exception as e:
|
||||
_log.warning(f"自动启动 uiautodev 失败: {e}(元素抓取功能不可用)")
|
||||
|
||||
|
||||
def _stop_uiauto():
|
||||
"""停止自动启动的 uiautodev 子进程。"""
|
||||
global _uiauto_proc
|
||||
if not _uiauto_proc:
|
||||
return
|
||||
try:
|
||||
_uiauto_proc.terminate()
|
||||
_uiauto_proc.wait(timeout=5)
|
||||
_log.info("uiautodev 服务已停止")
|
||||
except Exception:
|
||||
try:
|
||||
_uiauto_proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
_uiauto_proc = None
|
||||
|
||||
|
||||
def _run_server(host, port):
|
||||
"""启动 Flask,自动处理端口冲突和权限问题。
|
||||
|
||||
@@ -453,8 +622,11 @@ def _run_server(host, port):
|
||||
|
||||
if __name__ == "__main__":
|
||||
from config import WEB_HOST, WEB_PORT
|
||||
# 自动启动 uiautodev 服务(元素抓取功能依赖,端口 20242)
|
||||
_ensure_uiauto_running()
|
||||
_log.info(f"管理后台: http://localhost:{WEB_PORT}/ (admin/admin123)")
|
||||
try:
|
||||
_run_server(WEB_HOST, WEB_PORT)
|
||||
finally:
|
||||
mgr.shutdown()
|
||||
_stop_uiauto()
|
||||
|
||||
Reference in New Issue
Block a user