feat(任务): 任务级「公共巡检」——独立于步骤画布的守护条件(含 webhook 通知)
需求:任务编辑器里能单独配"这个任务每隔 N 秒检查一次"——熄屏就点亮、某个元素
出现就通知、掉出 App 就停本设备;通知标题正文要能自己写。
配置与执行分离(这是本次的关键设计):
- **配置是任务级的**(`params.watchers`),在任务编辑器单独一块,不进步骤画布;
- **执行是穿插的**:worker 每执行完一步、以及长等待的每个分片,看一眼哪个巡检
到点了。不起线程 → 不需要并发模型,也不会和主流程抢屏幕(两边同时点屏幕会
互相打断)。代价是精度受步长影响(某步卡 30s,巡检最多晚 30s),已在文档写明。
- core/patrol.py(新):检查项/动作注册表(CHECKS/ACTIONS)+ evaluate/act。
检查:屏幕熄灭/亮着、元素存在/不存在、前台是/不是某 App;
动作:只通知、点亮、息屏、停止本设备。屏幕走 `dumpsys power`(0.3s,
不用 d.info——那玩意在部分设备要 14s),前台走 d.app_current()(0.7s)。
- tasks/generic/task.py:`_maybe_patrol` / `_run_patrol`(冷却、命中记一条
步骤明细、发通知);**文案在动作之前渲染**——点亮后 {screen} 就成了"亮屏",
用户要看的是"发现熄屏,已点亮"。
- 任务编辑器新增「公共巡检」块(static/admin/tasks.js)+ 样式;保存进 params.watchers。
- 通知:新增事件 `task.patrol.hit`(巡检命中)与 `task.notify.custom`(步骤发通知);
给了 title 就用它当标题(不再拼前缀),level 字段可点名级别。
顺带(巡检需要的原语,也可单独用):
- if_el 条件判断支持 `selector_type=screen`(亮/熄)与 `foreground`(前台包名);
非元素条件不参与「选择器健康」统计(否则会攒出假的"选择器失效"告警)。
- 新增两个步骤:`notify`(发自定义通知)、`stop_self`(停本设备,记"被停止"
而不是失败,不触发重试)。步骤类型 18 → 20,相关文档计数一并更新。
修 bug:`wait` 步骤在巡检耗时超过剩余时间后 `sleep(负数)` 抛
"sleep length must be non-negative"(真机联调抓到,已 clamp 到 0)。
自测:假设备单测 12 组(命中/冷却/间隔/元素/前台/停止/静默/异常不炸);
真机联调:熄屏→点亮(False→True)+ 通知文案正确、掉出抖音按间隔命中 5 次、
长等待里穿插生效且步骤回到 ok、清理后用户通知配置原样恢复。
文档:TASK_DEV §4.5(含两个可抄的例子)与步骤表/条件类型、NOTIFY §3、README。
This commit is contained in:
@@ -46,7 +46,8 @@
|
|||||||
| **多设备并发任务** | 每设备一个 Worker 线程;同一设备同时只跑一个任务(可配置"抢占"打断其他任务) |
|
| **多设备并发任务** | 每设备一个 Worker 线程;同一设备同时只跑一个任务(可配置"抢占"打断其他任务) |
|
||||||
| **设备池** | SQLite 清单 + adb 在线状态;支持手工添加、网段自动发现、一键重连、型号采集、启用/停用 |
|
| **设备池** | SQLite 清单 + adb 在线状态;支持手工添加、网段自动发现、一键重连、型号采集、启用/停用 |
|
||||||
| **任务调度** | 手动 / cron 定时 / 定时启停;运行窗口;失败重试(含端口耗尽类的长退避) |
|
| **任务调度** | 手动 / cron 定时 / 定时启停;运行窗口;失败重试(含端口耗尽类的长退避) |
|
||||||
| **步骤编辑器** | 可视化拖拽编排 18 种步骤(含循环/条件/OCR),可打包成"自定义动作"复用 |
|
| **步骤编辑器** | 可视化拖拽编排 20 种步骤(含循环/条件/OCR/通知),可打包成"自定义动作"复用 |
|
||||||
|
| **公共巡检** | 任务级的守护条件(**独立于步骤画布**):每 N 秒检查屏幕亮/熄、元素在不在、前台是不是某 App,命中就点亮/息屏/停本设备/推通知——通知标题正文自己写(见 [TASK_DEV.md](doc/TASK_DEV.md) §4.5) |
|
||||||
| **元素抓取** | 拉取设备 UI 元素树 → 点选回填选择器;支持"点一下"与"测选择器"真机验证 |
|
| **元素抓取** | 拉取设备 UI 元素树 → 点选回填选择器;支持"点一下"与"测选择器"真机验证 |
|
||||||
| **实时看屏** | MJPEG 实时流 + 点击/滑动/按键/文字输入;全屏监控大屏(`/wall`) |
|
| **实时看屏** | MJPEG 实时流 + 点击/滑动/按键/文字输入;全屏监控大屏(`/wall`) |
|
||||||
| **应用管理** | APK 上传/解析/批量安装;设备已装应用与版本查询;剪贴板注入 |
|
| **应用管理** | APK 上传/解析/批量安装;设备已装应用与版本查询;剪贴板注入 |
|
||||||
@@ -140,6 +141,7 @@ auto_control/
|
|||||||
│ ├── system_backup.py # 数据备份导出/导入(重启生效)
|
│ ├── system_backup.py # 数据备份导出/导入(重启生效)
|
||||||
│ ├── notifier.py # 通知分发(队列/聚合/限流/适配器)+ notify_events.py 事件目录
|
│ ├── notifier.py # 通知分发(队列/聚合/限流/适配器)+ notify_events.py 事件目录
|
||||||
│ ├── step_log.py # 任务步骤明细(异步写线程 + 保留期清理)
|
│ ├── step_log.py # 任务步骤明细(异步写线程 + 保留期清理)
|
||||||
|
│ ├── patrol.py # 任务级公共巡检(检查项/动作注册表,穿插执行)
|
||||||
│ ├── tailscale_client.py # Tailscale API v2 客户端
|
│ ├── tailscale_client.py # Tailscale API v2 客户端
|
||||||
│ ├── ssh_client.py # SSH 封装(当前无人调用,预留)
|
│ ├── ssh_client.py # SSH 封装(当前无人调用,预留)
|
||||||
│ ├── logger.py # 分文件日志(core/task/web/action)
|
│ ├── logger.py # 分文件日志(core/task/web/action)
|
||||||
@@ -148,7 +150,7 @@ auto_control/
|
|||||||
├── tasks/ # 任务定义层
|
├── tasks/ # 任务定义层
|
||||||
│ ├── base.py # BaseTask + _TASK_TYPES + register_task
|
│ ├── base.py # BaseTask + _TASK_TYPES + register_task
|
||||||
│ └── generic/ # 通用步骤任务(task_type=generic_steps,当前唯一类型)
|
│ └── generic/ # 通用步骤任务(task_type=generic_steps,当前唯一类型)
|
||||||
│ └── task.py # STEP_TYPES(18 种步骤)+ Worker + 执行器
|
│ └── task.py # STEP_TYPES(20 种步骤)+ Worker + 执行器
|
||||||
│
|
│
|
||||||
├── mcp_server/ # MCP Server(20 个 de_* 工具,:8033)
|
├── mcp_server/ # MCP Server(20 个 de_* 工具,:8033)
|
||||||
│ ├── mcp_server.py # 工具定义 + 平台登录 + 门控
|
│ ├── mcp_server.py # 工具定义 + 平台登录 + 门控
|
||||||
@@ -246,7 +248,7 @@ self.set_progress(done=5, total=80, unit="视频", action_counts={"like": 3}, el
|
|||||||
| Tab | 内容 | 可见性 |
|
| Tab | 内容 | 可见性 |
|
||||||
|-----|------|--------|
|
|-----|------|--------|
|
||||||
| **监控** | 统计卡片 + 设备表(在线/型号/任务状态/进度/前台 App/最近错误,支持排序、搜索、分页、多选批量操作)+ 异常汇总 + **任务运行概况**(每张任务卡:执行任务 / 停用任务 + 覆盖设备彩色 chip);导航栏「📺 大屏」打开 `/wall` | 所有登录用户(设备操作按钮需"设备控制") |
|
| **监控** | 统计卡片 + 设备表(在线/型号/任务状态/进度/前台 App/最近错误,支持排序、搜索、分页、多选批量操作)+ 异常汇总 + **任务运行概况**(每张任务卡:执行任务 / 停用任务 + 覆盖设备彩色 chip);导航栏「📺 大屏」打开 `/wall` | 所有登录用户(设备操作按钮需"设备控制") |
|
||||||
| **任务** | 子分栏:**任务计划**(CRUD / 启停 / 执行 / 下次运行时间 / 离线跳过 / 步骤编辑器)、**自定义动作**(步骤打包复用) | 可看;写操作需"任务管理" |
|
| **任务** | 子分栏:**任务计划**(CRUD / 启停 / 执行 / 下次运行时间 / 离线跳过 / 步骤编辑器 / **公共巡检**)、**自定义动作**(步骤打包复用) | 可看;写操作需"任务管理" |
|
||||||
| **日志** | 子分栏:**文件日志**(文件切换含滚动历史、关键字/级别/时间过滤、命中高亮、下载筛选结果、自动刷新)、**步骤明细**(按设备/任务/结果/时间过滤、按运行归组的概览、导出 CSV) | 需"日志查看" |
|
| **日志** | 子分栏:**文件日志**(文件切换含滚动历史、关键字/级别/时间过滤、命中高亮、下载筛选结果、自动刷新)、**步骤明细**(按设备/任务/结果/时间过滤、按运行归组的概览、导出 CSV) | 需"日志查看" |
|
||||||
| **用户** | 用户 CRUD、改密、分配权限 | 仅管理员 |
|
| **用户** | 用户 CRUD、改密、分配权限 | 仅管理员 |
|
||||||
| **工具** | 8 个子分栏:剪贴板注入 / adb 远程终端 / Tailscale 管理 / 应用管理 / 应用版本管理 / 设备已装应用 / **设备池管理**(含自动发现)/ 设备分组 | 仅管理员 |
|
| **工具** | 8 个子分栏:剪贴板注入 / adb 远程终端 / Tailscale 管理 / 应用管理 / 应用版本管理 / 设备已装应用 / **设备池管理**(含自动发现)/ 设备分组 | 仅管理员 |
|
||||||
@@ -274,7 +276,7 @@ self.set_progress(done=5, total=80, unit="视频", action_counts={"like": 3}, el
|
|||||||
|
|
||||||
`generic_steps` 的执行内容全在 `params.steps`(JSON 数组),由步骤编辑器产出。**没有默认步骤**——空步骤任务执行时会明确报错。
|
`generic_steps` 的执行内容全在 `params.steps`(JSON 数组),由步骤编辑器产出。**没有默认步骤**——空步骤任务执行时会明确报错。
|
||||||
|
|
||||||
**18 种步骤**(完整参数见 [doc/TASK_DEV.md](doc/TASK_DEV.md)):
|
**20 种步骤**(完整参数见 [doc/TASK_DEV.md](doc/TASK_DEV.md)):
|
||||||
|
|
||||||
| 类别 | 步骤 |
|
| 类别 | 步骤 |
|
||||||
|------|------|
|
|------|------|
|
||||||
@@ -396,7 +398,7 @@ read_log_text()`,`file` 参数走白名单,不接受任意路径。
|
|||||||
| [doc/ARCHITECTURE.md](doc/ARCHITECTURE.md) | 架构详解(分层、装配顺序、线程模型、设备生命周期、调度链路、设计决策) |
|
| [doc/ARCHITECTURE.md](doc/ARCHITECTURE.md) | 架构详解(分层、装配顺序、线程模型、设备生命周期、调度链路、设计决策) |
|
||||||
| [doc/DATA_MODEL.md](doc/DATA_MODEL.md) | 数据模型(表结构、迁移、`app_meta`、数据目录、备份覆盖清单) |
|
| [doc/DATA_MODEL.md](doc/DATA_MODEL.md) | 数据模型(表结构、迁移、`app_meta`、数据目录、备份覆盖清单) |
|
||||||
| [doc/API.md](doc/API.md) | HTTP 接口全量说明 |
|
| [doc/API.md](doc/API.md) | HTTP 接口全量说明 |
|
||||||
| [doc/TASK_DEV.md](doc/TASK_DEV.md) | 任务与步骤开发(18 种步骤、选择器、自定义动作、新增任务类型) |
|
| [doc/TASK_DEV.md](doc/TASK_DEV.md) | 任务与步骤开发(20 种步骤、公共巡检、选择器、自定义动作、新增任务类型) |
|
||||||
| [doc/MCP.md](doc/MCP.md) · [doc/MCP_DESIGN.md](doc/MCP_DESIGN.md) | MCP 使用手册 / 设计文档 |
|
| [doc/MCP.md](doc/MCP.md) · [doc/MCP_DESIGN.md](doc/MCP_DESIGN.md) | MCP 使用手册 / 设计文档 |
|
||||||
| [doc/AI_CONSOLE.md](doc/AI_CONSOLE.md) · [doc/AI_TASK_GEN.md](doc/AI_TASK_GEN.md) | AI 控制台机制 / AI 建任务设计(规划) |
|
| [doc/AI_CONSOLE.md](doc/AI_CONSOLE.md) · [doc/AI_TASK_GEN.md](doc/AI_TASK_GEN.md) | AI 控制台机制 / AI 建任务设计(规划) |
|
||||||
| [doc/DEPLOY.md](doc/DEPLOY.md) | 部署与运维(含生产容器、备份导入、故障排查) |
|
| [doc/DEPLOY.md](doc/DEPLOY.md) | 部署与运维(含生产容器、备份导入、故障排查) |
|
||||||
|
|||||||
+18
-7
@@ -310,10 +310,15 @@ _LEVEL_BY_HINT = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _level_of(event_key):
|
def _level_of(event_key, fields=None):
|
||||||
for hints, lv in _LEVEL_BY_HINT:
|
"""消息级别:**事件自己带的 `level` 优先**(步骤「发通知」/巡检都能自己选),
|
||||||
|
否则按事件 key 猜(failed→error、success→success…),猜不到算 info。"""
|
||||||
|
lv = (fields or {}).get("level")
|
||||||
|
if lv in ("info", "success", "warning", "error"):
|
||||||
|
return lv
|
||||||
|
for hints, lv2 in _LEVEL_BY_HINT:
|
||||||
if any(h in event_key for h in hints):
|
if any(h in event_key for h in hints):
|
||||||
return lv
|
return lv2
|
||||||
return "info"
|
return "info"
|
||||||
|
|
||||||
|
|
||||||
@@ -331,6 +336,8 @@ _FIELD_LABELS = {
|
|||||||
"timeout_s": "超时(秒)", "task_job": "原任务", "model": "型号",
|
"timeout_s": "超时(秒)", "task_job": "原任务", "model": "型号",
|
||||||
"apk_name": "应用", "apk_id": "应用ID", "package_name": "包名",
|
"apk_name": "应用", "apk_id": "应用ID", "package_name": "包名",
|
||||||
"success": "成功", "failed": "失败", "skipped": "跳过", "total": "总数",
|
"success": "成功", "failed": "失败", "skipped": "跳过", "total": "总数",
|
||||||
|
"title": "标题", "message": "内容", "patrol_name": "巡检",
|
||||||
|
"check_label": "检查", "action_label": "动作", "detail": "结果",
|
||||||
"stopped": "停止", "failed_devices": "失败设备",
|
"stopped": "停止", "failed_devices": "失败设备",
|
||||||
"failed_items": "失败明细", "filename": "文件", "size": "大小(字节)",
|
"failed_items": "失败明细", "filename": "文件", "size": "大小(字节)",
|
||||||
"tables": "表数", "include_apk": "含APK", "user": "操作人", "operator": "操作人",
|
"tables": "表数", "include_apk": "含APK", "user": "操作人", "operator": "操作人",
|
||||||
@@ -350,7 +357,9 @@ def _label(k):
|
|||||||
|
|
||||||
|
|
||||||
def _subject(ev, fields):
|
def _subject(ev, fields):
|
||||||
"""标题主体:优先任务名,其次设备名/serial。"""
|
"""标题主体:**用户自己写的 title 优先**,其次任务名,再次设备名/serial。"""
|
||||||
|
if fields.get("title"):
|
||||||
|
return str(fields["title"])
|
||||||
for k in ("job_name", "apk_name", "hook_name"):
|
for k in ("job_name", "apk_name", "hook_name"):
|
||||||
if fields.get(k):
|
if fields.get(k):
|
||||||
return str(fields[k])
|
return str(fields[k])
|
||||||
@@ -365,9 +374,11 @@ def build_message(ev, fields, hook=None):
|
|||||||
"""把事件渲染成统一消息体(title/summary/fields/level/markdown)。"""
|
"""把事件渲染成统一消息体(title/summary/fields/level/markdown)。"""
|
||||||
fields = dict(fields or {})
|
fields = dict(fields or {})
|
||||||
merged = fields.pop("_merged", None)
|
merged = fields.pop("_merged", None)
|
||||||
level = _level_of(ev.key)
|
level = _level_of(ev.key, fields)
|
||||||
subj = _subject(ev, fields)
|
subj = _subject(ev, fields)
|
||||||
title = ev.label + (f":{subj}" if subj else "")
|
# 「发通知」步骤自带标题时**直接用它**:用户写的是"抖音号1 掉线了",
|
||||||
|
# 前面再挂一个"任务自定义通知:"纯属噪音(其它事件照旧带事件名)
|
||||||
|
title = str(fields["title"]) if fields.get("title") else ev.label + (f":{subj}" if subj else "")
|
||||||
if merged and merged.get("merged_count", 0) > 1:
|
if merged and merged.get("merged_count", 0) > 1:
|
||||||
title = f"{ev.label}:{subj}" if subj else ev.label
|
title = f"{ev.label}:{subj}" if subj else ev.label
|
||||||
title += f"({merged['merged_count']} 次)"
|
title += f"({merged['merged_count']} 次)"
|
||||||
@@ -382,7 +393,7 @@ def build_message(ev, fields, hook=None):
|
|||||||
continue
|
continue
|
||||||
# 标题里已经写出来的主体(任务名/设备名)不再重复占一行:
|
# 标题里已经写出来的主体(任务名/设备名)不再重复占一行:
|
||||||
# "任务批次结束:抖音养号" 下面再来一行 "任务:抖音养号" 是纯噪音
|
# "任务批次结束:抖音养号" 下面再来一行 "任务:抖音养号" 是纯噪音
|
||||||
if k in ("job_name", "device_name", "apk_name", "hook_name") \
|
if k in ("job_name", "device_name", "apk_name", "hook_name", "title") \
|
||||||
and str(v) == str(subj):
|
and str(v) == str(subj):
|
||||||
continue
|
continue
|
||||||
# 设备名优先:两个都带时只显示名字——IP 是给日志看的,通知里没人想读地址
|
# 设备名优先:两个都带时只显示名字——IP 是给日志看的,通知里没人想读地址
|
||||||
|
|||||||
@@ -96,6 +96,17 @@ EVENTS = [
|
|||||||
"单次 attempt 抛异常"),
|
"单次 attempt 抛异常"),
|
||||||
|
|
||||||
# ---------------- 业务(任务内容) ----------------
|
# ---------------- 业务(任务内容) ----------------
|
||||||
|
_e("task.patrol.hit", "任务巡检命中", "业务",
|
||||||
|
["title", "message", "patrol_name", "check_label", "detail", "action_label",
|
||||||
|
"job_id", "job_name", "serial", "device_name", "model"],
|
||||||
|
"任务里配置的「公共巡检」命中(熄屏/元素/前台…),已执行动作——"
|
||||||
|
"标题正文可在巡检项里自己写,支持 {device}/{job}/{time}/{app}/{screen}",
|
||||||
|
agg_window=0, recommend=True),
|
||||||
|
_e("task.notify.custom", "任务自定义通知", "业务",
|
||||||
|
["title", "message", "job_id", "job_name", "serial", "device_name", "model"],
|
||||||
|
"任务里的「发通知」步骤被触发——标题与正文由任务自己写,支持 "
|
||||||
|
"{device}/{serial}/{job}/{time}/{app}/{screen} 占位符", agg_window=0,
|
||||||
|
recommend=True),
|
||||||
_e("task.selector.invalid", "选择器连续失效", "业务",
|
_e("task.selector.invalid", "选择器连续失效", "业务",
|
||||||
["serial", "device_name", "selector", "miss_count"],
|
["serial", "device_name", "selector", "miss_count"],
|
||||||
"某选择器连续 10 次未命中——任务可能显示成功但什么都没做", agg_window=0,
|
"某选择器连续 10 次未命中——任务可能显示成功但什么都没做", agg_window=0,
|
||||||
|
|||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
"""任务内**公共巡检**:任务运行期间按间隔穿插检查设备状态,命中就动作 + 通知。
|
||||||
|
|
||||||
|
和步骤编辑器的关系(重要,别混):
|
||||||
|
|
||||||
|
| | 步骤(`params.steps`) | 公共巡检(`params.watchers`) |
|
||||||
|
|---|---|---|
|
||||||
|
| 配置位置 | 步骤画布,拖出来的 | 任务编辑器里单独一块 |
|
||||||
|
| 执行方式 | 顺序执行 | **穿插**:每执行完一步(以及长等待的每个分片)看一眼"哪个巡检到点了" |
|
||||||
|
| 适合什么 | 一段固定流程 | 全程都要盯的守护条件(熄屏就点亮、掉到桌面就通知) |
|
||||||
|
|
||||||
|
为什么用穿插而不是另起线程:不需要并发模型,不会和主流程抢屏幕(否则巡检点屏幕、
|
||||||
|
主流程也在点,动作会互相打断)。代价是巡检精度受步长影响——某一步卡 30 秒,
|
||||||
|
巡检最多晚 30 秒;这也是刻意的取舍(见 doc/TASK_DEV.md §4.5)。
|
||||||
|
|
||||||
|
配置结构(`params.watchers` 数组,每项):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"name": "熄屏点亮", "enabled": true, "interval": 60,
|
||||||
|
"check": "screen_off", // 见 CHECKS
|
||||||
|
"selector_type": "xpath", "selector_value": "", // 元素类检查才用
|
||||||
|
"action": "screen_on", // 见 ACTIONS
|
||||||
|
"notify": true, "title": "{device} 熄屏了",
|
||||||
|
"message": "任务 {job} 在 {time} 发现 {screen},已点亮",
|
||||||
|
"cooldown": 300} // 命中后多久内不再重复动作/通知(防刷屏)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
from core.logger import get_logger
|
||||||
|
from core.u2_helper import screen_is_on, current_package
|
||||||
|
|
||||||
|
_log = get_logger("task.generic")
|
||||||
|
|
||||||
|
# 检查项:need 说明该项还要填什么(selector=元素选择器 / package=包名)
|
||||||
|
CHECKS = {
|
||||||
|
"screen_off": {"label": "屏幕熄灭", "need": ""},
|
||||||
|
"screen_on": {"label": "屏幕亮着", "need": ""},
|
||||||
|
"element_exists": {"label": "元素存在", "need": "selector"},
|
||||||
|
"element_missing": {"label": "元素不存在", "need": "selector"},
|
||||||
|
"foreground_is": {"label": "前台是该App", "need": "package"},
|
||||||
|
"foreground_not": {"label": "前台不是该App", "need": "package"},
|
||||||
|
}
|
||||||
|
|
||||||
|
ACTIONS = {
|
||||||
|
"none": {"label": "只发通知,不动设备"},
|
||||||
|
"screen_on": {"label": "点亮屏幕"},
|
||||||
|
"screen_off": {"label": "熄灭屏幕"},
|
||||||
|
"stop_self": {"label": "停止本设备任务"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 通知正文里可用的占位符(前端提示、文档、渲染三处共用一份,避免漂移)
|
||||||
|
PLACEHOLDERS = ("device", "serial", "job", "time", "app", "screen")
|
||||||
|
|
||||||
|
|
||||||
|
def _element_exists(d, sel_type, sel_val):
|
||||||
|
"""元素是否存在(不等超时,立即判定)。一次 UI dump 的代价,慢设备上可能要几秒。"""
|
||||||
|
if sel_type == "xpath":
|
||||||
|
return bool(d.xpath(sel_val).exists())
|
||||||
|
return bool(d(**{sel_type: sel_val}).exists())
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate(d, spec):
|
||||||
|
"""跑一次检查,返回 (是否命中, 一句人话说明)。异常由调用方兜。"""
|
||||||
|
check = str(spec.get("check") or "")
|
||||||
|
val = str(spec.get("selector_value") or "").strip()
|
||||||
|
|
||||||
|
if check in ("screen_off", "screen_on"):
|
||||||
|
state = screen_is_on(d)
|
||||||
|
if state is None:
|
||||||
|
return False, "屏幕状态读不到"
|
||||||
|
hit = (not state) if check == "screen_off" else state
|
||||||
|
return hit, f"屏幕{'亮着' if state else '熄灭'}"
|
||||||
|
|
||||||
|
if check in ("element_exists", "element_missing"):
|
||||||
|
if not val:
|
||||||
|
return False, "没填选择器"
|
||||||
|
found = _element_exists(d, spec.get("selector_type") or "xpath", val)
|
||||||
|
return (found if check == "element_exists" else not found), \
|
||||||
|
f"元素{'存在' if found else '不存在'}"
|
||||||
|
|
||||||
|
if check in ("foreground_is", "foreground_not"):
|
||||||
|
if not val:
|
||||||
|
return False, "没填包名"
|
||||||
|
cur = current_package(d)
|
||||||
|
return (cur == val) if check == "foreground_is" else (cur != val), \
|
||||||
|
f"前台={cur or '未知'}"
|
||||||
|
|
||||||
|
return False, f"未知检查项 {check}"
|
||||||
|
|
||||||
|
|
||||||
|
def act(d, spec, worker=None):
|
||||||
|
"""执行命中后的动作,返回一句人话(没动作返回空串)。"""
|
||||||
|
action = str(spec.get("action") or "none")
|
||||||
|
if action == "screen_on":
|
||||||
|
d.unlock() # 与 screen_on 步骤同语义:唤醒 + 解锁
|
||||||
|
return "已点亮屏幕"
|
||||||
|
if action == "screen_off":
|
||||||
|
d.screen_off()
|
||||||
|
return "已熄灭屏幕"
|
||||||
|
if action == "stop_self":
|
||||||
|
if worker is not None:
|
||||||
|
worker.stop() # 置停止位,主流程下一步就退出
|
||||||
|
return "已停止本设备任务"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def check_label(check):
|
||||||
|
return (CHECKS.get(check) or {}).get("label", check)
|
||||||
|
|
||||||
|
|
||||||
|
def action_label(action):
|
||||||
|
return (ACTIONS.get(action) or {}).get("label", action)
|
||||||
@@ -909,6 +909,18 @@ class TaskManager:
|
|||||||
job_name=job.name, attempt=attempt,
|
job_name=job.name, attempt=attempt,
|
||||||
phase="after")
|
phase="after")
|
||||||
return
|
return
|
||||||
|
if worker is not None and worker.stopped():
|
||||||
|
# 任务**自己**停的(步骤里的「停止本设备」):不是失败,也不重试。
|
||||||
|
# 判据是"worker 置了停止位但 _stop_requested 里没有"——上面那个
|
||||||
|
# 分支已经排除了用户/调度停止的情况。
|
||||||
|
_log.info(f"{serial} 任务内主动停止本设备 (job={job.name})")
|
||||||
|
outcome = "stopped"
|
||||||
|
notifier.notify("task.device.stopped", serial=serial,
|
||||||
|
device_name=dname,
|
||||||
|
model=_dev_model(serial, tracker),
|
||||||
|
job_id=job.id, job_name=job.name,
|
||||||
|
attempt=attempt, phase="self")
|
||||||
|
return
|
||||||
_log.warning(f"{serial} 任务未成功(status={st})")
|
_log.warning(f"{serial} 任务未成功(status={st})")
|
||||||
except DeviceOfflineError as e:
|
except DeviceOfflineError as e:
|
||||||
# 设备掉线,立即放弃,不重试
|
# 设备掉线,立即放弃,不重试
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
import random
|
import random
|
||||||
|
import re
|
||||||
|
|
||||||
from core.logger import get_logger
|
from core.logger import get_logger
|
||||||
|
|
||||||
@@ -65,6 +66,34 @@ def random_sleep(min_s, max_s):
|
|||||||
time.sleep(random.uniform(min_s, max_s))
|
time.sleep(random.uniform(min_s, max_s))
|
||||||
|
|
||||||
|
|
||||||
|
def screen_is_on(d):
|
||||||
|
"""屏幕是否亮着。返回 True/False,取不到返回 None(三态,别当 False 用)。
|
||||||
|
|
||||||
|
为什么不用 `d.info`:它在部分设备上一次要**十几秒**(见 doc/backlog/TODO.md);
|
||||||
|
`dumpsys power` 经 atx-agent 跑只要 ~0.3s,且 `mWakefulness` 字段全机型都有
|
||||||
|
(MIUI 的 `dumpsys display` 反而没有 mScreenState)。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
out = d.shell("dumpsys power")
|
||||||
|
out = getattr(out, "output", None) or str(out)
|
||||||
|
m = re.search(r"mWakefulness=(\w+)", out)
|
||||||
|
if m:
|
||||||
|
return m.group(1).lower() != "asleep"
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"读取屏幕状态失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def current_package(d):
|
||||||
|
"""当前前台 App 包名;取不到返回空串。"""
|
||||||
|
try:
|
||||||
|
cur = d.app_current() or {}
|
||||||
|
return cur.get("package") or ""
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"读取前台 App 失败: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def safe_click(el, timeout=1):
|
def safe_click(el, timeout=1):
|
||||||
"""安全点击:元素存在才点,不抛异常。返回是否点击成功。"""
|
"""安全点击:元素存在才点,不抛异常。返回是否点击成功。"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
+1
-1
@@ -184,7 +184,7 @@ mcp_agent.Agent.run_stream(prompt, serial, history, on_delta, on_tool, on_usage,
|
|||||||
从本轮**成功**的步骤轨迹里提炼命名动作(如「打开抖音」)。硬约束:
|
从本轮**成功**的步骤轨迹里提炼命名动作(如「打开抖音」)。硬约束:
|
||||||
|
|
||||||
- **禁坐标**:带 `click_xy` 的步骤不会被沉淀(坐标换个设备/分辨率就失效)
|
- **禁坐标**:带 `click_xy` 的步骤不会被沉淀(坐标换个设备/分辨率就失效)
|
||||||
- 白名单步骤类型(18 种去掉 `click_xy`、`keep_screen`)
|
- 白名单步骤类型(20 种去掉 `click_xy`、`keep_screen`)
|
||||||
- 每类型有必填参数校验(如 `click` 必须有选择器)
|
- 每类型有必填参数校验(如 `click` 必须有选择器)
|
||||||
- 输入/产出限量:最多 10 步输入、最多 3 个动作 × 4 步
|
- 输入/产出限量:最多 10 步输入、最多 3 个动作 × 4 步
|
||||||
- 保存时服务端**再校验一次**,含坐标的提交直接 400
|
- 保存时服务端**再校验一次**,含坐标的提交直接 400
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
平台已有两套能力,但互不相通:
|
平台已有两套能力,但互不相通:
|
||||||
- **AI 控制台**:一句话 + 选设备 → 多模态 Agent(DeepSeek)通过 20 个 `de_*` 工具在手机上「边看边做」(截图看屏、`de_ui_tree` 拿元素树、`de_tap_element/de_tap_text` 语义点按),流式回放步骤。
|
- **AI 控制台**:一句话 + 选设备 → 多模态 Agent(DeepSeek)通过 20 个 `de_*` 工具在手机上「边看边做」(截图看屏、`de_ui_tree` 拿元素树、`de_tap_element/de_tap_text` 语义点按),流式回放步骤。
|
||||||
- **任务系统 + 步骤编辑器**:`generic_steps` 任务 = 一棵可嵌套步骤树(open_app/click/swipe/loop/group/if_el…18 种节点),在编辑器里拖拽编排、单步试跑、定时调度。
|
- **任务系统 + 步骤编辑器**:`generic_steps` 任务 = 一棵可嵌套步骤树(open_app/click/swipe/loop/group/if_el…20 种节点),在编辑器里拖拽编排、单步试跑、定时调度。
|
||||||
|
|
||||||
目标:让**非工程用户用一句自然语言需求**(例:「创建一个每日养号刷视频的任务,每天 8:00-9:00 在 100.100.10.13 跑」)得到**一条可直接调度、可继续在现有步骤编辑器里手改的任务**。AI 先自己在设备上打开 App、看 UI 树、确认可点元素,再直接撰写编辑器的步骤 JSON。
|
目标:让**非工程用户用一句自然语言需求**(例:「创建一个每日养号刷视频的任务,每天 8:00-9:00 在 100.100.10.13 跑」)得到**一条可直接调度、可继续在现有步骤编辑器里手改的任务**。AI 先自己在设备上打开 App、看 UI 树、确认可点元素,再直接撰写编辑器的步骤 JSON。
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ daemon 线程。所以:
|
|||||||
| 任务批次 | `task.batch.started` · `.finished` · `.no_device` · `.unknown_type` · `task.cron.stopped` |
|
| 任务批次 | `task.batch.started` · `.finished` · `.no_device` · `.unknown_type` · `task.cron.stopped` |
|
||||||
| 任务·单设备 | `task.device.success` · `.failed` · `.offline` · `.error` · `.retry` · `.stopped` · `.preempted` · `.preempt_timeout` · `.released` |
|
| 任务·单设备 | `task.device.success` · `.failed` · `.offline` · `.error` · `.retry` · `.stopped` · `.preempted` · `.preempt_timeout` · `.released` |
|
||||||
| 业务 | `task.selector.invalid`(选择器连续 10 次未命中——"任务成功但什么都没做"的隐蔽故障) |
|
| 业务 | `task.selector.invalid`(选择器连续 10 次未命中——"任务成功但什么都没做"的隐蔽故障) |
|
||||||
|
| 业务·任务自己发 | `task.notify.custom`(步骤「发通知」)/ `task.patrol.hit`(任务「公共巡检」命中)——**标题正文由任务自己写**,见下 |
|
||||||
| Worker | `worker.connected` · `.attempt.done` · `.attempt.error`(单次尝试级,噪音大,默认没人勾) |
|
| Worker | `worker.connected` · `.attempt.done` · `.attempt.error`(单次尝试级,噪音大,默认没人勾) |
|
||||||
| 设备 | `device.online` · `.offline` · `.discovered` · `.claimed` · `device.heartbeat_timeout` |
|
| 设备 | `device.online` · `.offline` · `.discovered` · `.claimed` · `device.heartbeat_timeout` |
|
||||||
| 安装 | `apk.install.started` · `.finished` |
|
| 安装 | `apk.install.started` · `.finished` |
|
||||||
@@ -91,6 +92,20 @@ daemon 线程。所以:
|
|||||||
单设备事件里的设备一律**显示名字**(`cs1`)而不是地址;只有设备没命名时才退回 IP。
|
单设备事件里的设备一律**显示名字**(`cs1`)而不是地址;只有设备没命名时才退回 IP。
|
||||||
`型号` 取自设备池的快照(后台统一采集的那份),比 worker 每次连接时现采的稳。
|
`型号` 取自设备池的快照(后台统一采集的那份),比 worker 每次连接时现采的稳。
|
||||||
|
|
||||||
|
**任务自己发的通知**(两种,文案都由任务侧提供):
|
||||||
|
|
||||||
|
| 事件 | 谁发 | 文案从哪来 |
|
||||||
|
|---|---|---|
|
||||||
|
| `task.notify.custom` | 步骤「发通知」 | 步骤参数 `title` / `message` |
|
||||||
|
| `task.patrol.hit` | 任务「公共巡检」命中 | 巡检项 `title` / `message`(留空则用默认:`巡检命中:<巡检名>` + 检查结果) |
|
||||||
|
|
||||||
|
- 两者都支持占位符 `{device}` `{serial}` `{job}` `{time}` `{app}` `{screen}`
|
||||||
|
(见 [TASK_DEV.md](TASK_DEV.md) §4.5;`{app}`/`{screen}` 要查设备,不用就别写)。
|
||||||
|
- **给了 `title` 就用它做消息标题**(不再拼"任务通知:" 前缀),`level` 字段可点名级别
|
||||||
|
(`info`/`success`/`warning`/`error` → 决定 emoji,默认按事件名猜)。
|
||||||
|
- 谁能收到仍然只看 webhook 的**事件订阅**:想让某个群收任务自定义消息,就在那条
|
||||||
|
webhook 上勾 `task.notify.custom` / `task.patrol.hit`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 配置
|
## 4. 配置
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
| [ARCHITECTURE.md](ARCHITECTURE.md) | **架构详解**:分层、启动装配顺序、线程与并发模型、设备生命周期、任务调度链路、状态机、关键设计决策与扩展点 | 所有开发者(先读这篇) |
|
| [ARCHITECTURE.md](ARCHITECTURE.md) | **架构详解**:分层、启动装配顺序、线程与并发模型、设备生命周期、任务调度链路、状态机、关键设计决策与扩展点 | 所有开发者(先读这篇) |
|
||||||
| [DATA_MODEL.md](DATA_MODEL.md) | **数据模型**:SQLite 表与字段、schema 迁移、非模型表、`app_meta` 配置键、数据目录、备份覆盖清单 | 后端开发、运维 |
|
| [DATA_MODEL.md](DATA_MODEL.md) | **数据模型**:SQLite 表与字段、schema 迁移、非模型表、`app_meta` 配置键、数据目录、备份覆盖清单 | 后端开发、运维 |
|
||||||
| [API.md](API.md) | **HTTP 接口全量**:按蓝图分组的路由表、鉴权、请求/响应示例、非 JSON 响应、错误分支 | 前端开发、外部接入 |
|
| [API.md](API.md) | **HTTP 接口全量**:按蓝图分组的路由表、鉴权、请求/响应示例、非 JSON 响应、错误分支 | 前端开发、外部接入 |
|
||||||
| [TASK_DEV.md](TASK_DEV.md) | **任务与步骤开发**:TaskType/TaskJob 概念、18 种步骤全表、选择器与定位、自定义动作、新增任务类型模板 | 写任务的开发 |
|
| [TASK_DEV.md](TASK_DEV.md) | **任务与步骤开发**:TaskType/TaskJob 概念、20 种步骤全表、公共巡检、选择器与定位、自定义动作、新增任务类型模板 | 写任务的开发 |
|
||||||
| [MCP.md](MCP.md) | **MCP 手机控制手册**:20 个 `de_*` 工具用法、写操作门控、坐标换算、接入示例 | 接入方、数字员工 |
|
| [MCP.md](MCP.md) | **MCP 手机控制手册**:20 个 `de_*` 工具用法、写操作门控、坐标换算、接入示例 | 接入方、数字员工 |
|
||||||
| [MCP_DESIGN.md](MCP_DESIGN.md) | **MCP 设计文档**:边界划分、错误码、白名单/审计设计、演进方向 | 平台开发者 |
|
| [MCP_DESIGN.md](MCP_DESIGN.md) | **MCP 设计文档**:边界划分、错误码、白名单/审计设计、演进方向 | 平台开发者 |
|
||||||
| [AI_CONSOLE.md](AI_CONSOLE.md) | **AI 控制台**:会话/SSE、经验库、动作库、巡检、Markdown 渲染、推理链、token 统计 | 使用者、平台开发者 |
|
| [AI_CONSOLE.md](AI_CONSOLE.md) | **AI 控制台**:会话/SSE、经验库、动作库、巡检、Markdown 渲染、推理链、token 统计 | 使用者、平台开发者 |
|
||||||
|
|||||||
+70
-5
@@ -10,8 +10,8 @@
|
|||||||
|
|
||||||
- [1. 核心概念](#1-核心概念)
|
- [1. 核心概念](#1-核心概念)
|
||||||
- [2. 通用步骤任务 generic_steps](#2-通用步骤任务-generic_steps)
|
- [2. 通用步骤任务 generic_steps](#2-通用步骤任务-generic_steps)
|
||||||
- [3. 18 种步骤全表](#3-18-种步骤全表)
|
- [3. 20 种步骤全表](#3-20-种步骤全表)
|
||||||
- [4. 容器步骤与公共参数](#4-容器步骤与公共参数)
|
- [4. 容器步骤与公共参数](#4-容器步骤与公共参数)(含 [4.5 公共巡检](#45-公共巡检任务级独立于步骤画布))
|
||||||
- [5. 选择器与元素定位](#5-选择器与元素定位)
|
- [5. 选择器与元素定位](#5-选择器与元素定位)
|
||||||
- [6. 自定义动作](#6-自定义动作)
|
- [6. 自定义动作](#6-自定义动作)
|
||||||
- [7. 步骤编辑器(前端)](#7-步骤编辑器前端)
|
- [7. 步骤编辑器(前端)](#7-步骤编辑器前端)
|
||||||
@@ -108,7 +108,7 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 18 种步骤全表
|
## 3. 20 种步骤全表
|
||||||
|
|
||||||
> 参数与默认值以 `tasks/generic/task.py` 为准;前端 `STEP_LIB`(`static/admin/editor.js`)负责在编辑器里呈现这些字段。
|
> 参数与默认值以 `tasks/generic/task.py` 为准;前端 `STEP_LIB`(`static/admin/editor.js`)负责在编辑器里呈现这些字段。
|
||||||
|
|
||||||
@@ -131,7 +131,9 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
|
|||||||
| 15 | `wait` | 等待 | — | `min`(1.0)、`max`(3.0) | — | 随机时长;**分片 sleep**(每 ≤0.5s 检查停止/超时),可被抢占打断 |
|
| 15 | `wait` | 等待 | — | `min`(1.0)、`max`(3.0) | — | 随机时长;**分片 sleep**(每 ≤0.5s 检查停止/超时),可被抢占打断 |
|
||||||
| 16 | `loop` | 循环块 | — | `loop_mode`("rounds")、`max_iterations`(10)、`loop_duration`(600) | `children` | 见 §4.1 |
|
| 16 | `loop` | 循环块 | — | `loop_mode`("rounds")、`max_iterations`(10)、`loop_duration`(600) | `children` | 见 §4.1 |
|
||||||
| 17 | `group` | 动作组 | — | — | `children` | 子步骤**按序执行一次**(不循环);自定义动作拖入画布就是展开成 group |
|
| 17 | `group` | 动作组 | — | — | `children` | 子步骤**按序执行一次**(不循环);自定义动作拖入画布就是展开成 group |
|
||||||
| 18 | `if_el` | 条件判断 | `selector_type`、`selector_value`、`timeout`(3) | `ocr_click`(False) | `then` / `else` | 见 §4.2 |
|
| 18 | `if_el` | 条件判断 | `selector_type`、`selector_value`、`timeout`(3) | `ocr_click`(False) | `then` / `else` | 见 §4.2;条件类型除元素/OCR 外还支持 **屏幕状态**、**前台App** |
|
||||||
|
| 19 | `notify` | 发通知 | — | `title`("")、`message`("")、`level`("info") | — | 推一条**自定义**通知(事件 `task.notify.custom`):标题正文自己写,支持 `{device} {serial} {job} {time} {app} {screen}`;谁收到取决于 webhook 的事件订阅。两者都空则跳过 |
|
||||||
|
| 20 | `stop_self` | 停止本设备 | — | `reason`("") | — | 只停**本设备**的任务(其它设备照跑):置 worker 停止位,后续步骤不再执行,任务记成**被停止而不是失败**(不触发重试) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -149,7 +151,11 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
|
|||||||
|
|
||||||
### 4.2 `if_el` 条件判断
|
### 4.2 `if_el` 条件判断
|
||||||
|
|
||||||
- 先做元素判断:命中 → 执行 `then` 分支;未命中 → 执行 `else` 分支(分支都可继续嵌套)
|
- 先做判断:命中 → 执行 `then` 分支;未命中 → 执行 `else` 分支(分支都可继续嵌套)
|
||||||
|
- **`selector_type` 还支持两类非元素条件**(只有条件判断有,别的带选择器的步骤没有):
|
||||||
|
- `screen` —— 屏幕状态,`selector_value` 填 `off` / `on`(也认 `熄屏`/`灭屏`):读 `dumpsys power` 的 `mWakefulness`;
|
||||||
|
- `foreground` —— 前台是不是某个包名,`selector_value` 填包名(如 `com.ss.android.ugc.aweme`)。
|
||||||
|
- 这两类**不参与「选择器健康」统计**(它们不是选择器,不该攒出「连续未命中」告警),也不吃 `timeout`(只看当前状态,不等待)
|
||||||
- `selector_type="ocr"` 时改用 **OCR**:截屏 → `find_on_screen(img, selector_value)` 子串匹配;命中且 `ocr_click=true` 会点击命中位置。
|
- `selector_type="ocr"` 时改用 **OCR**:截屏 → `find_on_screen(img, selector_value)` 子串匹配;命中且 `ocr_click=true` 会点击命中位置。
|
||||||
- OCR 模式下 **`timeout` 不生效**(单次截图即判定)
|
- OCR 模式下 **`timeout` 不生效**(单次截图即判定)
|
||||||
- OCR 不可用(未装 `rapidocr_onnxruntime`)时该步返回 `None`(不判定)
|
- OCR 不可用(未装 `rapidocr_onnxruntime`)时该步返回 `None`(不判定)
|
||||||
@@ -170,6 +176,65 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 4.5 公共巡检(任务级,独立于步骤画布)
|
||||||
|
|
||||||
|
任务运行期间**要全程盯着的条件**(熄屏就点亮、掉到桌面就通知、刷到广告就停),
|
||||||
|
配在任务编辑器的「公共巡检」块里,**不进步骤画布**。权威实现在 `core/patrol.py`。
|
||||||
|
|
||||||
|
| | 步骤(`params.steps`) | 公共巡检(`params.watchers`) |
|
||||||
|
|---|---|---|
|
||||||
|
| 配置位置 | 步骤画布,拖出来的 | 任务编辑器单独一块 |
|
||||||
|
| 执行方式 | 顺序执行 | **穿插**:每执行完一步、以及长等待的每个分片,看一眼"哪个巡检到点了" |
|
||||||
|
| 适合 | 一段固定流程 | 全程都要盯的守护条件 |
|
||||||
|
|
||||||
|
为什么穿插而不是另起线程:不需要并发模型,也**不会和主流程抢屏幕**(否则巡检点屏幕、
|
||||||
|
主流程同时也在点,动作互相打断)。代价是**精度受步长影响**——某一步卡 30 秒,
|
||||||
|
巡检最多晚 30 秒。
|
||||||
|
|
||||||
|
配置结构(`params.watchers` 数组,每项):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"name": "熄屏点亮", "enabled": true, "interval": 60,
|
||||||
|
"check": "screen_off",
|
||||||
|
"selector_type": "xpath", "selector_value": "",
|
||||||
|
"action": "screen_on",
|
||||||
|
"notify": true, "title": "{device} 熄屏了",
|
||||||
|
"message": "任务 {job} 在 {time} 发现 {screen},已点亮",
|
||||||
|
"cooldown": 300}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `interval` | 检查间隔(秒,最小 5)。**首次检查在任务开始时立刻做一次**,之后按间隔 |
|
||||||
|
| `check` | `screen_off` 屏幕熄灭 / `screen_on` 屏幕亮着 / `element_exists` 元素存在 / `element_missing` 元素不存在 / `foreground_is` 前台是该App / `foreground_not` 前台不是该App |
|
||||||
|
| `selector_type`/`selector_value` | 元素类检查填选择器;`foreground_*` 填**包名**;屏幕类不用填 |
|
||||||
|
| `action` | `none` 只发通知 / `screen_on` 点亮屏幕 / `screen_off` 熄灭屏幕 / `stop_self` 停止本设备任务 |
|
||||||
|
| `notify` | 命中是否推通知(事件 `task.patrol.hit`,见 [NOTIFY.md](NOTIFY.md));`false` 就只做动作 |
|
||||||
|
| `title`/`message` | 通知标题正文,可用 `{device}` `{serial}` `{job}` `{time}` `{app}` `{screen}`。**文案在动作之前渲染**,所以写的是"发现熄屏,已点亮"而不是"发现亮屏" |
|
||||||
|
| `cooldown` | 命中后多少秒内不再重复动作/通知(默认 300)。**条件持续成立时(一直熄屏)靠它防刷屏** |
|
||||||
|
|
||||||
|
两个能直接抄的例子:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// 1) 熄屏就点亮(不吵人):每分钟看一眼,熄了就点亮
|
||||||
|
{"name":"熄屏点亮","interval":60,"check":"screen_off","action":"screen_on","notify":false}
|
||||||
|
|
||||||
|
// 2) 掉出抖音就通知 + 停本设备:前台不是抖音 → 通知 → 停下来等你处理
|
||||||
|
{"name":"掉出抖音","interval":60,"check":"foreground_not","selector_value":"com.ss.android.ugc.aweme",
|
||||||
|
"action":"stop_self","notify":true,"title":"{device} 掉出抖音了",
|
||||||
|
"message":"当前前台 {app},已停止本设备任务"}
|
||||||
|
```
|
||||||
|
|
||||||
|
要点与坑:
|
||||||
|
|
||||||
|
- **巡检不是步骤**:不要试图在画布上找它;它也不计入"已执行 N 次操作"的进度。
|
||||||
|
- 一次检查要查设备:屏幕 `0.3s`、前台 `0.7s`,**元素检查要 dump UI 树(慢设备上可能几秒)**。
|
||||||
|
所以 `interval` 别设太小(元素检查建议 ≥30s)。
|
||||||
|
- 巡检命中会往「日志 → 步骤明细」记一行(`step_type=patrol`,只记命中不记"没事发生")。
|
||||||
|
- 巡检内部异常只记日志、**绝不影响任务主流程**(同 `notifier.notify` 的口径)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 5. 选择器与元素定位
|
## 5. 选择器与元素定位
|
||||||
|
|
||||||
### 5.1 `selector_type` 支持的值
|
### 5.1 `selector_type` 支持的值
|
||||||
|
|||||||
+55
-15
@@ -19,7 +19,9 @@ var STEP_LIB=[
|
|||||||
{type:'wait_el',label:'等待元素',icon:'⏳',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:10}},
|
{type:'wait_el',label:'等待元素',icon:'⏳',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:10}},
|
||||||
{type:'loop',label:'循环块',icon:'↻',cat:'flow',params:{loop_mode:'rounds',max_iterations:10,loop_duration:600,children:[]}},
|
{type:'loop',label:'循环块',icon:'↻',cat:'flow',params:{loop_mode:'rounds',max_iterations:10,loop_duration:600,children:[]}},
|
||||||
{type:'group',label:'动作组',icon:'📦',cat:'flow',params:{children:[]}},
|
{type:'group',label:'动作组',icon:'📦',cat:'flow',params:{children:[]}},
|
||||||
{type:'if_el',label:'条件判断',icon:'❓',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:3,ocr_click:false,then:[],else:[]}}
|
{type:'if_el',label:'条件判断',icon:'❓',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:3,ocr_click:false,then:[],else:[]}},
|
||||||
|
{type:'notify',label:'发通知',icon:'🔔',cat:'flow',params:{title:'',message:'',level:'info'}},
|
||||||
|
{type:'stop_self',label:'停止本设备',icon:'⛔',cat:'flow',params:{reason:''}}
|
||||||
];
|
];
|
||||||
var STEP_LIB_CATS=[['interact','交互操作'],['screen','屏幕与App'],['flow','流程控制']];
|
var STEP_LIB_CATS=[['interact','交互操作'],['screen','屏幕与App'],['flow','流程控制']];
|
||||||
var _customActions=[]; // 后端加载的自定义动作
|
var _customActions=[]; // 后端加载的自定义动作
|
||||||
@@ -46,19 +48,31 @@ function _findLib(type){return STEP_LIB.find(function(s){return s.type===type;})
|
|||||||
// 选择器组件(类型下拉 + 值输入 + 抓取元素 + 测试按钮),click/long_click/swipe_until/wait_el/if_el 复用
|
// 选择器组件(类型下拉 + 值输入 + 抓取元素 + 测试按钮),click/long_click/swipe_until/wait_el/if_el 复用
|
||||||
// withOcr=true 时额外提供 OCR识别 选项(图片/画布里的文字,仅条件判断支持)
|
// withOcr=true 时额外提供 OCR识别 选项(图片/画布里的文字,仅条件判断支持)
|
||||||
function _selRowHtml(p, extra, withOcr){
|
function _selRowHtml(p, extra, withOcr){
|
||||||
var types=withOcr?['xpath','description','text','resourceId','descriptionContains','className','ocr']:
|
// withOcr(条件判断用)额外提供"屏幕状态 / 前台App"两类非元素条件
|
||||||
|
var types=withOcr?['xpath','description','text','resourceId','descriptionContains','className','ocr','screen','foreground']:
|
||||||
['xpath','description','text','resourceId','descriptionContains','className'];
|
['xpath','description','text','resourceId','descriptionContains','className'];
|
||||||
var labels={xpath:'xpath',description:'description',text:'text',resourceId:'resourceId',
|
var labels={xpath:'xpath',description:'description',text:'text',resourceId:'resourceId',
|
||||||
descriptionContains:'descriptionContains',className:'className',ocr:'OCR识别'};
|
descriptionContains:'descriptionContains',className:'className',ocr:'OCR识别',
|
||||||
|
screen:'屏幕状态',foreground:'前台App是'};
|
||||||
var opts=types.map(function(d){
|
var opts=types.map(function(d){
|
||||||
return '<option value="'+d+'"'+(p.selector_type===d?' selected':'')+'>'+labels[d]+'</option>';}).join('');
|
return '<option value="'+d+'"'+(p.selector_type===d?' selected':'')+'>'+labels[d]+'</option>';}).join('');
|
||||||
var toggle=withOcr?' onchange="_stepEditor._toggleOcrMode(this)"':'';
|
var toggle=withOcr?' onchange="_stepEditor._condTypeChanged(this)"':'';
|
||||||
var pick=_can('devices')?'<button class="btn btn-xs btn-pick" onclick="_stepEditor.openPicker(this)">抓取元素</button>':'';
|
var pick=_can('devices')?'<button class="btn btn-xs btn-pick" onclick="_stepEditor.openPicker(this)">抓取元素</button>':'';
|
||||||
var test=_can('devices')?'<button class="btn btn-xs" onclick="_stepEditor._testStep(this)" title="在设备上试执行本步骤,验证选择器是否命中">测试</button>':'';
|
var test=_can('devices')?'<button class="btn btn-xs" onclick="_stepEditor._testStep(this)" title="在设备上试执行本步骤,验证选择器是否命中">测试</button>':'';
|
||||||
|
var isScreen=p.selector_type==='screen', isFg=p.selector_type==='foreground';
|
||||||
|
var valLabel=isScreen?'屏幕状态':(isFg?'前台包名':'选择器值');
|
||||||
|
var valHtml;
|
||||||
|
if(isScreen){
|
||||||
|
valHtml='<select class="form-control" data-key="selector_value" style="max-width:140px">'+
|
||||||
|
'<option value="off"'+(p.selector_value!=='on'?' selected':'')+'>熄屏时就命中</option>'+
|
||||||
|
'<option value="on"'+(p.selector_value==='on'?' selected':'')+'>亮屏时就命中</option></select>';
|
||||||
|
}else{
|
||||||
|
valHtml='<input type="text" class="form-control" data-key="selector_value" value="'+esc(p.selector_value||'')+
|
||||||
|
'" placeholder="'+(isFg?'如 com.ss.android.ugc.aweme':'如 点赞')+'">'+(isFg?'':pick+test);
|
||||||
|
}
|
||||||
return '<div class="form-row"><div class="form-group"><label>选择器类型</label><select class="form-control" data-key="selector_type"'+toggle+'>'+opts+'</select></div>'+
|
return '<div class="form-row"><div class="form-group"><label>选择器类型</label><select class="form-control" data-key="selector_type"'+toggle+'>'+opts+'</select></div>'+
|
||||||
'<div class="form-group" style="grid-column:span 2"><label>选择器值</label><div class="sel-picker">'+
|
'<div class="form-group" style="grid-column:span 2"><label>'+valLabel+'</label><div class="sel-picker">'+
|
||||||
'<input type="text" class="form-control" data-key="selector_value" value="'+esc(p.selector_value||'')+'" placeholder="如 点赞">'+pick+test+
|
valHtml+'</div></div></div>'+(extra||'');
|
||||||
'</div></div></div>'+(extra||'');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载自定义动作库
|
// 加载自定义动作库
|
||||||
@@ -324,6 +338,20 @@ var _stepEditor={
|
|||||||
return '<option value="'+k[0]+'"'+(p.key===k[0]?' selected':'')+'>'+k[1]+'</option>';
|
return '<option value="'+k[0]+'"'+(p.key===k[0]?' selected':'')+'>'+k[1]+'</option>';
|
||||||
}).join('')+'</select></div>';
|
}).join('')+'</select></div>';
|
||||||
h+='<div class="help">返回键用于退出评论/返回上一页,比结束App轻量</div>';
|
h+='<div class="help">返回键用于退出评论/返回上一页,比结束App轻量</div>';
|
||||||
|
}else if(step.type==='notify'){
|
||||||
|
h+='<div class="form-group"><label>标题</label><input type="text" class="form-control" data-key="title" value="'+esc(p.title||'')+'" placeholder="如 抖音号1 掉线了(留空用任务通知)"></div>';
|
||||||
|
h+='<div class="form-group"><label>内容</label><textarea class="form-control" data-key="message" rows="2" style="min-height:52px">'+esc(p.message||'')+'</textarea></div>';
|
||||||
|
h+='<div class="form-row"><div class="form-group"><label>级别</label><select class="form-control" data-key="level">'+
|
||||||
|
[['info','ℹ️ 普通'],['success','✅ 正常'],['warning','⚠️ 警告'],['error','❌ 异常']].map(function(l){
|
||||||
|
return '<option value="'+l[0]+'"'+((p.level||'info')===l[0]?' selected':'')+'>'+l[1]+'</option>';}).join('')+
|
||||||
|
'</select></div></div>';
|
||||||
|
h+='<div class="help">推送到哪条群取决于 webhook 自己的<b>事件订阅</b>(勾了「任务自定义通知」的才收)。'
|
||||||
|
+'正文可用占位符:<b>{device}</b> 设备名、<b>{serial}</b> 地址、<b>{job}</b> 任务名、<b>{time}</b> 时间、'
|
||||||
|
+'<b>{app}</b> 当前前台包名、<b>{screen}</b> 亮屏/熄屏(后两个要查设备,不用就别写)</div>';
|
||||||
|
}else if(step.type==='stop_self'){
|
||||||
|
h+='<div class="form-group"><label>停止原因(可留空)</label><input type="text" class="form-control" data-key="reason" value="'+esc(p.reason||'')+'" placeholder="如 连续刷到广告"></div>';
|
||||||
|
h+='<div class="help">只停<b>本设备</b>的任务,其它设备照常;后续步骤不再执行,任务记为「被停止」而不是失败'
|
||||||
|
+'(不会触发失败重试)。常配合「条件判断」用:命中条件 → 先「发通知」再「停止本设备」</div>';
|
||||||
}else if(step.type==='wait'){
|
}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-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>';
|
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>';
|
||||||
@@ -353,12 +381,17 @@ var _stepEditor={
|
|||||||
h+='<div class="help">动作组:按顺序执行下方子步骤一次。把多个步骤打包成一个可复用的整体。</div>';
|
h+='<div class="help">动作组:按顺序执行下方子步骤一次。把多个步骤打包成一个可复用的整体。</div>';
|
||||||
h+='<div class="form-group"><button class="btn btn-xs btn-primary" onclick="_stepEditor.saveAsCustomAction(\''+esc(step.id||'')+'\')">存为自定义动作</button> <span class="help" style="display:inline">把本组步骤保存到左侧自定义动作库,方便复用</span></div>';
|
h+='<div class="form-group"><button class="btn btn-xs btn-primary" onclick="_stepEditor.saveAsCustomAction(\''+esc(step.id||'')+'\')">存为自定义动作</button> <span class="help" style="display:inline">把本组步骤保存到左侧自定义动作库,方便复用</span></div>';
|
||||||
}else if(step.type==='if_el'){
|
}else if(step.type==='if_el'){
|
||||||
|
// 屏幕状态 / 前台App 是"直接读设备当前状态",没有等待超时、也没有选择器可抓
|
||||||
|
var dyn=(p.selector_type==='screen'||p.selector_type==='foreground');
|
||||||
h+=_selRowHtml(p,
|
h+=_selRowHtml(p,
|
||||||
'<div class="form-group"><label>查找超时(秒)</label><input type="number" class="form-control" data-key="timeout" value="'+(p.timeout||3)+'" min="0.5" max="30" step="0.5"></div>'+
|
(dyn?'':'<div class="form-group"><label>查找超时(秒)</label><input type="number" class="form-control" data-key="timeout" value="'+(p.timeout||3)+'" min="0.5" max="30" step="0.5"></div>'+
|
||||||
'<div class="form-group form-check" data-ocr-field style="display:'+(p.selector_type==='ocr'?'block':'none')+'">'+
|
'<div class="form-group form-check" data-ocr-field style="display:'+(p.selector_type==='ocr'?'block':'none')+'">'+
|
||||||
'<input type="checkbox" data-key="ocr_click" '+(p.ocr_click?'checked':'')+'><label>OCR 命中后自动点击该文字</label></div>'+
|
'<input type="checkbox" data-key="ocr_click" '+(p.ocr_click?'checked':'')+'><label>OCR 命中后自动点击该文字</label></div>')+
|
||||||
'<div class="help">超时内找到元素 → 执行下方"✅ 找到时"分支;未找到 → 执行"❌ 未找到时"分支。两分支可放任意步骤,也可再嵌套条件判断/循环;'+
|
'<div class="help">'+(dyn
|
||||||
'<b>OCR识别</b>可匹配图片/画布里的文字(截屏识别,跨平台)</div>', true);
|
? '命中 → 执行下方"✅ 找到时"分支;未命中 → 执行"❌ 未找到时"分支。<b>屏幕状态</b>读系统 mWakefulness(亮/熄),'
|
||||||
|
+'<b>前台App是</b>判断当前前台是不是某个包名——都只看当前状态,不等待。'
|
||||||
|
: '超时内找到元素 → 执行下方"✅ 找到时"分支;未找到 → 执行"❌ 未找到时"分支。两分支可放任意步骤,也可再嵌套条件判断/循环;'
|
||||||
|
+'<b>OCR识别</b>可匹配图片/画布里的文字(截屏识别,跨平台)')+'</div>', true);
|
||||||
}
|
}
|
||||||
// 共享:触发概率(所有步骤通用,0~100)
|
// 共享:触发概率(所有步骤通用,0~100)
|
||||||
h+='<div class="form-row" style="margin-top:8px"><div class="form-group"><label>触发概率(%)</label><input type="number" class="form-control" data-key="probability" value="'+(p.probability!=null?p.probability:100)+'" min="0" max="100"><div class="help">0~100,<100 表示按概率执行(如 30=隔几次才触发一次,适合"偶尔点赞")</div></div></div>';
|
h+='<div class="form-row" style="margin-top:8px"><div class="form-group"><label>触发概率(%)</label><input type="number" class="form-control" data-key="probability" value="'+(p.probability!=null?p.probability:100)+'" min="0" max="100"><div class="help">0~100,<100 表示按概率执行(如 30=隔几次才触发一次,适合"偶尔点赞")</div></div></div>';
|
||||||
@@ -626,10 +659,12 @@ var _stepEditor={
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
// 条件判断:选择器类型切到 OCR 时显示"命中后点击"开关
|
// 条件判断:选择器类型切到 OCR 时显示"命中后点击"开关
|
||||||
_toggleOcrMode:function(sel){
|
_condTypeChanged:function(sel){
|
||||||
var card=sel.closest('.step-card');
|
// 条件类型一变,"值"控件也要跟着换(元素值输入框 / 屏幕亮熄下拉 / 包名输入),
|
||||||
var f=card&&card.querySelector('[data-ocr-field]');
|
// 整卡重渲染最省心。先把 DOM 的值同步回 _steps,否则重渲染会丢掉刚改的东西。
|
||||||
if(f)f.style.display=sel.value==='ocr'?'block':'none';
|
this._syncFromDom();
|
||||||
|
var self=this;
|
||||||
|
setTimeout(function(){self._renderCanvas();},0);
|
||||||
},
|
},
|
||||||
// 给数组分配唯一 id,用于 move/duplicate/remove/拖拽定位到正确的父数组
|
// 给数组分配唯一 id,用于 move/duplicate/remove/拖拽定位到正确的父数组
|
||||||
_arrId:function(arr){
|
_arrId:function(arr){
|
||||||
@@ -1220,6 +1255,11 @@ async function saveTask(jobId){
|
|||||||
}
|
}
|
||||||
const mdEl=document.getElementById('f-max_duration');
|
const mdEl=document.getElementById('f-max_duration');
|
||||||
params={max_duration:mdEl?parseInt(mdEl.value)||0:0,steps:steps};
|
params={max_duration:mdEl?parseInt(mdEl.value)||0:0,steps:steps};
|
||||||
|
// 公共巡检(任务级,独立于步骤画布;实现在 tasks.js + core/patrol.py)
|
||||||
|
if(typeof patrolCollect==='function'){
|
||||||
|
const ws=patrolCollect();
|
||||||
|
if(ws.length)params.watchers=ws;
|
||||||
|
}
|
||||||
}else{
|
}else{
|
||||||
try{params=JSON.parse(document.getElementById('f-params').value||'{}');}
|
try{params=JSON.parse(document.getElementById('f-params').value||'{}');}
|
||||||
catch(e){showToast('参数 JSON 格式错误','error');return;}
|
catch(e){showToast('参数 JSON 格式错误','error');return;}
|
||||||
|
|||||||
@@ -80,6 +80,128 @@ function _normalizeTaskEnv(env){
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ================== 公共巡检(任务级,独立于步骤画布) ==================
|
||||||
|
// 与后端 core/patrol.py 的 CHECKS/ACTIONS 一一对应;选项变动时两边一起改。
|
||||||
|
// 存进任务 params.watchers,worker 在步骤之间穿插执行(见 core/patrol.py 模块注释)。
|
||||||
|
const _PATROL_CHECKS=[
|
||||||
|
['screen_off','屏幕熄灭',''], ['screen_on','屏幕亮着',''],
|
||||||
|
['element_exists','元素存在','selector'], ['element_missing','元素不存在','selector'],
|
||||||
|
['foreground_is','前台是该App','package'], ['foreground_not','前台不是该App','package'],
|
||||||
|
];
|
||||||
|
const _PATROL_ACTIONS=[
|
||||||
|
['none','只发通知,不动设备'], ['screen_on','点亮屏幕'],
|
||||||
|
['screen_off','熄灭屏幕'], ['stop_self','停止本设备任务'],
|
||||||
|
];
|
||||||
|
let _patrolItems=[];
|
||||||
|
|
||||||
|
function patrolInit(items){
|
||||||
|
_patrolItems=(items||[]).filter(function(x){return x&&typeof x==='object';})
|
||||||
|
.map(function(x){return Object.assign({},x);});
|
||||||
|
patrolRender();
|
||||||
|
}
|
||||||
|
function patrolAdd(){
|
||||||
|
patrolSync();
|
||||||
|
_patrolItems.push({enabled:true,name:'',interval:60,check:'screen_off',selector_type:'xpath',
|
||||||
|
selector_value:'',action:'screen_on',notify:true,
|
||||||
|
title:'',message:'',cooldown:300});
|
||||||
|
patrolRender();
|
||||||
|
}
|
||||||
|
function patrolRemove(i){
|
||||||
|
patrolSync();
|
||||||
|
_patrolItems.splice(i,1);
|
||||||
|
patrolRender();
|
||||||
|
}
|
||||||
|
// DOM → 内存(重渲染前必须调,否则刚填的值会被覆盖)
|
||||||
|
function patrolSync(){
|
||||||
|
const list=document.getElementById('patrol-list');
|
||||||
|
if(!list)return;
|
||||||
|
list.querySelectorAll('.patrol-card').forEach(function(card,i){
|
||||||
|
const w=_patrolItems[i]; if(!w)return;
|
||||||
|
const get=function(k){
|
||||||
|
const el=card.querySelector('[data-pk="'+k+'"]');
|
||||||
|
if(!el)return undefined;
|
||||||
|
return el.type==='checkbox'?el.checked:el.value;
|
||||||
|
};
|
||||||
|
['name','check','action','selector_type','selector_value','title','message'].forEach(function(k){
|
||||||
|
const v=get(k); if(v!==undefined)w[k]=v;
|
||||||
|
});
|
||||||
|
['interval','cooldown'].forEach(function(k){
|
||||||
|
const v=get(k); if(v!==undefined)w[k]=parseInt(v,10)||0;
|
||||||
|
});
|
||||||
|
['enabled','notify'].forEach(function(k){
|
||||||
|
const v=get(k); if(v!==undefined)w[k]=v;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function patrolCollect(){
|
||||||
|
patrolSync();
|
||||||
|
return _patrolItems.map(function(w){
|
||||||
|
return {enabled:w.enabled!==false, name:(w.name||'').trim(), interval:parseInt(w.interval,10)||60,
|
||||||
|
check:w.check||'screen_off', selector_type:w.selector_type||'xpath',
|
||||||
|
selector_value:(w.selector_value||'').trim(), action:w.action||'none',
|
||||||
|
notify:w.notify!==false, title:(w.title||'').trim(), message:(w.message||'').trim(),
|
||||||
|
cooldown:parseInt(w.cooldown,10)||0};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 检查项一变,条件行(选择器/包名)跟着换
|
||||||
|
function patrolCheckChanged(sel){
|
||||||
|
const card=sel.closest('.patrol-card');
|
||||||
|
const need=(_PATROL_CHECKS.find(function(c){return c[0]===sel.value;})||[])[2]||'';
|
||||||
|
const row=card.querySelector('.patrol-cond');
|
||||||
|
row.style.display=need?'flex':'none';
|
||||||
|
const typeBox=row.querySelector('.patrol-seltype');
|
||||||
|
if(typeBox)typeBox.style.display=(need==='selector')?'block':'none';
|
||||||
|
const lb=row.querySelector('.patrol-vallabel');
|
||||||
|
if(lb)lb.textContent=(need==='package')?'前台包名':'选择器值';
|
||||||
|
const inp=row.querySelector('[data-pk="selector_value"]');
|
||||||
|
if(inp)inp.placeholder=(need==='package')?'如 com.ss.android.ugc.aweme':'如 点赞';
|
||||||
|
}
|
||||||
|
function patrolRender(){
|
||||||
|
const list=document.getElementById('patrol-list');
|
||||||
|
if(!list)return;
|
||||||
|
if(!_patrolItems.length){
|
||||||
|
list.innerHTML='<div class="text-muted" style="font-size:12px;padding:4px 0">(没有巡检项)</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML=_patrolItems.map(_patrolCardHtml).join('');
|
||||||
|
}
|
||||||
|
function _patrolCardHtml(w,i){
|
||||||
|
const need=(_PATROL_CHECKS.find(function(c){return c[0]===w.check;})||[])[2]||'';
|
||||||
|
const chkOpts=_PATROL_CHECKS.map(function(c){
|
||||||
|
return '<option value="'+c[0]+'"'+(w.check===c[0]?' selected':'')+'>'+c[1]+'</option>';}).join('');
|
||||||
|
const actOpts=_PATROL_ACTIONS.map(function(a){
|
||||||
|
return '<option value="'+a[0]+'"'+(w.action===a[0]?' selected':'')+'>'+a[1]+'</option>';}).join('');
|
||||||
|
const typeOpts=['xpath','description','text','resourceId','descriptionContains','className']
|
||||||
|
.map(function(t){return '<option value="'+t+'"'+(w.selector_type===t?' selected':'')+'>'+t+'</option>';}).join('');
|
||||||
|
return '<div class="patrol-card">'
|
||||||
|
+'<div class="patrol-row">'
|
||||||
|
+'<label class="form-check"><input type="checkbox" data-pk="enabled" '+(w.enabled!==false?'checked':'')+'><span>启用</span></label>'
|
||||||
|
+'<input class="form-control" data-pk="name" style="flex:1" placeholder="巡检名,如 熄屏点亮" value="'+esc(w.name||'')+'">'
|
||||||
|
+'<button class="btn btn-xs btn-danger" onclick="patrolRemove('+i+')">删除</button>'
|
||||||
|
+'</div>'
|
||||||
|
+'<div class="patrol-row">'
|
||||||
|
+'<div class="pg"><label>每隔(秒)</label><input type="number" class="form-control" data-pk="interval" value="'+(w.interval||60)+'" min="5"></div>'
|
||||||
|
+'<div class="pg"><label>检查</label><select class="form-control" data-pk="check" onchange="patrolCheckChanged(this)">'+chkOpts+'</select></div>'
|
||||||
|
+'<div class="pg"><label>命中后动作</label><select class="form-control" data-pk="action">'+actOpts+'</select></div>'
|
||||||
|
+'<div class="pg"><label>冷却(秒)</label><input type="number" class="form-control" data-pk="cooldown" value="'+(w.cooldown!=null?w.cooldown:300)+'" min="0"></div>'
|
||||||
|
+'</div>'
|
||||||
|
+'<div class="patrol-row patrol-cond" style="display:'+(need?'flex':'none')+'">'
|
||||||
|
+'<div class="pg patrol-seltype" style="display:'+(need==='selector'?'block':'none')+'"><label>选择器类型</label>'
|
||||||
|
+'<select class="form-control" data-pk="selector_type">'+typeOpts+'</select></div>'
|
||||||
|
+'<div class="pg" style="flex:1"><label class="patrol-vallabel">'+(need==='package'?'前台包名':'选择器值')+'</label>'
|
||||||
|
+'<input class="form-control" data-pk="selector_value" value="'+esc(w.selector_value||'')+'" placeholder="'
|
||||||
|
+(need==='package'?'如 com.ss.android.ugc.aweme':'如 点赞')+'"></div>'
|
||||||
|
+'</div>'
|
||||||
|
+'<div class="patrol-row">'
|
||||||
|
+'<label class="form-check"><input type="checkbox" data-pk="notify" '+(w.notify!==false?'checked':'')+'><span>命中发通知</span></label>'
|
||||||
|
+'<div class="pg" style="flex:1"><label>标题</label><input class="form-control" data-pk="title" value="'+esc(w.title||'')+'" placeholder="{device} 熄屏了"></div>'
|
||||||
|
+'<div class="pg" style="flex:2"><label>内容</label><input class="form-control" data-pk="message" value="'+esc(w.message||'')+'" placeholder="任务 {job} 在 {time} 发现 {screen},已处理"></div>'
|
||||||
|
+'</div>'
|
||||||
|
+'<div class="help">占位符:{device} 设备名、{serial} 地址、{job} 任务名、{time} 时间、{app} 当前前台包名、'
|
||||||
|
+'{screen} 亮屏/熄屏(后两个要查设备,不用就别写)。冷却期内重复命中不会重复动作/通知,防刷屏</div>'
|
||||||
|
+'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
// openTaskModal(jobId, prefill)
|
// openTaskModal(jobId, prefill)
|
||||||
// jobId —— 传 id = 编辑已有任务
|
// jobId —— 传 id = 编辑已有任务
|
||||||
// prefill —— AI 建任务的草稿:{task, notes, warnings}(服务端已校验,这里只预填给人核对)
|
// prefill —— AI 建任务的草稿:{task, notes, warnings}(服务端已校验,这里只预填给人核对)
|
||||||
@@ -179,6 +301,16 @@ function openTaskModal(jobId, prefill){
|
|||||||
'<div class="se-lib" id="se-lib"><div class="lib-title">操作库</div></div>'+
|
'<div class="se-lib" id="se-lib"><div class="lib-title">操作库</div></div>'+
|
||||||
'<div class="se-canvas" id="se-canvas"></div>'+
|
'<div class="se-canvas" id="se-canvas"></div>'+
|
||||||
'</div>'+
|
'</div>'+
|
||||||
|
'</div>'+
|
||||||
|
// 公共巡检:任务级配置,**不在步骤画布里**(穿插执行,见 core/patrol.py)
|
||||||
|
'<div class="patrol-box" id="patrol-box">'+
|
||||||
|
'<div class="patrol-head">'+
|
||||||
|
'<span>公共巡检<span class="hint" style="margin-left:8px">可选:任务运行期间按间隔穿插检查,命中就动作 + 通知</span></span>'+
|
||||||
|
'<button class="btn btn-xs" onclick="patrolAdd()">+ 添加巡检</button>'+
|
||||||
|
'</div>'+
|
||||||
|
'<div id="patrol-list"></div>'+
|
||||||
|
'<div class="help">和步骤的区别:步骤是"按顺序做完这一段",巡检是"全程都要盯着的条件"(比如熄屏就点亮、掉到桌面就通知)。'+
|
||||||
|
'检查穿插在步骤之间执行,不额外抢设备;某一步耗时很长时,巡检最多晚那一步的时间。</div>'+
|
||||||
'</div>';
|
'</div>';
|
||||||
|
|
||||||
// AI 草稿的提醒/复核项:贴在步骤编辑器上方,别让人漏看
|
// AI 草稿的提醒/复核项:贴在步骤编辑器上方,别让人漏看
|
||||||
@@ -198,6 +330,7 @@ function openTaskModal(jobId, prefill){
|
|||||||
// 又保留自动存草稿(保存成功时 saveTask 会 clearDraft 清掉它)。
|
// 又保留自动存草稿(保存成功时 saveTask 会 clearDraft 清掉它)。
|
||||||
const draftKey=jobId||(pf?('ai:'+Date.now()):'');
|
const draftKey=jobId||(pf?('ai:'+Date.now()):'');
|
||||||
_stepEditor.init(j.params&&j.params.steps?j.params.steps:[],j.params&&j.params.max_duration?j.params.max_duration:0,draftKey);
|
_stepEditor.init(j.params&&j.params.steps?j.params.steps:[],j.params&&j.params.max_duration?j.params.max_duration:0,draftKey);
|
||||||
|
patrolInit(j.params&&j.params.watchers);
|
||||||
|
|
||||||
document.getElementById('modal-footer').innerHTML=
|
document.getElementById('modal-footer').innerHTML=
|
||||||
'<button class="btn" onclick="closeModal()">取消</button>'+
|
'<button class="btn" onclick="closeModal()">取消</button>'+
|
||||||
@@ -285,6 +418,8 @@ function onTaskTypeChange(){
|
|||||||
const isGeneric=tt==='generic_steps';
|
const isGeneric=tt==='generic_steps';
|
||||||
document.getElementById('step-editor').classList.toggle('show',isGeneric);
|
document.getElementById('step-editor').classList.toggle('show',isGeneric);
|
||||||
document.getElementById('f-params_wrap').style.display=isGeneric?'none':'block';
|
document.getElementById('f-params_wrap').style.display=isGeneric?'none':'block';
|
||||||
|
const pb=document.getElementById('patrol-box');
|
||||||
|
if(pb)pb.style.display=isGeneric?'block':'none'; // 巡检只对通用步骤任务有意义
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================== 自定义动作管理 ==================
|
// ================== 自定义动作管理 ==================
|
||||||
|
|||||||
+167
-6
@@ -26,6 +26,8 @@ worker 按 steps 顺序执行,支持 loop 步骤循环、停止信号、进度
|
|||||||
wait_el - 等待元素出现(条件等待,替代固定时长 wait)
|
wait_el - 等待元素出现(条件等待,替代固定时长 wait)
|
||||||
input_text - 输入文字(随机候选/指定文字,可先清空)
|
input_text - 输入文字(随机候选/指定文字,可先清空)
|
||||||
wait - 等待时长
|
wait - 等待时长
|
||||||
|
notify - 发自定义通知(标题/正文自己写,推送 webhook)
|
||||||
|
stop_self - 停止本设备的任务(其它设备不受影响)
|
||||||
loop - 循环块(含 children 步骤列表 + max_iterations)
|
loop - 循环块(含 children 步骤列表 + max_iterations)
|
||||||
group - 动作组(含 children 步骤列表,按序执行一次,可折叠复用)
|
group - 动作组(含 children 步骤列表,按序执行一次,可折叠复用)
|
||||||
"""
|
"""
|
||||||
@@ -35,9 +37,10 @@ import time
|
|||||||
|
|
||||||
from tasks.base import BaseTask, register_task
|
from tasks.base import BaseTask, register_task
|
||||||
from core.device_worker import BaseWorker, _update_status
|
from core.device_worker import BaseWorker, _update_status
|
||||||
from core.u2_helper import ensure_app_running, wait_for_app_home, random_sleep
|
from core.u2_helper import (ensure_app_running, wait_for_app_home, random_sleep,
|
||||||
|
screen_is_on, current_package)
|
||||||
from core.logger import get_logger
|
from core.logger import get_logger
|
||||||
from core import notifier, step_log
|
from core import notifier, step_log, patrol
|
||||||
|
|
||||||
_log = get_logger("task.generic")
|
_log = get_logger("task.generic")
|
||||||
|
|
||||||
@@ -107,6 +110,10 @@ STEP_TYPES = [
|
|||||||
{"type": "if_el", "label": "条件判断", "icon": "❓",
|
{"type": "if_el", "label": "条件判断", "icon": "❓",
|
||||||
"params": {"selector_type": "xpath", "selector_value": "", "timeout": 3,
|
"params": {"selector_type": "xpath", "selector_value": "", "timeout": 3,
|
||||||
"then": [], "else": []}},
|
"then": [], "else": []}},
|
||||||
|
{"type": "notify", "label": "发通知", "icon": "🔔",
|
||||||
|
"params": {"title": "", "message": "", "level": "info"}},
|
||||||
|
{"type": "stop_self", "label": "停止本设备", "icon": "⛔",
|
||||||
|
"params": {"reason": ""}},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -136,6 +143,17 @@ class GenericStepsWorker(BaseWorker):
|
|||||||
self._path = []
|
self._path = []
|
||||||
self._step_rows = 0 # 本次运行已记录的明细条数(封顶见 _record_step)
|
self._step_rows = 0 # 本次运行已记录的明细条数(封顶见 _record_step)
|
||||||
self._step_capped = False
|
self._step_capped = False
|
||||||
|
# 公共巡检(任务级配置,独立于步骤画布;见 core/patrol.py):
|
||||||
|
# _wid 内部标识,用来记"下次到点 / 上次命中"两个节奏
|
||||||
|
self._watchers = []
|
||||||
|
for i, w in enumerate(p.get("watchers") or []):
|
||||||
|
if not isinstance(w, dict) or not w.get("enabled", True):
|
||||||
|
continue
|
||||||
|
w = dict(w)
|
||||||
|
w["_wid"] = f"w{i}"
|
||||||
|
self._watchers.append(w)
|
||||||
|
self._watch_next = {} # wid -> 下次到点(缺省 0 = 任务一开始就先查一次)
|
||||||
|
self._watch_last = {} # wid -> 上次命中(冷却用)
|
||||||
|
|
||||||
# 某 click 选择器连续未找到元素的次数达到该值,判定可能失效(App 改版)
|
# 某 click 选择器连续未找到元素的次数达到该值,判定可能失效(App 改版)
|
||||||
_MAX_CONSECUTIVE_MISS = 10
|
_MAX_CONSECUTIVE_MISS = 10
|
||||||
@@ -185,6 +203,7 @@ class GenericStepsWorker(BaseWorker):
|
|||||||
for i, step in enumerate(steps):
|
for i, step in enumerate(steps):
|
||||||
if self.stopped() or self.is_time_up():
|
if self.stopped() or self.is_time_up():
|
||||||
return
|
return
|
||||||
|
self._maybe_patrol(d) # 穿插:每步之前看一眼有没有巡检到点了
|
||||||
# 压入本步在兄弟里的序号(1 起):容器步骤执行 children 时会在其后
|
# 压入本步在兄弟里的序号(1 起):容器步骤执行 children 时会在其后
|
||||||
# 继续追加,得到 "2.1" 这样的嵌套路径
|
# 继续追加,得到 "2.1" 这样的嵌套路径
|
||||||
self._path.append(str(i + 1))
|
self._path.append(str(i + 1))
|
||||||
@@ -279,6 +298,71 @@ class GenericStepsWorker(BaseWorker):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass # 记录失败绝不能影响任务执行
|
pass # 记录失败绝不能影响任务执行
|
||||||
|
|
||||||
|
# ================== 公共巡检(任务级,穿插执行) ==================
|
||||||
|
def _maybe_patrol(self, d):
|
||||||
|
"""看一眼有没有巡检到点了;到点就跑一次检查(配置见 core/patrol.py)。
|
||||||
|
|
||||||
|
穿插式:不起线程、不抢屏幕,所以巡检精度受主流程步长影响——某一步卡
|
||||||
|
30 秒,巡检最多晚 30 秒(这是刻意取舍,换来的是绝不会和主流程打架)。
|
||||||
|
"""
|
||||||
|
if not self._watchers or self.stopped():
|
||||||
|
return
|
||||||
|
now = time.time()
|
||||||
|
for w in self._watchers:
|
||||||
|
wid = w["_wid"]
|
||||||
|
if now < self._watch_next.get(wid, 0):
|
||||||
|
continue
|
||||||
|
# 先排下一次再执行:检查本身可能慢(元素检查要 dump UI 树),
|
||||||
|
# 否则慢检查会导致"每分钟"变成"每检查一次"
|
||||||
|
self._watch_next[wid] = now + max(5, int(w.get("interval") or 60))
|
||||||
|
try:
|
||||||
|
self._run_patrol(d, w, now)
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"[{self.serial}] 巡检[{w.get('name') or wid}]异常: {e}")
|
||||||
|
|
||||||
|
def _run_patrol(self, d, w, now):
|
||||||
|
"""执行一次巡检:命中 → 过冷却 → 做动作 + 发通知 + 记一条明细。"""
|
||||||
|
wid, name = w["_wid"], (w.get("name") or patrol.check_label(w.get("check")))
|
||||||
|
hit, detail = patrol.evaluate(d, w)
|
||||||
|
if not hit:
|
||||||
|
return
|
||||||
|
# 命中后冷却:条件持续成立(比如一直熄屏)时不要每分钟都动作/发通知
|
||||||
|
cooldown = max(0, int(w.get("cooldown") or 0))
|
||||||
|
if now - self._watch_last.get(wid, 0) < cooldown:
|
||||||
|
_log.info(f"[{self.serial}] 巡检[{name}]命中({detail})但在冷却期内,不重复动作")
|
||||||
|
return
|
||||||
|
self._watch_last[wid] = now
|
||||||
|
# 文案在**动作之前**渲染:动作会改变设备状态(点亮后 {screen} 就成了"亮屏"),
|
||||||
|
# 而用户要看的是"发现时"的样子——"发现熄屏,已点亮"而不是"发现亮屏,已点亮"
|
||||||
|
title = self._render_vars(d, w.get("title") or "") or f"巡检命中:{name}"
|
||||||
|
message = self._render_vars(d, w.get("message") or "") or detail
|
||||||
|
done = patrol.act(d, w, self)
|
||||||
|
_log.info(f"[{self.serial}] 巡检[{name}]命中:{detail}"
|
||||||
|
f"{(' → ' + done) if done else '(只通知)'}")
|
||||||
|
self.set_action(f"巡检[{name}]:{detail}{(',' + done) if done else ''}")
|
||||||
|
summary = f"{detail}{(' → ' + done) if done else ''}"
|
||||||
|
try: # 明细:只记命中,不记"没事发生"
|
||||||
|
step_log.record(run_id=self.ctx.get("run_id", ""), serial=self.serial,
|
||||||
|
device_name=self.ctx.get("device_name", ""),
|
||||||
|
job_id=self.ctx.get("job_id", ""),
|
||||||
|
job_name=self.ctx.get("job_name", ""),
|
||||||
|
step_label=f"巡检[{name}]", step_type="patrol",
|
||||||
|
result="hit", detail=summary)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if w.get("notify", True):
|
||||||
|
notifier.notify("task.patrol.hit",
|
||||||
|
patrol_name=name,
|
||||||
|
check_label=patrol.check_label(w.get("check")),
|
||||||
|
action_label=patrol.action_label(w.get("action")),
|
||||||
|
detail=detail,
|
||||||
|
job_id=self.ctx.get("job_id", ""),
|
||||||
|
job_name=self.ctx.get("job_name", ""),
|
||||||
|
serial=self.serial,
|
||||||
|
device_name=self.ctx.get("device_name", ""),
|
||||||
|
title=title, message=message,
|
||||||
|
level=w.get("level", "warning"))
|
||||||
|
|
||||||
# ================== 步骤执行器 ==================
|
# ================== 步骤执行器 ==================
|
||||||
def _exec_screen_on(self, d, params, depth=0):
|
def _exec_screen_on(self, d, params, depth=0):
|
||||||
"""亮屏:息屏时唤醒并滑动解锁。任务执行前用(息屏时 u2 无法操作)。"""
|
"""亮屏:息屏时唤醒并滑动解锁。任务执行前用(息屏时 u2 无法操作)。"""
|
||||||
@@ -490,7 +574,23 @@ class GenericStepsWorker(BaseWorker):
|
|||||||
_log.warning(f"[{self.serial}] if_el 缺少选择器,跳过")
|
_log.warning(f"[{self.serial}] if_el 缺少选择器,跳过")
|
||||||
return None
|
return None
|
||||||
found = False
|
found = False
|
||||||
if sel_type == "ocr":
|
# 非元素类条件(屏幕状态/前台 App):不参与"选择器健康"统计——它们不是选择器,
|
||||||
|
# 连续未命中不该被记成"选择器失效"
|
||||||
|
dynamic = sel_type in ("screen", "foreground")
|
||||||
|
detail = ""
|
||||||
|
if sel_type == "screen":
|
||||||
|
# 屏幕亮/熄:selector_value 填 on / off(也认 熄屏/灭屏 这类中文)
|
||||||
|
want = str(sel_val).strip().lower()
|
||||||
|
want_off = want in ("off", "0", "false", "no", "熄屏", "灭屏", "黑屏")
|
||||||
|
state = screen_is_on(d)
|
||||||
|
found = (state is False) if want_off else (state is True)
|
||||||
|
detail = (f"屏幕{'熄屏' if state is False else '亮屏' if state else '状态未知'}"
|
||||||
|
f"(判断{'熄屏' if want_off else '亮屏'})")
|
||||||
|
elif sel_type == "foreground":
|
||||||
|
cur = current_package(d)
|
||||||
|
found = cur == str(sel_val).strip()
|
||||||
|
detail = f"前台='{cur or '未知'}'(期望 {sel_val})"
|
||||||
|
elif sel_type == "ocr":
|
||||||
# OCR 模式:截图 → 识别文字 → 关键词匹配(UI 树里没有的文字也能找到)
|
# OCR 模式:截图 → 识别文字 → 关键词匹配(UI 树里没有的文字也能找到)
|
||||||
try:
|
try:
|
||||||
from core.ocr import available as _ocr_available, find_on_screen
|
from core.ocr import available as _ocr_available, find_on_screen
|
||||||
@@ -523,10 +623,13 @@ class GenericStepsWorker(BaseWorker):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log.warning(f"[{self.serial}] 条件判断异常: {e}")
|
_log.warning(f"[{self.serial}] 条件判断异常: {e}")
|
||||||
found = False
|
found = False
|
||||||
self._track_selector_health(sel_val, found)
|
if dynamic:
|
||||||
|
_log.info(f"[{self.serial}] 条件判断 {detail} → {'命中' if found else '未命中'}")
|
||||||
|
else:
|
||||||
|
self._track_selector_health(sel_val, found)
|
||||||
branch = params.get("then" if found else "else") or []
|
branch = params.get("then" if found else "else") or []
|
||||||
self.set_action(f"条件{'命中' if found else '未命中'} → 执行{'找到' if found else '未找到'}分支({len(branch)}步)")
|
self.set_action(f"条件{'命中' if found else '未命中'} → 执行{'找到' if found else '未找到'}分支({len(branch)}步)")
|
||||||
_log.info(f"[{self.serial}] 条件判断 {'命中' if found else '未命中'}: {sel_val},执行{'then' if found else 'else'}分支 {len(branch)} 步")
|
_log.info(f"[{self.serial}] 条件判断 {'命中' if found else '未命中'}: {detail or sel_val},执行{'then' if found else 'else'}分支 {len(branch)} 步")
|
||||||
self._exec_steps(d, branch, depth + 1)
|
self._exec_steps(d, branch, depth + 1)
|
||||||
return found
|
return found
|
||||||
|
|
||||||
@@ -547,6 +650,61 @@ class GenericStepsWorker(BaseWorker):
|
|||||||
elif direction == "right":
|
elif direction == "right":
|
||||||
d.swipe(int(w * 0.2), int(h * 0.5), int(w * 0.8), int(h * 0.5), dur)
|
d.swipe(int(w * 0.2), int(h * 0.5), int(w * 0.8), int(h * 0.5), dur)
|
||||||
|
|
||||||
|
def _exec_notify(self, d, params, depth=0):
|
||||||
|
"""发一条自定义通知(标题/正文由任务自己写,支持占位符)。
|
||||||
|
|
||||||
|
事件是 `task.notify.custom`:**谁能收到取决于 webhook 的事件订阅**——
|
||||||
|
这个步骤只负责"把消息交出去",不关心发给谁(与平台其它通知同一套口径)。
|
||||||
|
"""
|
||||||
|
title = self._render_vars(d, params.get("title") or "")
|
||||||
|
msg = self._render_vars(d, params.get("message") or "")
|
||||||
|
if not (title or msg):
|
||||||
|
_log.warning(f"[{self.serial}] notify 步骤既没标题也没内容,跳过")
|
||||||
|
return None
|
||||||
|
notifier.notify("task.notify.custom",
|
||||||
|
job_id=self.ctx.get("job_id", ""),
|
||||||
|
job_name=self.ctx.get("job_name", ""),
|
||||||
|
serial=self.serial,
|
||||||
|
device_name=self.ctx.get("device_name", ""),
|
||||||
|
title=title or "任务通知", message=msg,
|
||||||
|
level=params.get("level", "info"))
|
||||||
|
_log.info(f"[{self.serial}] 已发自定义通知: {title} {msg}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _render_vars(self, d, text):
|
||||||
|
"""替换通知文本里的占位符:{device} {serial} {job} {time} {app} {screen}。
|
||||||
|
|
||||||
|
`{app}` / `{screen}` 要查设备(0.3~0.7s),**只在文本里真用到时才查**,
|
||||||
|
否则每条通知都白付一次设备往返。
|
||||||
|
"""
|
||||||
|
if not text or "{" not in text:
|
||||||
|
return text
|
||||||
|
vals = {"device": self.ctx.get("device_name") or self.serial,
|
||||||
|
"serial": self.serial,
|
||||||
|
"job": self.ctx.get("job_name", ""),
|
||||||
|
"time": time.strftime("%Y-%m-%d %H:%M:%S")}
|
||||||
|
if "{app}" in text:
|
||||||
|
vals["app"] = current_package(d) or "未知"
|
||||||
|
if "{screen}" in text:
|
||||||
|
st = screen_is_on(d)
|
||||||
|
vals["screen"] = "未知" if st is None else ("亮屏" if st else "熄屏")
|
||||||
|
for k, v in vals.items():
|
||||||
|
text = text.replace("{" + k + "}", str(v))
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _exec_stop_self(self, d, params, depth=0):
|
||||||
|
"""停止本设备的任务(其它设备的任务不受影响)。
|
||||||
|
|
||||||
|
实现只是置 worker 的停止位:`_exec_steps` 每步前检查 `stopped()`,所以
|
||||||
|
后续步骤不会再跑;TaskManager 侧把它记成"被停止"而不是失败
|
||||||
|
(见 `core/task_manager.py` 的 `_run_with_retry` 自停分支)。
|
||||||
|
"""
|
||||||
|
reason = (params.get("reason") or "").strip()
|
||||||
|
_log.info(f"[{self.serial}] 任务内主动停止本设备({reason or '未填原因'})")
|
||||||
|
self.set_action(f"停止本设备任务{(':' + reason) if reason else ''}")
|
||||||
|
self.stop()
|
||||||
|
return True
|
||||||
|
|
||||||
def _exec_click(self, d, params, depth=0):
|
def _exec_click(self, d, params, depth=0):
|
||||||
sel_type = params.get("selector_type", "xpath")
|
sel_type = params.get("selector_type", "xpath")
|
||||||
sel_val = params.get("selector_value", "")
|
sel_val = params.get("selector_value", "")
|
||||||
@@ -624,7 +782,10 @@ class GenericStepsWorker(BaseWorker):
|
|||||||
while time.time() < end:
|
while time.time() < end:
|
||||||
if self.stopped() or self.is_time_up():
|
if self.stopped() or self.is_time_up():
|
||||||
return
|
return
|
||||||
time.sleep(min(0.5, end - time.time()))
|
self._maybe_patrol(d) # 长等待里也看一眼,别让巡检被 wait 拖住
|
||||||
|
# max(0, …):巡检本身要花时间(屏幕 0.3s、前台 0.7s、元素检查更久),
|
||||||
|
# 扣掉这段时间后可能已经过点了,负数 sleep 会直接抛异常
|
||||||
|
time.sleep(max(0.0, min(0.5, end - time.time())))
|
||||||
|
|
||||||
def _exec_loop(self, d, params, depth=0):
|
def _exec_loop(self, d, params, depth=0):
|
||||||
children = params.get("children", [])
|
children = params.get("children", [])
|
||||||
|
|||||||
@@ -116,6 +116,18 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
|||||||
.log-cont{color:#8a94a6;padding-left:2.2em} /* 续行(traceback 缩进行):淡色缩进 */
|
.log-cont{color:#8a94a6;padding-left:2.2em} /* 续行(traceback 缩进行):淡色缩进 */
|
||||||
.log-hit{background:#fbbf2433} /* 关键字命中高亮 */
|
.log-hit{background:#fbbf2433} /* 关键字命中高亮 */
|
||||||
|
|
||||||
|
/* 「公共巡检」配置块(任务编辑器里,独立于步骤画布) */
|
||||||
|
.patrol-box{margin-top:14px;border:1px solid var(--border-strong);border-radius:10px;padding:12px}
|
||||||
|
.patrol-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
|
||||||
|
.patrol-head>span:first-child{font-weight:600;font-size:13px}
|
||||||
|
.patrol-card{border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:8px;background:var(--surface-2,#fafafa)}
|
||||||
|
.patrol-row{display:flex;gap:10px;align-items:flex-end;margin-bottom:8px;flex-wrap:wrap}
|
||||||
|
.patrol-row:last-child{margin-bottom:0}
|
||||||
|
.patrol-row .pg{display:flex;flex-direction:column;gap:2px;min-width:110px}
|
||||||
|
.patrol-row .pg label{font-size:11px;color:var(--text-light)}
|
||||||
|
.patrol-row .form-check{margin-bottom:6px;display:flex;align-items:center;gap:4px;font-size:12px}
|
||||||
|
.patrol-cond{background:rgba(59,130,246,.05);border-radius:6px;padding:6px 8px;margin:0 -2px 8px}
|
||||||
|
|
||||||
/* 「步骤明细」表格的结果列配色(.log-* 那套作用域在 .log-content 里,表格用这套) */
|
/* 「步骤明细」表格的结果列配色(.log-* 那套作用域在 .log-content 里,表格用这套) */
|
||||||
.res-error{color:#dc2626;font-weight:600} /* 异常 / 未知步骤类型 */
|
.res-error{color:#dc2626;font-weight:600} /* 异常 / 未知步骤类型 */
|
||||||
.res-miss{color:#d97706} /* 未命中 / 超时 / 已达上限 */
|
.res-miss{color:#d97706} /* 未命中 / 超时 / 已达上限 */
|
||||||
|
|||||||
Reference in New Issue
Block a user