17 Commits
Author SHA1 Message Date
butubb e82fd64695 feat(去重): 「记为已做」留空 = 自动跟随上面「去重」检查的身份(不再要求配两遍)
用户提的(他说得对):"这个标记已做不是应该是我上面的条件判断命中哪一个就用哪一个做吗?
这怎么还要自己选择元素啊"。

原来的设计要求检查与记账**各配一次身份元素** —— 一旦一边填了、另一边忘了,两边算出的
key 不一致 → **去重静默失效**(现象就是"去重没生效、还是重复做",他刚踩过)。
而我在"有效期"上为了避免两处配置特意强制放任务级,却在"身份"上要求填两遍,自相矛盾。

改成:
- `tasks/generic/task.py`:「去重」条件解析出的身份存进 `self._last_ident`;
  `_exec_mark_done` 的身份元素**留空时复用它**(检查与记账必然是同一个字符串)。
  三种情况分清(这是关键,别退化成"用设备"误标):
    ① 自己填了身份元素 → 用自己填的(显式优先)
    ② 留空 + 上面跑过去重检查 → **复用那个身份**
    ③ 留空 + 检查**跑了但没读到身份**(页面没到)→ **不记账**,下次重跑重试
       —— 绝不能退回设备身份,那会在"什么都没做成"时把设备标成已做
    ④ 留空 + 任务里根本没有去重检查 → 退回设备身份(一号一机场景)
- `static/admin/editor.js`:`mark_done` 面板文案改为"留空 = 自动跟随上面检查的身份(推荐)";
  校验里记账侧的**空值不再参与身份比对**(留空是合法的、且会自动对齐),
  只有"填了、且和检查侧不一样"才告警,并在文案里建议留空。
- `doc/TASK_DEV.md` §4.6:身份只需在检查侧配一次(带一张四种组合的表);步骤表同步。

验证:新增 11 项(复用后账本身份 == 检查身份、换设备仍能命中=去重真生效、
显式填身份仍优先、无检查时退回设备、**读不到身份时不记账也不误标设备**);
原 dedup 套件(单元 + 真机)+ 编辑器语法回归全过;
把用户**生产任务的真实 JSON** 喂给新校验 —— **0 条警告**(旧版会报一条"身份不一致",
但现在留空=自动跟随是正确配置)。
2026-09-24 13:39:45 +08:00
butubb 4f3027b54f Merge branch 'dev'——修复去重条件的校验误报 + 补身份一致性检测 2026-09-24 13:35:41 +08:00
butubb 331999d65c Merge branch 'fix/dedup-validation'——去重条件不再误报文本比对警告 + 补上两处身份不一致的检测 2026-09-24 13:35:34 +08:00
butubb 6b2866692d fix(编辑器+执行器): 去重条件上残留的文本比对设置会误报警告;并补上"两处身份不一致"的检测
用户报的现象:内层条件判断已经选了「去重」、身份元素也填了,但保存时**一直提示
"选了文本比对但没填比对的值"**(他看不到、也清不掉那两行)。

根因(我写的):从"元素类"切到「去重 / 屏幕状态」时,面板里"文本比对"那块是**隐藏**的,
但 params 里旧的 `cmp_op`/`cmp_value` 还留着;而校验没排除这两类不用文本比对的类型,
于是每次保存都拿那个残留去报警 —— 用户看不到源头的两行,自然无从下手。

- `static/admin/editor.js` 的 `validate`:
  · 「去重 / 屏幕状态」不再报文本比对的警告(设计上就用不到);
  · 这两类也不报"选择器为空"(去重用的是 `ident_value`,不是 `selector_value`);
  · **顺手补一个真问题的检测**:去重的两处身份**空值也纳入比对** —— 一边填了、
    另一边留空(= 用设备当身份)是最常见的不一致,只收非空值时恰好检测不到。
    报错文案把"留空"写成「(留空 → 用设备当身份)」,用户一眼知道差在哪。
- `tasks/generic/task.py`:被忽略的文本比对只在**真的配了值**时才打 WARNING
  (切换类型留下的空残留不必每轮刷日志)。

验证:把用户**生产任务的真实 JSON** 喂给新的 `validate()`:
假警告消失,只剩下一条真警告 ——「检查侧=账号元素 / 记账侧=留空(用设备)」身份不一致。
2026-09-24 13:34:53 +08:00
butubb 438710f830 Merge branch 'dev'——修复编辑器:嵌套条件判断的分支同步写错容器 2026-09-24 11:45:52 +08:00
butubb 6e1a23c5ae Merge branch 'fix/nested-if-branch-sync'——修复嵌套条件判断的分支同步写错容器(填了值保存就没了 / 步骤被改名) 2026-09-24 11:45:46 +08:00
butubb cba39562dc fix(编辑器): 嵌套条件判断的分支同步写错容器 —— 修复「填了值保存就没了」+「步骤被改名」
用户报的现象(生产「评论」任务):
  ① 一直提示"步骤7子步骤1没有填写包名"
  ② 包名填了、一保存就没了
  ③ 那个步骤还会"自己重命名"成「指定视频评论」

根因(**2026-08-11 的 060157e 引入的老 bug,不是本批改动**):
`_syncParams` 定位 if_el 的分支容器时用的是
    card.querySelector(':scope > .if-branch .step-children.if-else')
`.if-branch` 与 `.step-children` 之间是**空格(后代)**而不是 `>`(直接子级)。
于是**条件判断里再嵌一个条件判断**时,外层卡片会匹配到**内层那个 if_el 的分支容器**
(文档顺序上它更靠前),把内层的子步骤当成自己的分支去同步:

  · 内层分支的「步骤名」输入框 → 写进了外层分支里那个 stop_app 的 label
    (所以步骤被改名成内层容器步骤的名字「指定视频评论」);
  · 内层分支里**没有**包名输入框 → 外层那个 stop_app 的 package **永远读不回来**
    (所以"填了保存就没"、保存后仍报"没填包名")。

把两处都改成直接子级(`> `)即可。then 那侧此前恰好因为文档顺序能命中自己,
但写法一样脆,一并收紧。

验证:最小复现(外层 if_el 的 then 里嵌内层 if_el,外层 else 放 stop_app+screen_off)
—— 修复前:`package=""`、`label="指定视频评论"`(与用户现象逐字一致);
修复后:`package="com.ss.android.ugc.aweme"`、`label="我给它起的名"`。
另扫了生产全部 6 个任务:只有「评论」这一个任务的结构会踩到,且已经踩了
(`7.第1步 stop_app 包名为空`)—— 改动不会波及其它任务。
2026-09-24 11:29:53 +08:00
butubb 870e7138d5 Merge branch 'dev'——跨设备去重账本(同一个号不做两次 + 去重记录页)+ 条件判断多值比对 2026-09-24 11:03:32 +08:00
butubb 8834ecb7ef Merge branch 'feat/dedup-ledger'——跨设备去重账本(同一个号不做两次 + 去重记录页)+ 条件判断多值比对 2026-09-24 11:00:41 +08:00
butubb 876224f876 feat(去重): 跨设备「已做过」账本 —— 同一个号不会做两次 + 「谁做过了」看得见
用户场景(他原话):一台手机登录 5 个抖音号、一共 5 台手机,每个任务只让其中一个
目标号评论;每天跑一次但不知道什么时候跑完,于是"一直重复跑" → 结果
"一个手机还没评论到,一个手机都评论两次了"。

**根因不是"单设备重复",是跨设备没有共享的判断 + 进度不可见。** 所以做两件事:
① 幂等;② 把"谁做过了、还差谁"摆到台面上(不然只能靠重跑确认,而重跑又在制造重复)。

- `core/models.py`:新表 `done_mark`(迁移账本补 v7)。**判据只有 `scope_key` 的
  唯一索引**——多台设备会同时判断"没做过","先查后插"有竞态(两台都插),
  唯一索引 + `INSERT ... ON DUPLICATE KEY`/`INSERT OR IGNORE` 的**受影响行数**才原子。
- `core/dedup.py`(新):`build_key`(`任务|身份|时间桶`)/ `check` / `mark` /
  `list_marks`(带"今天做了几台/几个号"统计)/ `delete_mark` / `clear_job` / `purge_old`。
  自建 app context(照 device_pool 的 `_ctx()`),任务线程/Web/清理都不用关心。
- 任务侧两个部件(**检查在前、记账在后**):
  · `if_el` 新增条件类型 `selector_type="dedup"`:命中=这个身份做过了 → 走 then 分支。
    身份元素在 `ident_type`/`ident_value`(留空 = 用设备 serial,一号一机场景)。
  · 新步骤 `mark_done`「记为已做」(22 种步骤):放动作**成功之后**。
  拆两步的用意:动作失败就不记账,下次重跑还会重试该设备 —— 失败不丢。
- 有效期(`dedup_reset` = day/all/hours)放**任务级**:检查与记账两处各填一份的话,
  填不一致就算出两个 key、去重会**静默失效**,所以强制只配一处(编辑器顶部下拉)。
- 三条防误伤规则(都有测试兜着):
  · 身份读不到 / 身份值过长 → **不去重、当没做过照常执行**。绝不能把"读不到"
    当成空身份——那会让所有设备共用一个 key、第一台记账后其余全被误判成"做过"。
  · `kind='all'`(只做一次)的记录**永不清理**(清了等于语义失效);清理只删 day/hours。
  · 去重的两个易错点在保存时直接告警:身份元素两边不一致、有检查没记账/有记账没检查。
- 「任务 → 去重记录」新子分栏(`static/admin/dedup.js`):统计行 + 明细表 +
  删单条(那个号重跑)/ 清空任务(整批重跑)。接口 3 个(GET/delete/clear,PERM_TASKS)。
- 每日 04:23 清理(挂现有 APScheduler),`TABLE_LABELS` 补中文名(备份覆盖自动派生)。
- AI 建任务草稿校验同步:`dedup` 走自己的规则(要 ident_value、xpath 前缀校验),
  没填身份元素只警告不拦(用设备当身份是合法用法);普通条件空选择器仍然拦。
- 文档:TASK_DEV §4.6(去重专章 + App 内检测的兜底配方与它的三个局限)、
  DATA_MODEL §2.9、API 三个接口、ARCHITECTURE(分层/装配/子分栏/JS 分工/清理)、
  DEPLOY §5.2(15 张表)、步骤数 21→22 全库同步。

自测:单元 + 集成 33 项(**含 8 线程抢同一个身份、恰好一个成功**的原子性断言,
以及"all 记录不被清理""身份读不到不去重""清了能重跑")、
**真机端到端**(cs1 上"检查→动作→记账"跑两遍:第二遍被拦、换 serial 的"另一台设备"
同样被拦、删记录后能重跑)、草稿校验 5 项、GET 冒烟 56 路由 0 个 500。

(注:本分支基于 feat/if-el-multi-value,因为它俩都要改 task.py 的 STEP_TYPES 与
editor.js 的 STEP_LIB 同一区域,分开从 dev 拉必然冲突——这份是超集,合一次两份都进。)
2026-09-24 10:55:29 +08:00
butubb 5c88cc6384 feat(条件判断): 比对的值支持多个(任一命中即可)
需求:`35377983067` 或 `35377983068` 都算我的号,要能一次列好几个。

- `cmp_value` 现在是**多值**:换行或 `|` 分隔,一行一个。
  多个之间是 **OR**(任一命中即命中);否定式(不等于/不包含)语义是
  "一个都不许出现"。**逗号刻意不当分隔符**——要比对的文本本身就常带逗号
  (`抖音号:123,456`),拆了就永远匹配不上。
- 比对核心抽成 `_cmp_hit(got, op, want) -> (是否命中, 命中的那个值)`,
  `_text_matches` 保留布尔版(多处调用只看结果,不改签名)。
  日志因此能写清人话:
  `采集到 '抖音号:35377983068' 包含 2 个候选值 → 符合,命中『35377983068』`
  否定式不成立时则指出"因为出现了『xxx』"——排查时一眼看出是哪个值惹的。
- 行为统一(**刻意的变更**):候选值为空/全空白 = 误配 → 一律不命中。
  以前"等于 + 两边都空"会返回 True(`"" == ""` 的巧合),等于选了"等于"
  却没填值就永远命中、条件判断形同虚设;现在与编辑器告警、AI 草稿校验
  的口径一致了("选了运算符没填值 → 永远不命中")。
- 前端:`cmp_value` 由单行输入换成**两行文本域**,标签与提示写明"可多行、
  任一命中即可、逗号不是分隔符"。
- 文档:TASK_DEV.md §4.2 例子改成多号,并写明分隔符与 OR/否定语义。

自测:多值语义 15 项(换行/`|` 拆分、逗号不拆、空白清理、四种运算符的多值
OR 与否定语义、命中的是哪个值、候选全空不命中)+ 原有 38 项回归全过;
`_exec_if_el` 走多值路径实测命中 then 分支、日志含候选数与命中值。
2026-09-24 10:40:08 +08:00
butubb 54d9a55ff6 Merge branch 'dev'——设备电量监控(大屏+监控页显示、低电量 webhook 告警)、选择设备与所有通知显示设备名、条件判断支持文本比对 2026-09-24 10:12:41 +08:00
butubb f660b1031c Merge branch 'feat/if-el-text-compare'——条件判断支持「拿选中元素的文本和设定的值比」(等于/不等于/包含/不包含) 2026-09-24 10:07:24 +08:00
butubb 34a03db1b9 feat(条件判断): 支持「选中元素 → 拿它的文本和设定的值比」(等于/不等于/包含/不包含)
需求原话:条件判断需要能选择元素,比如我设置了一个抖音号,让它采集这个元素的
文本和我设的值做条件判断。

原来的条件判断只会判「元素在不在」(找到走 then、没找到走 else),没法判"内容对不对"。
现在加两个可选参数:

- `cmp_op`:等于 / 不等于 / 包含 / 不包含(`==`/`!=`/`contains` 这类别名也认);
  留空 = 老行为,**完全向后兼容**(现有任务不用改)。
- `cmp_value`:要比对的值。

填了就变成:先按选择器取到元素文本 → 和 cmp_value 比 → 比对结果才是命中与否。
用户那个例子就是:元素 `com.ss.android.ugc.aweme:id/506` 的文本是
`抖音号:35377983067`,选「包含」、值填 `35377983067` 即可;
要抓"登录的不是这个号"就用「不等于」。

- 读取只走 `get_text()`(只读,2 秒超时),不点击、不改状态。
- **两条防误判规则**(都写进文档了):
  · 元素**没找到**时一律算未命中(走 else)——"没读到"绝不能被当成"和我设的不一样",
    否则界面还没加载出来就会误判成"账号被换了";
  · 元素在但文本为空时按空文本参与比对(用于抓"栏位是空的"),日志会打出实际读到的值。
- 比对**不污染「选择器健康」统计**:统计用的仍是"元素在不在"——"找到了但值不对"
  是条件按预期走了 else,不是选择器失效,不该攒出「连续未命中」告警。
- 支持比对的类型:所有元素类选择器 + `foreground`(比前台包名)+ `ocr`(比 OCR 命中的那段字)。
  `screen`(亮/熄)没有文本可比,设了会忽略并告警。
- 前端:条件判断面板多一块「文本比对」(运算符下拉 + 比对的值),
  只在元素/OCR/前台App 条件下出现;保存前校验"选了运算符却没填值"和"screen 上设了比对"。
- AI 建任务草稿校验(`core/task_draft.py`)同步拦"运算符拼错""有运算符没值"——
  执行器对认不出的运算符是"一律不命中",不拦就会变成悄悄走 else。

自测:比对函数 16 项(含别名、空值、None、拼错运算符)、`_exec_if_el` 全分支 20 项
(含"没找到+不等于"这个关键边界、健康统计用存在性、screen 忽略比对、u2 抛异常不炸)、
**真机验证**(cs1 上真读一个 TextView 的文本再比对:包含/等于/不等于 全对,
元素不存在时不误判)、草稿校验 5 项、GET 路由冒烟 55 个 0 个 500。

(测试里踩到一个坑记一笔:u2 的 `XPathSelector.exists` 是纯 bool 属性,而
`UiObject.exists` 是"可调用的 Exists 对象"(`bool()` 即时判断、`exists(timeout=)` 带等待),
两套语义不能想当然统一——单测的假设备一开始没模拟对,是测试写错了不是代码写错了。)
2026-09-24 09:22:32 +08:00
butubb bf432b4f56 Merge branch 'fix/device-name-display'——选择设备与所有通知显示设备名(不再显示 IP)+ 修 /locate 500/XSS 2026-09-24 09:17:15 +08:00
butubb 8fdde978ea fix(设备名): 选择设备与所有通知都显示名称而不是 IP
用户反馈:① 元素抓取的选择设备列表显示的是 IP;② 所有 webhook 通知里都是 IP;
要都显示设备名。

- 通知链路统一补名字(调用点不用动):`core/notifier.py`
  · 新增 `set_device_name_resolver()` + `fill_device_names()`,在 `notify()` 与
    `build_message()`(预览/测试发送也走它)里把 `serial` 补成 `device_name`、
    把 `serials`/`devices` 列表逐项换成名字。
  · 补一处就全带名字了——标题主体、字段表、聚合样本认的都是 `device_name`。
  · **做成"纯内存回调"是刻意的**:notify() 的硬红线是零 DB,不能为了取个名字去查库
    (那等于在业务线程里加一次阻塞查询)。
  · 降级规则:调用点自己传了 device_name 就用它的;查不到名字(没命名/不在池里)
    保留原地址;解析器缺失或抛异常都只是降级,绝不影响发送。
- `core/device_pool.py`:维护 `serial → 名称` 内存快照——`init_app` 同步刷一次、
  增删改名/迁址后各刷一次(改名立刻生效)、`device-names` 线程每 60s 兜底刷一次
  (覆盖整库恢复这类进程外改动)。`name_of()` 只读内存,可在通知路径上安全调用。
- `web_server.py`:装配层接上 `notifier.set_device_name_resolver(device_pool.name_of)`。
- 元素抓取/测试此步骤的设备列表:`GET /api/uiauto/devices` 的 `name` 改用**平台名**,
  前端 `editor.js` 新增共用的 `_devCard()`——名字做主标题(粗体),
  `型号 · 地址` 作副标题。
  · 池外设备**退回地址而不是 uiautodev 的 name**:实测那份 name 是设备 codename
    (一柜子机器全叫 "earth"),拿它认设备等于没名字,地址至少唯一。
  · 「测试此步骤」的设备列表原来连状态角标都没有,一并统一成同一个卡片。
- 顺带修一个**既有 bug(不是本次需求)**:`/locate` 设备端定位页从 ce47a5b 那次
  web 蓝图拆分起就一直 500——拆分时漏掉了 `render_template_string` 与
  `markupsafe.escape as _esc` 两个 import。后者不只是缺个名字:定位页把 query 里的
  serial 拼进 HTML,而 `render_template_string` 的模板名是 "<template>"、
  **不会自动转义**,所以那还是个反射 XSS。已显式转义(已用 `<img onerror>` 验证)。

自测:device_pool 快照/改名即时生效、fill_device_names 全分支(含列表、
未命名保留地址、不在池保留地址、解析器缺失/抛异常降级)、真消息渲染断言
**通篇不含 IP**;GET 路由冒烟 54 个 0 个 500;前端语法 + 卡片渲染截图核对。
2026-09-24 08:55:10 +08:00
butubb beb51637dd feat(设备): 电量显示(大屏 + 监控页)+ 低电量 webhook 告警
需求:大屏和监控页都要能看到每台设备的电量,并且"低于多少电量就发通知"。

数据从哪来:`adb -s <serial> shell dumpsys battery`(**只读**,实测 0.2~0.35s/台,
11 台并发一轮 0.44s)。设备只要在 `adb devices` 里就说明 transport 现成,
**不需要 connect**——所以连空闲设备也能查。这是本模块敢每轮扫全量的前提,
也是这条只读路径与"空闲设备不主动 connect"红线(DEVELOPMENT.md §2 #3)的边界,
已写进文档免得后人误加 connect。

- core/device_battery.py(新):后台 daemon 线程 `device-battery`(照 device_discovery
  的 init_app/shutdown 模式),默认 60s 一轮,采「设备池 ∩ 在线」(与 list_ready 同口径,
  adb 可见但不归平台管的设备不上屏也不告警)。
  · 结果**只放内存**(不落库 → 不建表、不动备份覆盖清单),离线设备保留最后读数;
  · 读失败不清缓存:list_devices 失败时若照清会把整份缓存抹掉、大屏全体变「—」;
  · 配置存 `app_meta.device_battery`(照 step_defaults:出厂值 + 白名单 + 范围钳制 +
    只落与出厂值不同的字段),改阈值不用重启(线程每轮重读配置)。
- 告警:事件 `device.battery.low` / `device.battery.recovered`(默认关,去通知页勾订阅)。
  **按档位做状态沿**(0 正常 / 1 低 / 2 严重):掉档才报、严重再报一次、恢复报一次;
  迟滞 +5% 避免在阈值上下反复告警;同档位每 6h 重复提醒一次。
  "充电中"看的是 `status:` 行(2/5),**不是"插着电"**——所以"插着却没充电"
  (劣质线/温控/满电停充 status:4)仍会告警,那正是最该知道的情况(实测 fleet 里
  就有一台 8% 插着 AC 但 `Max charging current: 0`)。
- GET /api/status 每台设备多一个 `battery`:`{level,charging,at,tier}`。
  **tier 由后端算好**,前端只按它上色——阈值只在「工具 → 设备发现 → 电量监控」一处定义,
  免得 JS 里再判一遍、两边不一致。
- 前端:大屏卡片型号行右侧加电量徽标(绿/黄/红 + ⚡ 充电中,离线置灰),
  `cardHtml`/`renderGrid` 增量更新/脏检查白名单三处都改了(漏一处就不刷新);
  监控页设备表新增可排序的「电量」列(空表 colspan 11 顺带对齐了)。
- 接口:GET/POST /api/devices/battery[/settings] + POST /scan(需设备权限,
  与 /api/devices/discovery/* 同权限档)。

自测:单元 30 项(解析真机输出/档位/状态机/钳制,含 scale=255、无 status 行、
垃圾输出等边界)、集成 27 项(临时库自建设备池:缓存/通知/离线保留/删设备清理/
配置往返/阈值改动即时生效)、真实 11 台设备一轮 0.44s 全读到、API 端到端
(登录/权限/钳制/越界/REST 往返/页面 DOM)、两份前端各自语法检查 + 渲染用例。
2026-09-24 08:37:42 +08:00
33 changed files with 1888 additions and 80 deletions
+8 -5
View File
@@ -45,12 +45,14 @@
|------|------|
| **多设备并发任务** | 每设备一个 Worker 线程;同一设备同时只跑一个任务(可配置"抢占"打断其他任务) |
| **设备池** | SQLite 清单 + adb 在线状态;支持手工添加、网段自动发现、一键重连、型号采集、启用/停用 |
| **电量监控** | 后台每分钟 `dumpsys battery`(只读)采一次电量,**大屏卡片**与**监控页设备列表**都显示(按档位着色 + ⚡ 充电中);低于阈值推 webhook 告警(**充电中不报、"插着却没充电"照报**,掉档/恢复各报一次,不刷屏)。电量只放内存不落库(见 [NOTIFY.md](doc/NOTIFY.md) §3.1) |
| **任务调度** | 手动 / cron 定时 / 定时启停;运行窗口;失败重试(含端口耗尽类的长退避) |
| **步骤编辑器** | 可视化拖拽编排 21 种步骤(含循环/条件/OCR/通知/录制回放),可打包成"自定义动作"复用 |
| **步骤编辑器** | 可视化拖拽编排 22 种步骤(含循环/条件/OCR/通知/录制回放),可打包成"自定义动作"复用 |
| **拟人化操作** | 滑动默认走弧线轨迹、位置/幅度/时长每次抖动,且**每台设备有自己的手速与习惯**(同设备风格稳定、设备之间明显不同)——批量跑时不像同步机器人(见 [TASK_DEV.md](doc/TASK_DEV.md) §8.4) |
| **录制回放** | 「录制手势」步骤 = **纯录制**:**用手指在真机上划**(读 getevent 真触屏)或在网页画面上拖,把轨迹点列原样存下来,回放时**照原路径与时长重放**——不套滑动那套方向/幅度/拟人参数 |
| **动作配置 / 录制** | 「任务 → 动作配置」:① 配**步骤默认值**(新建步骤预填:滑动时长/幅度/抖动/拟人、点击超时、等待区间…)② **录制动作**——在设备画面上点/划/按键,自动翻译成步骤(点元素记选择器、划一下记方向/幅度/时长),可存成可复用动作,也可只取一个手势回填滑动步骤 |
| **公共巡检** | 任务级的守护条件(**独立于步骤画布**):每 N 秒检查屏幕亮/熄、元素在不在、前台是不是某 App,命中就点亮/息屏/停本设备/推通知——通知标题正文自己写(见 [TASK_DEV.md](doc/TASK_DEV.md) §4.5) |
| **去重账本** | 解决"**同一个号被做两次、有的号还没做**":任务里用「条件判断→去重」+「记为已做」,把"这个身份做过了"记进一张**所有设备共享**的账本——多台手机、反复重跑都不会重复做同一个号。「任务 → 去重记录」页能看"谁做过了、还差谁",也能删记录让它重跑(见 [TASK_DEV.md](doc/TASK_DEV.md) §4.6) |
| **元素抓取** | 拉取设备 UI 元素树 → 点选回填选择器;支持"点一下"与"测选择器"真机验证 |
| **实时看屏** | MJPEG 实时流 + 点击/滑动/按键/文字输入;全屏监控大屏(`/wall`) |
| **应用管理** | APK 上传/解析/批量安装;设备已装应用与版本查询;剪贴板注入 |
@@ -136,6 +138,7 @@ auto_control/
│ ├── task_manager.py # 调度器:分组/任务/APScheduler/重试/运行控制
│ ├── adb_helper.py # adb 命令封装(全局锁,红线:绝不 kill-server)
│ ├── device_discovery.py # 网段扫描发现设备 → 待连接池
│ ├── device_battery.py # 设备电量采集(dumpsys 只读)+ 低电量告警
│ ├── u2_helper.py # uiautomator2 通用辅助(等首页/安全点击等)
│ ├── uiauto_helper.py # uiautodev 客户端(元素树 + XPath 建议)
│ ├── ocr.py # 屏幕 OCR(RapidOCR,条件判断用)
@@ -155,7 +158,7 @@ auto_control/
├── tasks/ # 任务定义层
│ ├── base.py # BaseTask + _TASK_TYPES + register_task
│ └── generic/ # 通用步骤任务(task_type=generic_steps,当前唯一类型)
│ └── task.py # STEP_TYPES(21 种步骤)+ Worker + 执行器
│ └── task.py # STEP_TYPES(22 种步骤)+ Worker + 执行器
│
├── mcp_server/ # MCP Server(20 个 de_* 工具,:8033)
│ ├── mcp_server.py # 工具定义 + 平台登录 + 门控
@@ -281,14 +284,14 @@ self.set_progress(done=5, total=80, unit="视频", action_counts={"like": 3}, el
`generic_steps` 的执行内容全在 `params.steps`(JSON 数组),由步骤编辑器产出。**没有默认步骤**——空步骤任务执行时会明确报错。
**21 种步骤**(完整参数见 [doc/TASK_DEV.md](doc/TASK_DEV.md)):
**22 种步骤**(完整参数见 [doc/TASK_DEV.md](doc/TASK_DEV.md)):
| 类别 | 步骤 |
|------|------|
| 屏幕 | `screen_on` 亮屏 · `screen_off` 息屏 · `keep_screen` 保持亮屏 |
| 应用与输入 | `open_app` 打开 App · `stop_app` 结束 App · `input_text` 输入文字 · `clipboard` 剪贴板注入 · `key_event` 按键 |
| 交互 | `click` 点击元素 · `click_xy` 点击坐标 · `long_click` 长按 · `swipe` 滑动 · `swipe_until` 滑动直到元素出现 · `wait_el` 等待元素 · `wait` 等待时长 |
| 流程 | `loop` 循环块 · `group` 动作组 · `if_el` 条件判断(元素或 **OCR 识别**) |
| 流程 | `loop` 循环块 · `group` 动作组 · `if_el` 条件判断(元素 / **OCR 识别** / 屏幕状态 / 前台App / **去重**)· `mark_done` 记为已做(配套去重) |
要点:
@@ -403,7 +406,7 @@ read_log_text()`,`file` 参数走白名单,不接受任意路径。
| [doc/ARCHITECTURE.md](doc/ARCHITECTURE.md) | 架构详解(分层、装配顺序、线程模型、设备生命周期、调度链路、设计决策) |
| [doc/DATA_MODEL.md](doc/DATA_MODEL.md) | 数据模型(表结构、迁移、`app_meta`、数据目录、备份覆盖清单) |
| [doc/API.md](doc/API.md) | HTTP 接口全量说明 |
| [doc/TASK_DEV.md](doc/TASK_DEV.md) | 任务与步骤开发(21 种步骤、公共巡检、录制回放、选择器、自定义动作) |
| [doc/TASK_DEV.md](doc/TASK_DEV.md) | 任务与步骤开发(22 种步骤、公共巡检、录制回放、去重、选择器、自定义动作) |
| [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/DEPLOY.md](doc/DEPLOY.md) | 部署与运维(含生产容器、备份导入、故障排查) |
+235
View File
@@ -0,0 +1,235 @@
"""「已做过」账本:跨设备幂等(评论不重复)+ 进度可见。
**它解决什么**(用户场景):一台手机登录多个账号、多台手机跑同一个任务,
每天跑一次但不知道什么时候跑完,于是反复重跑 → 同一个号被做两次、有的号还没做。
账本给出两个东西:
1. `check()` —— "这个身份在这个任务里做过了吗"(跨设备,多台手机共享同一份判断)
2. `list_marks()` —— "谁做过了、还差谁"(界面上看得见,就不用靠"一直重跑"来确认)
**为什么必须靠唯一索引**:多台设备可能同时判断"没做过"。
"先 SELECT 再 INSERT"有竞态——两台都会插进去。唯一索引 + `INSERT ... ON DUPLICATE KEY`
(MySQL)/ `INSERT OR IGNORE`(SQLite)的**受影响行数**才是原子的判据:
`1` = 你抢到了首次,`0` = 别人已经做过。
**身份值读不到时绝不去重**(`identity is None`):宁可漏拦一次,
也不能让"读不到"退化成"空身份"——那会让所有设备共用一个 key,
第一台记账后其余全部被误跳过。同理,身份值过长时也不截断(截断会让两个身份撞车)。
有效期策略(`kind`)由任务级参数 `dedup_reset` 决定,**只在一处配**:
步骤里各填一份的话,两处填不一致就会算出不用的 key、去重静默失效。
DB 访问由本模块自建 app context(照 `core/device_pool.py` 的 `_ctx()`),
调用方(任务线程 / Web 线程 / 每日清理)都不用关心。
"""
import time
from datetime import datetime, timedelta
from core.logger import get_logger
from core.models import db
_log = get_logger("core.dedup")
# 有效期策略
KIND_DAY = "day" # 每天一次(默认;"每天跑一次"的场景)
KIND_HOURS = "hours" # 每 N 小时一次
KIND_ALL = "all" # 只做一次(永久)
RESET_KINDS = (KIND_DAY, KIND_ALL, KIND_HOURS)
DEFAULT_HOURS = 6
# 账本保留期:只影响 day/hours 桶(它们过期就没意义了);
# **kind='all' 的记录永不清理**——清了就等于"只做一次"失效
KEEP_DAYS = 180
# scope_key 上限(= DoneMark.scope_key 列宽)。超了不去重、只告警:
# 截断会让两个不同身份撞成同一个 key,进而误跳过
MAX_KEY_LEN = 290
_app = None
def init_app(app):
"""web_server 启动时调用:绑 app(后台线程访问 db 要自推 context)。"""
global _app
_app = app
def _ctx():
if _app is None:
raise RuntimeError("dedup 未关联 Flask app(web_server 启动时调用 init_app)")
return _app.app_context()
# ================== 唯一键 ==================
def build_key(job_id, identity, kind=KIND_DAY, hours=DEFAULT_HOURS, now=None):
"""算唯一键:`任务 | 身份 | 时间桶`。
桶只影响"多久之后算新的一轮":
· day → `|d:2026-09-24`(本地日期,跟着自然日走)
· hours → `|h6:4939219`(epoch // (N*3600),改 N 就换了桶)
· all → `|all`(无桶,永远命中同一个 key)
"""
now = time.time() if now is None else now
base = f"{(job_id or '').strip()}|{(identity or '').strip()}"
if kind == KIND_ALL:
return base + "|all"
if kind == KIND_HOURS:
h = max(1, int(hours or DEFAULT_HOURS))
return f"{base}|h{h}:{int(now // (h * 3600))}"
return base + "|d:" + time.strftime("%Y-%m-%d", time.localtime(now))
def _key_or_none(job_id, identity, kind, hours):
"""算 key 并做长度体检;超长返回 None(调用方按"不去重"处理,不截断)。"""
if not (identity or "").strip():
return None
key = build_key(job_id, identity, kind, hours)
if len(key) > MAX_KEY_LEN:
_log.warning(f"去重键过长({len(key)} 字符)→ 本次不去重,避免截断后两个身份撞车: {key[:80]}…")
return None
return key
# ================== 查 / 记 ==================
def check(job_id, identity, kind=KIND_DAY, hours=DEFAULT_HOURS):
"""这个身份在这个任务里做过了吗?(只读)
返回 True=做过 / False=没做过。identity 为空时返回 False(不去重,照常执行)。
"""
key = _key_or_none(job_id, identity, kind, hours)
if key is None:
return False
try:
with _ctx():
row = db.session.execute(
db.text("SELECT 1 FROM done_mark WHERE scope_key = :k LIMIT 1"),
{"k": key}).fetchone()
return row is not None
except Exception as e:
# 账本读失败不能拦住任务:当"没做过"放行(宁可重复,不可卡死)
_log.warning(f"去重检查失败(按未做过放行): {e}")
return False
def mark(job_id, identity, kind=KIND_DAY, hours=DEFAULT_HOURS,
serial="", device_name="", job_name=""):
"""原子记账。返回 True=本次是首次(你抢到了)/ False=别人已经记过(或没记成)。
⚠ **必须靠唯一索引**:不能先 check 再 insert(多台设备会同时通过)。
"""
key = _key_or_none(job_id, identity, kind, hours)
if key is None:
return False
args = {"k": key, "kind": kind, "jid": job_id or "", "jname": job_name or "",
"serial": serial or "", "dname": device_name or "",
"ident": (identity or "").strip()[:200],
"ts": time.strftime("%Y-%m-%d %H:%M:%S")}
try:
with _ctx():
is_mysql = db.engine.dialect.name == "mysql"
cols = ("scope_key, kind, job_id, job_name, serial, device_name, "
"identity, created_at")
vals = (":k, :kind, :jid, :jname, :serial, :dname, :ident, :ts")
if is_mysql:
# 冲突时做一次"无变化"的更新:受影响行数 1=插入、0=已存在(MySQL 语义)
sql = (f"INSERT INTO done_mark ({cols}) VALUES ({vals}) "
f"ON DUPLICATE KEY UPDATE scope_key = scope_key")
else:
sql = f"INSERT OR IGNORE INTO done_mark ({cols}) VALUES ({vals})"
r = db.session.execute(db.text(sql), args)
db.session.commit()
first = (r.rowcount or 0) > 0
if first:
_log.info(f"[{serial}] 记账:任务『{job_name or job_id}』身份『{args['ident']}』"
f"({kind})")
return first
except Exception as e:
_log.warning(f"去重记账失败(本次不记,下次重跑会重试): {e}")
return False
# ================== 界面:看 / 清 ==================
def list_marks(job_id="", limit=200):
"""去重记录(界面用):返回 (rows, stats)。
stats:`total` 本任务全部 · `today_devices` 今天已做的设备数 ·
`today_identities` 今天已做的身份值数(== "今天做成了几个号")。
"""
limit = max(1, min(int(limit or 200), 2000))
today0 = time.strftime("%Y-%m-%d 00:00:00")
where, args = "", {}
if job_id:
where = "WHERE job_id = :jid"
args["jid"] = job_id
with _ctx():
rows = db.session.execute(db.text(
f"SELECT id, scope_key, kind, job_id, job_name, serial, device_name, "
f"identity, created_at FROM done_mark {where} "
f"ORDER BY id DESC LIMIT :lim"), {**args, "lim": limit}).fetchall()
twhere = where + (" AND " if where else "WHERE ") + "created_at >= :t0"
stat = db.session.execute(db.text(
f"SELECT COUNT(*) AS total, "
f"COUNT(DISTINCT serial) AS devs, "
f"COUNT(DISTINCT identity) AS ids "
f"FROM done_mark {where}"), args).fetchone()
today = db.session.execute(db.text(
f"SELECT COUNT(DISTINCT serial) AS devs, "
f"COUNT(DISTINCT identity) AS ids "
f"FROM done_mark {twhere}"), {**args, "t0": today0}).fetchone()
out = [{"id": r[0], "scope_key": r[1], "kind": r[2], "job_id": r[3], "job_name": r[4],
"serial": r[5], "device_name": r[6], "identity": r[7], "created_at": r[8]}
for r in rows]
stats = {"total": stat[0] if stat else 0,
"devices": stat[1] if stat else 0,
"identities": stat[2] if stat else 0,
"today_devices": today[0] if today else 0,
"today_identities": today[1] if today else 0}
return out, stats
def delete_mark(mark_id):
"""删一条记录(让某个号/某台设备能重跑)。返回是否删掉了。"""
try:
with _ctx():
r = db.session.execute(db.text("DELETE FROM done_mark WHERE id = :i"),
{"i": int(mark_id)})
db.session.commit()
return (r.rowcount or 0) > 0
except Exception as e:
_log.warning(f"删除去重记录失败: {e}")
return False
def clear_job(job_id):
"""清空某个任务的全部去重记录(整批重跑)。返回删掉的行数。"""
try:
with _ctx():
r = db.session.execute(db.text("DELETE FROM done_mark WHERE job_id = :j"),
{"j": job_id or ""})
db.session.commit()
n = r.rowcount or 0
_log.info(f"清空去重记录:任务 {job_id} 共 {n} 条")
return n
except Exception as e:
_log.warning(f"清空去重记录失败: {e}")
return 0
def purge_old(keep_days=KEEP_DAYS):
"""清理过期的 day/hours 桶记录。**kind='all' 永不清理**(清了等于去重失效)。
量级很小(设备数 × 天数),直接一条 DELETE 就行,不用像步骤明细那样分批。
"""
cutoff = (datetime.now() - timedelta(days=max(1, int(keep_days)))
).strftime("%Y-%m-%d %H:%M:%S")
try:
with _ctx():
r = db.session.execute(db.text(
"DELETE FROM done_mark WHERE kind <> :all AND created_at < :cut"),
{"all": KIND_ALL, "cut": cutoff})
db.session.commit()
n = r.rowcount or 0
if n:
_log.info(f"去重记录清理:删除 {n} 条(保留 {keep_days} 天;"
f"kind={KIND_ALL} 的永不清理)")
return n
except Exception as e:
_log.warning(f"去重记录清理失败: {e}")
return 0
+404
View File
@@ -0,0 +1,404 @@
"""设备电量采集 + 低电量告警(大屏/监控展示 + webhook 通知)。
设计要点(都是被现有代码/实测约束逼出来的,改之前先读一遍):
- **只读**:`adb -s <serial> shell dumpsys battery`(实测 0.2~0.35s/台)。
全模块不 connect / 不 disconnect / 不 kill-server(技术红线,见 doc/DEVELOPMENT.md)。
能查到的前提是设备已在 adb server 里(= device_pool.list_online()),
所以连"空闲设备"也能查——**不需要 connect**,这是本模块敢每轮扫全量的原因。
- **后台独立线程**采集(照 core/device_discovery.py 的 daemon 循环):`/api/status`
只读内存缓存。绝不能把 dumpsys 放进 /api/status——前端 5 秒轮询一次,
14 台设备同步查会把 Flask 拖死(`get_status` 的 5s 缓存注释也写了这条)。
- **不落库**:电量是易变值,重启重新采一轮即可 → 不建表、**不动备份覆盖清单**。
- 告警走 notifier(`device.battery.low` / `device.battery.recovered`),
**按档位做状态沿检测**(正常/低/严重),掉档才报,绝不满屏刷。
- 配置存 `app_meta.device_battery` 一个键(照 core/step_defaults.py 的做法:
出厂默认 + 字段白名单 + 只落"与出厂值不同"的字段)。
"""
import json
import re
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from config import USB_ADB_HOST, USB_ADB_PORT
from core import notifier, device_pool
from core.adb_helper import _adb, _adb_remote
from core.logger import get_logger
from core.models import db
_log = get_logger("core.battery")
# 出厂默认(前端「工具 → 设备发现 → 电量监控」的初始内容,也是没配过时的回退)
FACTORY = {
"enabled": True,
"low": 20, # 低电量阈值(%)
"critical": 10, # 严重低电量阈值(%)——通知等级升到 error
"skip_charging": True, # 充电中不告警(插着充电器说明正在补电)
"interval": 60, # 采集间隔(秒)
}
# 字段规格:("num", 最小, 最大) / ("bool",)。白名单之外的一律丢弃。
SPEC = {
"enabled": ("bool",),
"low": ("num", 1, 100),
"critical": ("num", 1, 100),
"skip_charging": ("bool",),
"interval": ("num", 30, 3600),
}
META_KEY = "device_battery"
# 恢复迟滞:掉到阈值下之后,要回升到 low+HYSTERESIS 才算"恢复"。
# 没有它的话,电量在阈值上下浮动(充电器接触不良)会反复告警。
HYSTERESIS = 5
# 持续低电量的重复提醒间隔(秒)。0 = 只提醒一次(等恢复后才重新武装)。
RE_NOTIFY_S = 6 * 3600
# 单台设备读取超时(秒)。正常 0.2~0.35s,8s 已经非常宽松。
_READ_TIMEOUT = 8
_app = None
_cache = {} # serial -> {"level": int, "charging": bool, "at": ts}
_tiers = {} # serial -> {"tier": int, "at": ts} 告警档位状态(0 正常/1 低/2 严重)
_lock = threading.Lock()
_stop_event = threading.Event()
_scanning = False
_last_scan = None # (时间串, 采集成功数, 在线数)
def _ctx():
"""后台线程访问 db 时自行推 app context(仿 device_pool._ctx)。"""
return _app.app_context() if _app else None
# ================== 配置读写 ==================
def factory():
"""出厂默认的拷贝(别让调用方改到常量)。"""
return dict(FACTORY)
def _clean(raw):
"""按 SPEC 校验清洗;非法字段**丢弃**(不让脏值进库)。"""
if not isinstance(raw, dict):
return {}
out = {}
for k, rule in SPEC.items():
if k not in raw:
continue
v = raw[k]
try:
if rule[0] == "bool":
out[k] = bool(v)
elif rule[0] == "num":
out[k] = int(round(max(rule[1], min(rule[2], float(v)))))
except (TypeError, ValueError):
continue
return out
def get_settings():
"""当前生效配置(读库;模块未初始化时回退出厂值,别让调用方炸)。"""
ctx = _ctx()
if ctx is None:
return factory()
try:
from core.db_config import meta_get
with ctx:
raw = meta_get(META_KEY) or ""
if not raw:
return factory()
return _merge(json.loads(raw))
except Exception:
return factory()
def _merge(user):
"""出厂默认 + 用户覆盖(逐字段合并 + 钳制)。"""
vals = dict(FACTORY)
vals.update(_clean(user))
# 严重阈值不能高于低电量阈值,否则"严重"永远先触发,语义反了
if vals["critical"] > vals["low"]:
vals["critical"] = vals["low"]
return vals
def save_settings(payload):
"""保存配置。返回 (ok, msg, saved)。
与出厂默认**相同的字段不入库**(照 step_defaults):以后调出厂值时,
没配过的字段能跟着更新,库里只留"用户真的改过"的部分。
"""
vals = _merge(payload)
diff = {k: v for k, v in vals.items() if FACTORY.get(k) != v}
ctx = _ctx()
if ctx is None:
return False, "电量模块未初始化", {}
try:
from core.db_config import meta_set
with ctx:
meta_set(META_KEY, json.dumps(diff, ensure_ascii=False))
db.session.commit()
except Exception as e:
return False, f"保存失败: {e}", {}
_log.info(f"电量监控配置已保存: {vals}")
return True, "已保存", vals
# ================== 单台读取与解析 ==================
_BAT_LEVEL_RE = re.compile(r"^\s*level:\s*(\d+)", re.M)
_BAT_SCALE_RE = re.compile(r"^\s*scale:\s*(\d+)", re.M)
_BAT_STATUS_RE = re.compile(r"^\s*status:\s*(\d+)", re.M)
# 部分 ROM 没有 status 行时的兜底:插着电就算充电中
_POWERED_RE = re.compile(r"^\s*(?:AC|USB|Wireless) powered:\s*true", re.M)
def _parse_battery(text):
"""解析 `dumpsys battery` 输出,返回 (level, charging) 或 None。
status: 2=充电中 5=已充满 → charging;3=放电 4=未充电(插着但不充,
例如劣质线/温控/满电停充)→ 不算充电中。所以"插着却掉电"仍然会告警,
这正是最该让人知道的情况。
"""
if not text or "level:" not in text:
return None
m = _BAT_LEVEL_RE.search(text)
if not m:
return None
level = int(m.group(1))
ms = _BAT_SCALE_RE.search(text)
scale = int(ms.group(1)) if ms else 100
if scale > 0 and scale != 100:
level = int(round(level * 100.0 / scale))
st = _BAT_STATUS_RE.search(text)
if st:
charging = int(st.group(1)) in (2, 5)
else:
charging = bool(_POWERED_RE.search(text))
return max(0, min(100, level)), charging
def _read_battery(serial):
"""读一台设备的电量,返回 (level, charging) 或 None。
与 device_pool.refresh_model 同思路:IP:5555 设备走本机 adb server;
USB 设备本机看不到,再走 220 远程 adb server 兜底。
"""
out = ""
try:
out = _adb("-s", serial, "shell", "dumpsys", "battery", timeout=_READ_TIMEOUT)
except Exception:
out = ""
if not out and ":" not in serial:
try:
out = _adb_remote(USB_ADB_HOST, USB_ADB_PORT, "-s", serial,
"shell", "dumpsys", "battery")
except Exception:
out = ""
return _parse_battery(out)
# ================== 告警(档位状态沿) ==================
def _tier_of(level, charging, spec, prev):
"""电量档位:0 正常 / 1 低 / 2 严重。
充电中(且配了「充电中不告警」)直接算 0——正在补电就不用管它。
回升要越过 low+HYSTERESIS 才回 0,避免在阈值上下反复告警。
"""
if spec["skip_charging"] and charging:
return 0
if level <= spec["critical"]:
return 2
if level <= spec["low"]:
return 1
if level >= spec["low"] + HYSTERESIS:
return 0
return prev # 迟滞带内:维持原档位(不报也不恢复)
def _check_alerts(serial, level, charging, spec, dname, model):
"""比上一轮档位,决定是否发通知。只在档位变化/到重复提醒点时发。"""
now = time.time()
with _lock:
st = _tiers.setdefault(serial, {"tier": 0, "at": 0.0})
prev = st["tier"]
tier = _tier_of(level, charging, spec, prev)
# 掉档(正常→低→严重)立刻报;同一档位停留太久到重复提醒间隔再报一次
fire = tier > prev or (tier > 0 and now - st["at"] >= RE_NOTIFY_S)
if fire:
st["tier"], st["at"] = tier, now
recovered = prev > 0 and tier == 0
if recovered:
st["tier"], st["at"] = 0, now
if fire:
notifier.notify(
"device.battery.low", serial=serial, device_name=dname, model=model,
battery=level, charging=charging,
threshold=spec["critical"] if tier == 2 else spec["low"],
level="error" if tier == 2 else "warning")
if recovered:
notifier.notify(
"device.battery.recovered", serial=serial, device_name=dname,
model=model, battery=level, charging=charging, threshold=spec["low"])
# ================== 一轮全量采集 ==================
def _scan_all():
"""采集池内所有在线设备的电量(后台线程执行)。
读失败的设备(离线/超时)**保持上一次的读数不动**——大屏上显示的是
"最后一次知道的电量",比直接变成空白有用。
"""
global _scanning, _last_scan
if _scanning:
return
_scanning = True
try:
spec = get_settings()
try:
online = list(device_pool.list_online())
except Exception as e:
_log.warning(f"电量采集: 获取在线设备失败: {e}")
return
if not online:
_last_scan = (time.strftime("%H:%M:%S"), 0, 0)
return
# 设备名/型号:通知里要显示「A08(Redmi K40)」而不是一串 IP
names, models, known = {}, {}, set()
try:
for d in device_pool.list_devices():
s = d.get("serial") or ""
if not s:
continue
known.add(s)
names[s] = d.get("name") or ""
models[s] = d.get("model") or ""
except Exception:
pass
# 只采集「设备池 ∩ 在线」:平台管的是设备池(与 list_ready 同口径),
# 池外的设备不上大屏、也不告警——adb 里能看到但不归平台管的设备不该吵人。
# known 为空 = 设备表没读到(不能因此整轮不采),退回按在线设备采。
targets = [s for s in online if s in known] if known else list(online)
if not targets:
_last_scan = (time.strftime("%H:%M:%S"), 0, 0)
return
results = {}
try:
with ThreadPoolExecutor(max_workers=min(10, len(targets))) as pool:
futures = {pool.submit(_read_battery, s): s for s in targets}
for fut in as_completed(futures, timeout=_READ_TIMEOUT + 15):
s = futures[fut]
try:
results[s] = fut.result()
except Exception:
results[s] = None
except Exception as e:
_log.warning(f"电量采集: 部分设备读取超时(保留已取到的): {e}")
now = time.time()
with _lock:
for s, r in results.items():
if r:
_cache[s] = {"level": r[0], "charging": r[1], "at": now}
# 设备已从池里删除的,连缓存一起清掉(避免大屏上冒出幽灵设备)。
# ⚠️ 只在真的读到设备清单时才清理:list_devices 失败时 known 是空的,
# 照清下去会把整份缓存抹掉、大屏全体变「—」。
if known:
for s in [k for k in _cache if k not in known]:
_cache.pop(s, None)
_tiers.pop(s, None)
for s, r in results.items():
if r:
_check_alerts(s, r[0], r[1], spec, names.get(s, ""), models.get(s, ""))
ok = sum(1 for r in results.values() if r)
_last_scan = (time.strftime("%H:%M:%S"), ok, len(targets))
_log.info(f"电量采集完成: {ok}/{len(targets)} 台(池内在线;adb 可见 {len(online)} 台)")
except Exception as e:
_log.warning(f"电量采集异常: {e}")
finally:
_scanning = False
def scan_once():
"""手动触发一轮采集(后台线程执行,不阻塞调用者)。返回是否已启动。
不看 enabled 开关:用户点了「立即采集」就应该采,开关只管定时循环。
"""
if _scanning:
return False
threading.Thread(target=_scan_all, name="battery-scan-once", daemon=True).start()
return True
# ================== 对外读取 ==================
def get(serial):
"""设备电量快照(供 /api/status 组装)或 None。
结构 {"level": int, "charging": bool, "at": ts, "tier": 0|1|2}。
**带上 tier 是刻意的**:前端只按档位上色,不在 JS 里重算阈值——
否则「工具 → 设备发现」改了阈值,大屏/监控页的判定就和告警对不上了。
"""
with _lock:
v = _cache.get(serial)
if not v:
return None
out = dict(v)
st = _tiers.get(serial)
out["tier"] = st["tier"] if st else 0
return out
def get_status():
"""采集器状态(面板显示用)。"""
with _lock:
cached = len(_cache)
return {
"settings": get_settings(),
"scanning": _scanning,
"last_scan": _last_scan,
"cached": cached,
}
# ================== 定时采集线程 ==================
def _battery_loop():
"""定时采集 daemon 线程。每轮重读配置(开关/间隔即时生效)。
启动先等 20s:让开 web_server 的预连接与型号采集(都抢 adb)。
等待用 15s 切片,关停/改间隔 ≤15s 生效。
"""
try:
_stop_event.wait(20)
while not _stop_event.is_set():
interval = 60
try:
spec = get_settings()
interval = max(30, int(spec.get("interval", 60)))
if spec.get("enabled"):
_scan_all()
except Exception as e:
_log.warning(f"电量定时采集异常: {e}")
waited = 0
while waited < interval and not _stop_event.is_set():
_stop_event.wait(min(15, interval - waited))
waited += min(15, interval - waited)
except Exception:
pass
def init_app(app):
"""web_server 启动时调用:绑 app + 起定时采集 daemon 线程。"""
global _app
_app = app
_stop_event.clear()
t = threading.Thread(target=_battery_loop, name="device-battery", daemon=True)
t.start()
_log.info("设备电量采集线程已启动(默认 60s 一次)")
def shutdown():
"""优雅退出:置 stop_event,等待中的循环在切片边界退出。"""
_stop_event.set()
+51
View File
@@ -39,13 +39,59 @@ def init_app(app):
并后台刷新一次设备型号(首次启动/设备更换后型号可能变化)。"""
global _app
_app = app
refresh_names() # 同步刷一次名称快照,让通知从第一条起就有名字
try:
t = threading.Thread(target=_refresh_models_bg, daemon=True)
t.start()
t2 = threading.Thread(target=_names_loop, name="device-names", daemon=True)
t2.start()
except Exception:
pass
# ================== 名称内存快照(给通知用) ==================
# 为什么单独存一份:webhook 通知里要显示"是哪台设备"(A08)而不是地址(IP),
# 但 `notifier.notify()` 有一条硬红线——**零 DB 访问**(不能为了取个名字去查库,
# 更不能在业务线程里阻塞)。所以由这里维护一份 serial→名称 的内存快照:
# 启动刷一次、池子有变动时刷一次、再兜底每 60s 刷一次(覆盖整库恢复等外部改动)。
_names = {}
_names_lock = threading.Lock()
_NAMES_INTERVAL = 60
def refresh_names():
"""把设备池的 serial→名称刷进内存快照(只查库,不碰 adb)。返回条数。"""
try:
rows = list_devices()
except Exception as e:
_log.debug(f"刷新设备名快照失败: {e}")
return 0
m = {}
for d in rows:
s = d.get("serial") or ""
if s:
m[s] = d.get("name") or ""
with _names_lock:
_names.clear()
_names.update(m)
return len(m)
def name_of(serial):
"""serial 对应的设备名称(**纯内存,可在通知路径上调用**)。没有则返回 ""。"""
if not serial:
return ""
with _names_lock:
return _names.get(str(serial), "")
def _names_loop():
"""兜底定时刷新名称快照(覆盖整库恢复这类进程外改动)。"""
while True:
time.sleep(_NAMES_INTERVAL)
refresh_names()
def _refresh_models_bg():
"""后台批量采集在线设备型号(启动时/手动触发)。失败静默,不影响启动。"""
time.sleep(3) # 等服务起来再查
@@ -183,6 +229,7 @@ def _move_device_row(old, new_serial, fingerprint=""):
if fingerprint:
old.fingerprint = fingerprint
db.session.commit()
refresh_names() # 键变了(serial → 名称的映射也跟着变)
if _move_hook is not None:
try:
_move_hook(old_serial, new_serial)
@@ -255,11 +302,13 @@ def add_device(serial, name="", note="", enabled=True, fingerprint=""):
if fingerprint:
d.fingerprint = fingerprint
db.session.commit()
refresh_names() # 名字可能改了:通知里要立刻用新名字
return False
db.session.add(Device(serial=serial, name=name or "", note=note or "",
enabled=enabled, fingerprint=fingerprint or "",
created_at=time.strftime("%Y-%m-%d %H:%M")))
db.session.commit()
refresh_names()
_log.info(f"设备池新增设备: {serial}")
return True
@@ -274,6 +323,7 @@ def rename_device(serial, name):
old = d.name or ""
d.name = name
db.session.commit()
refresh_names()
_log.info(f"设备池重命名: {old or serial} → {name}({serial})")
return True
@@ -301,6 +351,7 @@ def remove_device(serial):
return False
db.session.delete(d)
db.session.commit()
refresh_names()
_log.info(f"设备池删除设备: {serial}")
return True
+34
View File
@@ -403,6 +403,39 @@ class TaskStepLog(db.Model):
)
class DoneMark(db.Model):
"""「已做过」账本:跨设备幂等的标记(「任务 → 去重记录」页)。
要解决的问题(用户场景):一台手机登录多个账号、多台手机跑同一个任务,
任务被反复重跑(因为不知道什么时候跑完)→ 同一个号被做两次、有的号还没做。
**判据只有一条:`scope_key` 的唯一索引。**
多台设备可能同时判断"没做过","先查后插"会两台都插进去;
唯一索引 + `INSERT ... ON DUPLICATE KEY`/`INSERT OR IGNORE` 的**受影响行数**
才是原子的(见 `core/dedup.py` 的 `mark()`)。
写入方是任务步骤 `_exec_mark_done`(**成功之后才记账**):动作失败就不记账,
下次重跑还会重试该设备——这是"失败不丢"的关键。
`kind` 是有效期策略(`day`/`hours`/`all`),清理时**只删 day/hours**:
`all` 代表"只做一次",删掉就等于去重失效。
"""
__tablename__ = "done_mark"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
scope_key = db.Column(db.String(300), unique=True) # 幂等的全部依据(唯一索引)
kind = db.Column(db.String(12), default="day") # day / hours / all
job_id = db.Column(db.String(32), default="", index=True)
job_name = db.Column(db.String(120), default="")
serial = db.Column(db.String(120), default="")
device_name = db.Column(db.String(80), default="")
identity = db.Column(db.String(200), default="") # 身份值(如抖音号)
created_at = db.Column(db.String(20), default="", index=True)
__table_args__ = (
db.Index("ix_done_mark_job_ts", "job_id", "created_at"),
)
class AgentConversation(db.Model):
"""AI 控制台会话:整个消息序列以 JSON 存在一行里(单会话几十 KB,够用)。"""
__tablename__ = "agent_conversation"
@@ -427,6 +460,7 @@ SCHEMA_MIGRATIONS = [
(4, "自动发现:pending_device 待连接池表(扫描发现的设备,用户确认后才入正式池)", None),
(5, "设备池:device 表新增 fingerprint 列(设备指纹 ro.serialno,换 IP 后认领回原记录)", None),
(6, "自动发现:pending_device 表新增 fingerprint 列(扫描时读取,用于提示是已有设备换了 IP)", None),
(7, "去重账本:done_mark 表(跨设备幂等的「已做过」标记,唯一索引 scope_key)", None),
]
# 当前 schema 版本(备份/恢复用它判断新旧,也写进 app_meta.schema_version)
+50
View File
@@ -334,6 +334,7 @@ _FIELD_LABELS = {
"error_msg": "错误", "count": "数量", "serials": "设备", "devices": "设备",
"pairs": "认领", "target": "目标", "selector": "选择器", "miss_count": "连续未命中",
"timeout_s": "超时(秒)", "task_job": "原任务", "model": "型号",
"battery": "电量(%)", "threshold": "告警阈值(%)", "charging": "充电中",
"apk_name": "应用", "apk_id": "应用ID", "package_name": "包名",
"success": "成功", "failed": "失败", "skipped": "跳过", "total": "总数",
"title": "标题", "message": "内容", "patrol_name": "巡检",
@@ -356,6 +357,53 @@ def _label(k):
return _FIELD_LABELS.get(k, k)
# ================== 设备名解析(让通知显示名字而不是 IP) ==================
# 由装配层注册(web_server.py):`notifier.set_device_name_resolver(device_pool.name_of)`。
# **刻意做成"纯内存回调"**:notify() 的红线是零 DB——不能为了取个名字去查库。
# 快照由 device_pool 维护(启动/池子变动/每 60s 兜底刷新)。
_device_name_fn = None
def set_device_name_resolver(fn):
"""注册 serial → 设备名 的解析器。必须是**纯内存**实现(不得查库/发请求)。"""
global _device_name_fn
_device_name_fn = fn
def _dev_name(serial):
"""尽力把地址解析成设备名;解析不到返回 ""(调用方保留原值)。"""
if not serial or _device_name_fn is None:
return ""
try:
return str(_device_name_fn(str(serial)) or "")
except Exception:
return ""
def fill_device_names(fields):
"""把 fields 里的设备地址补成设备名(**原地改**,返回同一个 dict)。
两类字段:
· `serial` → 补 `device_name`(原来没填才补)——标题主体、字段表、
聚合样本认的都是 `device_name`,补上这一处就全带名字了;
· `serials` / `devices`(多设备事件,如设备断联)→ 逐项换成名字,
通知里不该出现一串 IP。
查不到名字(设备没命名 / 已不在池里)就**保留原值**,绝不把内容弄丢。
"""
if not isinstance(fields, dict):
return fields
s = fields.get("serial")
if s and not fields.get("device_name"):
n = _dev_name(s)
if n:
fields["device_name"] = n
for k in ("serials", "devices"):
v = fields.get(k)
if isinstance(v, (list, tuple)) and v:
fields[k] = [_dev_name(x) or x for x in v]
return fields
def _subject(ev, fields):
"""标题主体:**用户自己写的 title 优先**,其次任务名,再次设备名/serial。"""
if fields.get("title"):
@@ -373,6 +421,7 @@ def _subject(ev, fields):
def build_message(ev, fields, hook=None):
"""把事件渲染成统一消息体(title/summary/fields/level/markdown)。"""
fields = dict(fields or {})
fill_device_names(fields) # 同 notify():预览/测试发送也要显示设备名
merged = fields.pop("_merged", None)
level = _level_of(ev.key, fields)
subj = _subject(ev, fields)
@@ -1007,6 +1056,7 @@ def notify(event, **fields):
for p in h["events"])]
if not hooks:
return
fill_device_names(fields) # 地址 → 设备名(纯内存快照,不查库)
for h in hooks:
keys = tuple(str(fields.get(k, "")) for k in (ev.agg_key or ()))
# 聚合窗口:事件自己声明「立即发」(agg_window=0,低频高危事件,如批次结束 /
+10
View File
@@ -128,6 +128,16 @@ EVENTS = [
_e("device.heartbeat_timeout", "心跳超时", "设备",
["serial", "device_name", "timeout_s", "task_job", "model"],
"设备卡死(长时间没心跳),任务可能已中断", agg_window=0, recommend=True),
_e("device.battery.low", "设备电量低", "设备",
["serial", "device_name", "model", "battery", "charging", "threshold"],
"设备电量掉到阈值以下(默认 20%,可在「工具 → 设备发现 → 电量监控」改)。"
"充电中的设备不报——插着充电器说明正在补电;"
"「插着却没充电」(劣质线/温控/满电停充)仍会报,那正是最该知道的情况",
agg_window=0, agg_key=_BY_SERIAL, recommend=True),
_e("device.battery.recovered", "设备电量已恢复", "设备",
["serial", "device_name", "model", "battery", "charging", "threshold"],
"低电量设备重新充上电,或电量回升到阈值以上(含恢复迟滞)", agg_window=0,
agg_key=_BY_SERIAL),
# ---------------- 应用安装 ----------------
_e("apk.install.started", "应用安装开始", "安装",
+1
View File
@@ -57,6 +57,7 @@ TABLE_LABELS = {
"agent_experience": "经验库", "experience_audit": "经验巡检",
"agent_action": "动作库", "device_install_log": "设备端安装记录",
"task_step_log": "任务步骤明细",
"done_mark": "去重记录(已做过)",
}
_STAGE_TTL = 1800 # 导入暂存有效期(秒)
+32 -4
View File
@@ -148,8 +148,10 @@ def validate_steps(steps, depth=1, path="steps", errors=None, warnings=None,
errors.append(f"{here}.params.probability: 必须是 0~100 的数字"
f"(当前 {prob!r})")
# 选择器类步骤
if stype in NEED_SELECTOR:
# 选择器类步骤(例外:if_el 用「去重」条件时不需要 selector_value,
# 它的身份元素在 ident_value 里,按自己的规则校验)
if stype in NEED_SELECTOR and not (stype == "if_el"
and params.get("selector_type") == "dedup"):
sel = params.get("selector_value")
if not isinstance(sel, str) or not sel.strip():
errors.append(f"{here}.params.selector_value: 不能为空 —— "
@@ -219,6 +221,32 @@ def validate_steps(steps, depth=1, path="steps", errors=None, warnings=None,
if isinstance(else_, list) and else_:
validate_steps(else_, depth + 1, f"{here}.params.else",
errors, warnings, types, counter)
# 文本比对(可选):运算符必须认得,且配了运算符就得有值——
# 执行器对认不出的运算符是"一律不命中",写错了会静默走 else,
# 所以在这里拦住,别让 AI 草稿悄悄变成"永远不命中"
cmp_op = (params.get("cmp_op") or "").strip()
if cmp_op:
from tasks.generic.task import CMP_OPS
if cmp_op not in CMP_OPS:
errors.append(f"{here}.params.cmp_op: 不支持的比对运算符 {cmp_op!r}"
f"(可用:{'、'.join(CMP_OPS)})")
elif not str(params.get("cmp_value") or "").strip():
errors.append(f"{here}.params.cmp_value: 选了文本比对"
f"({cmp_op})就必须填比对的值,否则永远不命中")
elif (params.get("selector_type") or "xpath") == "screen":
warnings.append(f"{here}: 屏幕状态没有文本可比,"
"cmp_op/cmp_value 会被忽略")
# 去重条件(见 tasks/generic/task.py 的 selector_type="dedup"):
# 不用 selector_value,身份元素填在 ident_value
if (params.get("selector_type") or "") == "dedup":
iv = (params.get("ident_value") or "").strip()
if not iv:
warnings.append(f"{here}: 去重条件没填身份元素,将按「设备」当身份"
"(一号一机时没问题;一台机器多个号时要填账号那个元素)")
elif (params.get("ident_type") or "text") == "xpath" \
and not (iv.startswith("//") or iv.startswith("(//")):
errors.append(f"{here}.params.ident_value: 身份元素的类型是 xpath,"
f"取值应以 // 或 (// 开头(当前 {iv[:40]!r})")
if stype == "swipe":
_check_direction(params, here, errors)
@@ -279,9 +307,9 @@ def _check_selector(params, here, stype, errors, warnings):
stype_ok = IF_SELECTOR_TYPES if stype == "if_el" else SELECTOR_TYPES
sel_type = params.get("selector_type") or "xpath"
value = (params.get("selector_value") or "").strip()
if sel_type not in stype_ok:
if sel_type not in stype_ok and not (sel_type == "dedup" and stype == "if_el"):
errors.append(f"{here}.params.selector_type: 不支持 {sel_type!r}"
f"(可用:{'、'.join(stype_ok)})")
f"(可用:{'、'.join(stype_ok + ('dedup',))})")
return
if len(value) > MAX_SELECTOR_LEN:
errors.append(f"{here}.params.selector_value: 太长(>{MAX_SELECTOR_LEN} 字符)")
+4
View File
@@ -32,6 +32,7 @@ from config import DATA_DIR
from core.logger import get_logger
from core.models import db, DeviceGroup as GroupRow, TaskJob as JobRow
from core import device_pool
from core import device_battery
from core import notifier
from .adb_helper import get_foreground_app, get_foreground_app_remote
from .device_worker import (
@@ -1122,6 +1123,9 @@ class TaskManager:
"owner": "",
"worker_status": w.get("status", "idle"),
"foreground_app": self._fg_scanner.get(serial),
# 电量(后台线程采的缓存,这里只读内存不查 adb):
# {"level":85,"charging":false,"at":ts} 或 None(还没采到)
"battery": device_battery.get(serial),
# 通用进度字段(任意 app 通用,前端统一解析展示)
# 结构:{"done": int, "total": int, "unit": str, "action_counts": dict}
"progress": w.get("progress", {}),
+5 -1
View File
@@ -184,7 +184,11 @@ mcp_agent.Agent.run_stream(prompt, serial, history, on_delta, on_tool, on_usage,
从本轮**成功**的步骤轨迹里提炼命名动作(如「打开抖音」)。硬约束:
- **禁坐标**:带 `click_xy` 的步骤不会被沉淀(坐标换个设备/分辨率就失效)
- 白名单步骤类型(21 种去掉 `click_xy`、`keep_screen`、`gesture`)
- 白名单步骤类型(**显式列举 16 种**,见 `web/agent_api.py` 的 `_ACTION_STEP_TYPES`):
`open_app` / `stop_app` / `screen_on` / `screen_off` / `key_event` / `swipe` /
`swipe_until` / `click` / `long_click` / `wait_el` / `input_text` / `clipboard` /
`wait` / `loop` / `group` / `if_el`——坐标类(`click_xy`)与流程标记类
(`keep_screen` / `gesture` / `notify` / `stop_self` / `mark_done`)都不沉淀
- 每类型有必填参数校验(如 `click` 必须有选择器)
- 输入/产出限量:最多 10 步输入、最多 3 个动作 × 4 步
- 保存时服务端**再校验一次**,含坐标的提交直接 400
+1 -1
View File
@@ -9,7 +9,7 @@
平台已有两套能力,但互不相通:
- **AI 控制台**:一句话 + 选设备 → 多模态 Agent(DeepSeek)通过 20 个 `de_*` 工具在手机上「边看边做」(截图看屏、`de_ui_tree` 拿元素树、`de_tap_element/de_tap_text` 语义点按),流式回放步骤。
- **任务系统 + 步骤编辑器**:`generic_steps` 任务 = 一棵可嵌套步骤树(open_app/click/swipe/loop/group/if_el…21 种节点),在编辑器里拖拽编排、单步试跑、定时调度。
- **任务系统 + 步骤编辑器**:`generic_steps` 任务 = 一棵可嵌套步骤树(open_app/click/swipe/loop/group/if_el…22 种节点),在编辑器里拖拽编排、单步试跑、定时调度。
目标:让**非工程用户用一句自然语言需求**(例:「创建一个每日养号刷视频的任务,每天 8:00-9:00 在 100.100.10.13 跑」)得到**一条可直接调度、可继续在现有步骤编辑器里手改的任务**。AI 先自己在设备上打开 App、看 UI 树、确认可点元素,再直接撰写编辑器的步骤 JSON。
+26 -2
View File
@@ -134,6 +134,9 @@
| GET | `/api/uiauto/snapshot` | D | **一次取齐**截图 + 元素树(原生 u2,抓取弹窗用) |
| GET | `/api/uiauto/screenshot` | D | uiautodev 截图(JPEG) |
| POST | `/api/steps/test` | D | 真机试执行单个步骤 |
| GET | `/api/done_marks?job=&limit=` | L | 去重记录列表 + 统计(`{marks, stats{total,devices,identities,today_devices,today_identities}}`) |
| POST | `/api/done_marks/delete` | T | 删一条去重记录(`{id}`)→ 该设备/身份下次会重新执行 |
| POST | `/api/done_marks/clear` | T | 清空某任务的全部去重记录(`{job}`)→ 整批重跑 |
### 2.4 admin(`web/admin_api.py`)
@@ -169,6 +172,9 @@
| POST | `/api/devices/discovery/ignore` | D | 忽略待连接设备 |
| POST | `/api/devices/discovery/reconnect` | D | 立即重连指定断联设备 |
| POST | `/api/devices/discovery/settings` | D | 保存发现配置 |
| GET | `/api/devices/battery` | D | 电量采集器状态 + 生效配置(`{settings, scanning, last_scan:[时间,成功,目标数], cached}`) |
| POST | `/api/devices/battery/settings` | D | 保存电量监控配置(越界钳制,返回钳制后的生效值) |
| POST | `/api/devices/battery/scan` | D | 立即采集一次(后台执行;上一轮没跑完返回 409) |
### 2.6 apks(`web/apks_api.py`)
@@ -321,12 +327,19 @@
"serial": "192.168.20.206:5555", "model": "22120RN86C", "device_name": "", "present": true, "ready": true,
"worker_status": "running", "task_job": "测试抖音评论", "attempt": 1, "max_attempts": 1,
"current_action": "点击搜索", "progress": {"done": 5, "total": 0, "unit": "操作", "elapsed": 42},
"foreground_app": "抖音", "last_error": "", "last_warning": "", "end_time": 0
"foreground_app": "抖音", "last_error": "", "last_warning": "", "end_time": 0,
"battery": {"level": 85, "charging": true, "at": 1700000000.0, "tier": 0}
}]}
```
`worker_status`:`idle` / `connecting` / `running` / `done` / `error` / `released` / `failed`。
`battery`:**后台线程采的缓存值**(默认 60s 一轮,`dumpsys battery` 只读),没采到时为 `null`。
`tier` 是**告警档位**(`0` 正常 / `1` 低电量 / `2` 严重),由后端按配置阈值算好——
前端只按 `tier` 上色,**不在 JS 里重算阈值**(阈值只在「工具 → 设备发现 → 电量监控」一处定义)。
设备离线时保留最后一次读数(`at` 是采集时刻),页面置灰显示。详见 [NOTIFY.md](NOTIFY.md) §3 的
`device.battery.low`。
### GET /api/summary
```json
@@ -458,6 +471,17 @@
`steps` 的 schema 与 `generic_steps` 的 `params.steps` 完全一致,见 [TASK_DEV.md](TASK_DEV.md)。
### 去重记录(「任务 → 去重记录」页)
| 接口 | 请求 | 响应要点 |
|------|------|---------|
| `GET /api/done_marks` | `?job=<任务ID>&limit=200` | `{ok, marks:[{id,scope_key,kind,job_id,job_name,serial,device_name,identity,created_at}], stats:{total,devices,identities,today_devices,today_identities}}`。`job` 省略 = 全部任务 |
| `POST /api/done_marks/delete` | `{"id"}` | 删一条 → 该设备/身份下次会重新执行;不存在 → 404;缺 id → 400 |
| `POST /api/done_marks/clear` | `{"job"}` | 清空该任务的全部记录(整批重跑)→ `{ok,msg,deleted}`;缺 job → 400 |
账本表与语义见 [DATA_MODEL.md](DATA_MODEL.md) §2.9;任务侧怎么用见
[TASK_DEV.md](TASK_DEV.md) §4.6。
---
## 6. 设备池与自动发现
@@ -567,7 +591,7 @@
| 接口 | 请求 | 响应要点 |
|------|------|---------|
| `GET /api/uiauto/status` | — | uiautodev(:20242)是否在跑,前端据此禁/启用"抓取元素" |
| `GET /api/uiauto/devices` | — | 可选设备列表(uiautodev 设备 + 池内在线补全) |
| `GET /api/uiauto/devices` | — | 可选设备列表(uiautodev 设备 + 池内在线补全)。每项 `{serial, name, model, product, status}`;**`name` 是平台给设备起的名字**(`cs1`/`A08`),池外设备退回 `serial`——uiautodev 自带的 name 是设备 codename(实测一柜子全是 `earth`),不能用来认设备 |
| `GET /api/uiauto/elements?serial=` | — | 扁平元素列表(走 uiautodev),每项带 `suggested`、`bounds`、`depth` |
| `GET /api/uiauto/snapshot?serial=` | — | **一次取齐**:截图 + 元素树(原生 u2 同一连接背靠背)→ `{image,width,height,elements,unstable,screen_state,cost_ms}`;抓取弹窗与 MCP 的 `de_snapshot` 都用它,失败返回 **502** |
| `GET /api/uiauto/screenshot?serial=` | — | JPEG |
+14 -10
View File
@@ -26,6 +26,7 @@
┌───────────────────────────▼──────────────────────────────────────────┐
│ 领域层 task_manager(调度) device_worker(执行) device_pool(池) │
│ system_backup(备份) apk_manager(应用) device_discovery │
│ device_battery(电量采集 + 低电量告警) dedup(去重账本) │
└───────────────────────────┬──────────────────────────────────────────┘
│
┌───────────────────────────▼──────────────────────────────────────────┐
@@ -79,15 +80,14 @@
|------|------|---------|
| **B** | `:28-58` | `Flask(__name__)`;会话密钥(`.env` 的 `WEB_SECRET_KEY`,缺失则随机生成并 warning);`TEMPLATES_AUTO_RELOAD=True`;**数据库目标由 `core/db_config` 装配**(`.env` 的 `DEPLOY_ENV`/`DB_*` → URI + 引擎参数),配置错直接 `SystemExit(2)`;`LoginManager` + `login_view="auth.login"` |
| **C** | `:60-67` | **恢复任务消费** `consume_pending_restore()`。SQLite 时代它必须在 engine 首次打开 `users.db` **之前**(Windows 无法替换被持有的文件);改用 MySQL 后这一步的语义会变成"启动期事务替换",见 §7 |
| **D** | `:69-88` | `init_db(app)`(建表 → 补列 → 版本账本 → 唯一索引 → 默认管理员 → 旧 JSON 迁移)→ `notifier.init_app`(通知 dispatcher/sender 线程)与 `step_log.init_app`(步骤明细写线程)→ **恢复任务消费** `consume_pending_restore()` → **库环境标签校验 + 启动横幅**(`db_config.verify_deployment_label/print_banner`,不符拒绝启动);`device_pool.init_app`(**起一次性线程**,3s 后采集型号);`device_discovery.init_app`(**起常驻扫描线程**);`TaskManager(app=app)`(APScheduler + 看门狗 + 从库加载分组/任务 + 重注册 cron);`ApkManager(app=app)` |
| **D** | `:69-88` | `init_db(app)`(建表 → 补列 → 版本账本 → 唯一索引 → 默认管理员 → 旧 JSON 迁移)→ `notifier.init_app`(通知 dispatcher/sender 线程)与 `step_log.init_app`(步骤明细写线程)→ **恢复任务消费** `consume_pending_restore()` → **库环境标签校验 + 启动横幅**(`db_config.verify_deployment_label/print_banner`,不符拒绝启动);`device_pool.init_app`(**刷一次 `serial→名称` 内存快照** + **起线程**:3s 后采集型号、另起 `device-names` 每 60s 刷名称);`device_discovery.init_app`(**起常驻扫描线程**);`device_battery.init_app`(**起常驻电量采集线程**);`dedup.init_app`(**去重账本绑 app**,供任务线程/Web/清理自推 context);`TaskManager(app=app)`(APScheduler + 看门狗 + 从库加载分组/任务 + 重注册 cron);`ApkManager(app=app)`;`notifier.set_device_name_resolver(device_pool.name_of)`(通知里显示设备名而不是 IP,见 [NOTIFY.md](NOTIFY.md) §7) |
### 2.3 阶段 E~G:蓝图、巡检调度器、真正启动
| 阶段 | 位置 | 做了什么 |
|------|------|---------|
| **E** | `:64-67` | `context.init(...)`;`register_blueprints(app)`(10 个蓝图);`agent_api.set_app(app)`(供后台线程推 app context) |
| **F** | 同上附近 | **第二个独立 APScheduler**:`CronTrigger(hour=3, minute=47)` 挂经验库巡检、`hour=4, minute=13` 挂步骤明细清理(`_purge_step_log`,自建 app context);失败仅 warning |
| **G** | `__main__` | `_ensure_uiauto_running()`(拉起 uiautodev:20242,写 `data/uiauto.pid`,`atexit` 清理)→ `_preconnect_pool_devices()`(后台并发 connect 池内网络设备)→ `_purge_step_log_async()`(后台清理超期步骤明细)→ `_run_server()`(候选端口依次 bind:`0.0.0.0:18050` → `127.0.0.1:18050` → `127.0.0.1:18051..18055`);退出时 `notifier.shutdown()` + `step_log.shutdown()` + `mgr.shutdown()` + `device_discovery.shutdown()` + 停 uiautodev |
| **F** | 同上附近 | **第二个独立 APScheduler**:`CronTrigger(hour=3, minute=47)` 挂经验库巡检、`hour=4, minute=13` 挂步骤明细清理(`_purge_step_log`)、`hour=4, minute=23` 挂去重记录清理(`_purge_done_mark`,只清 `day`/`hours` 桶)——两个清理任务都自建 app context;失败仅 warning |
| **G** | `__main__` | `_ensure_uiauto_running()`(拉起 uiautodev:20242,写 `data/uiauto.pid`,`atexit` 清理)→ `_preconnect_pool_devices()`(后台并发 connect 池内网络设备)→ `_purge_step_log_async()`(后台清理超期步骤明细)→ `_run_server()`(候选端口依次 bind:`0.0.0.0:18050` → `127.0.0.1:18050` → `127.0.0.1:18051..18055`);退出时 `notifier.shutdown()` + `step_log.shutdown()` + `mgr.shutdown()` + `device_discovery.shutdown()` + `device_battery.shutdown()` + 停 uiautodev |
> ⚠️ **阶段 A~F 在 import 期就会起线程/调度器**,只有 uiautodev 拉起与预连接在 `__main__` 分支。以 WSGI 方式 import 本模块会得到"半个启动"的进程——本地调试请直接 `python web_server.py`。
@@ -104,7 +104,9 @@
| **巡检调度器** `BackgroundScheduler` | `web_server.py` | 经验库 AI 巡检 | 每日 03:47 |
| `worker-watchdog` | `start_watchdog()` | `running/connecting` 心跳超时 → 标 `error` | 30s 检查 / 120s 阈值 |
| `device-discovery` | `device_discovery.init_app` | 网段扫描 + 断联重连 | 首轮延迟 15s,之后 interval(默认 60s) |
| `device-names` | `device_pool.init_app` | 刷 `serial → 名称` 内存快照(通知显示设备名用;只查库) | 60s(另有启动/增删改名时主动刷) |
| `_refresh_models_bg` | `device_pool.init_app` | 启动后采集全部在线设备型号 | 一次性(3s 后) |
| `device-battery` | `device_battery.init_app` | 采设备电量(`dumpsys battery` 只读)+ 低电量告警 | 首轮延迟 20s,之后 interval(默认 60s) |
| `BaseWorker` × N | `TaskManager._run_with_retry` | 单设备任务执行 | 任务期 |
| `_run_with_retry` × N | 同上 | 单设备重试循环 | 任务期 |
| `fg-scan-once` | `_ForegroundScanner.scan_once` | 前台 App 扫描 | 手动触发,`Event` 防重入 |
@@ -127,6 +129,7 @@
| `_engine_lock` | `core/ocr.py` | OCR 引擎懒加载 + 推理串行 | 推理 0.2~0.5s,锁开销可忽略 |
| `_scan_lock` / `_stop_event` | `core/device_discovery.py` | 定时/手动扫描互斥 | `acquire(blocking=False)` |
| `_status_cache_lock` | `core/task_manager.py` | 状态缓存(TTL 5s) | 避免 `/api/status` 每次都查库 + adb |
| `device_battery._lock` | `core/device_battery.py` | 电量缓存 `_cache` + 告警档位 `_tiers` | 采集线程 / `/api/status` / 告警状态机共用 |
**无锁部分**:`device_pool` 与 `models` 不持显式锁,依赖"每次操作独立 app context" + 数据库自身的并发控制(MySQL 下是 InnoDB 行锁 + READ COMMITTED,回退 SQLite 时是 WAL + `busy_timeout=5000`)。
@@ -267,7 +270,7 @@ connecting ──获取设备──▶ u2 连接 ──▶ running ──▶ set
### 6.1 单页应用
- 主页面 `templates/admin/monitor.html`:一个内联 `<style>` + 7 个 Tab 面板 + 7 个模态框容器 + 11 个 `<script src>`
- 主页面 `templates/admin/monitor.html`:一个内联 `<style>` + 7 个 Tab 面板 + 10 个模态框容器 + 16 个 `<script src>`
- 独立页面:`login.html`(登录)、`wall.html`(监控大屏,**完全自包含**,自带 CSS/JS,不加载 `static/admin/*.js`)
- 服务端内联页:`GET /locate`(设备端定位大字页,免登录)
- 响应头强制 `no-store`,避免后台改版后浏览器拿旧页面
@@ -277,12 +280,12 @@ connecting ──获取设备──▶ u2 连接 ──▶ running ──▶ set
| 顶级 Tab | `data-tab` | 权限 | 子分栏 |
|---------|-----------|------|--------|
| 监控 | `monitor` | 登录即可 | — |
| 任务 | `tasks` | 登录即可(写操作需 `tasks`) | `plan` 任务计划 / `actions` 自定义动作 |
| 任务 | `tasks` | 登录即可(写操作需 `tasks`) | `plan` 任务计划 / `actions` 自定义动作 / `actioncfg` 动作配置 / `dedup` 去重记录 |
| 日志 | `logs` | `logs` | — |
| 用户 | `users` | `admin` | — |
| 工具 | `tools` | `admin` | `clipboard` / `adb` / `ts` / `apks` / `appver` / `devapps` / `devpool` / `groups` |
| AI 控制台 | `agent` | `admin` | — |
| 系统 | `system` | `admin` | `backup` 数据备份 / `restore` 导入恢复 |
| 系统 | `system` | `admin` | `backup` 数据备份 / `restore` 导入恢复 / `notify` 通知 Webhook |
子分栏会记住上次选中位置(`_activeSubs`);`devpool` 子分栏自带 10s 轮询,切走即停。
@@ -293,10 +296,11 @@ connecting ──获取设备──▶ u2 连接 ──▶ running ──▶ set
| `base.js` | esc / CSRF / 权限 / API 封装 / Toast / Tab 与子分栏切换 / 模态框 / 常量表 | 所有模块的公共底座 |
| `markdown.js` | 轻量 Markdown 渲染(`renderMarkdown`) | 无 CDN 依赖;**先转义再套标记** |
| `list.js` | 统一列表组件:搜索 + 分页 + 排序 | 状态注册表 `_LIST_PAGERS`;`setListPager` **不重置**页码/搜索/排序(避免轮询刷新打断用户) |
| `monitor.js` | 监控页:设备表、批量操作、异常汇总、任务概况卡片(含覆盖设备 chip) | 5s 轮询 + 脏检查(签名不变不重渲染) |
| `editor.js` | 步骤编辑器(拖拽 / 参数表单 / 条件分支 / 元素抓取 / 测试此步骤)+ `saveTask` | 最大的前端文件;`_stepEditor` 单例 |
| `monitor.js` | 监控页:设备表(含**电量列**,按 `battery.tier` 上色)、批量操作、异常汇总、任务概况卡片(含覆盖设备 chip) | 5s 轮询 + 脏检查(签名不变不重渲染) |
| `editor.js` | 步骤编辑器(拖拽 / 参数表单 / 条件分支 / 元素抓取 / 测试此步骤)+ `saveTask` | 最大的前端文件;`_stepEditor` 单例;设备选择卡 `_devCard` 供「抓取元素」「测试此步骤」共用(**名字优先**,型号·地址作副标题) |
| `tasks.js` | 任务 Tab:任务 CRUD / 调度解析 / 运行窗口 + 自定义动作 | |
| `tools.js` | 工具 Tab:剪贴板、adb 终端、Tailscale、设备池、自动发现、远程看屏 | |
| `tools.js` | 工具 Tab:剪贴板、adb 终端、Tailscale、设备池、自动发现、远程看屏、**电量监控配置**(阈值/间隔/立即采集) | 电量表单只回填一次,10s 轮询不冲掉用户正在输的值 |
| `dedup.js` | 任务 Tab·**去重记录**:账本列表 + 统计("今天做了几个号")+ 删单条/清空任务 | 数据来自 `done_mark` 表,见 [TASK_DEV.md](TASK_DEV.md) §4.6 |
| `apps.js` | 应用管理:APK 上传/安装/删除、设备已装应用 | |
| `admin.js` | 分组、日志、用户 + **全局初始化入口**(末尾 `initCsrf(); loadMe(); showTab('monitor')`) | |
| `agent.js` | AI 控制台·聊天:会话、SSE 流、Markdown / 推理链 / token 渲染、实时画面、经验库 / 动作库 | |
+26
View File
@@ -172,6 +172,31 @@
写入走 `core/step_log.py` 的**专用写线程 + 有界队列**(任务线程只 `put_nowait`,
微秒级;队列满丢弃并计数)——步骤执行是热路径,绝不能在任务线程里同步写库。
### 2.9 `done_mark` — 去重账本(跨设备"已做过")
| 列 | 类型 | 说明 |
|----|------|------|
| `id` | Integer PK | |
| `scope_key` | String(300) **UNIQUE** | **幂等的全部依据**:`任务ID \| 身份值 \| 时间桶` |
| `kind` | String(12) | `day` / `hours` / `all`(有效期策略,任务级 `dedup_reset`) |
| `job_id` | String(32) idx | 哪个任务 |
| `job_name` | String(120) | |
| `serial` / `device_name` | String | 哪台设备(界面上显示"谁做过了") |
| `identity` | String(200) | 身份值(如抖音号 `35377983067`) |
| `created_at` | String(20) idx | |
索引:`job_id`、`created_at`、`(job_id, created_at)`。
**为什么靠唯一索引**:多台设备会同时判断"没做过","先查后插"有竞态(两台都插);
唯一索引 + `INSERT ... ON DUPLICATE KEY`/`INSERT OR IGNORE` 的**受影响行数**才是原子的。
实现在 `core/dedup.py`(`check` / `mark` / `list_marks` / `delete_mark` / `clear_job` / `purge_old`)。
**保留期**:每天 04:23 清理过保留期(`core/dedup.KEEP_DAYS`,默认 180 天)的记录,
但**只清 `day`/`hours` 桶**——`kind='all'`("只做一次")清了就等于去重失效,永不清理。
量级很小(设备数 × 天数),单条 DELETE 足够,不需要像步骤明细那样分批。
⚠️ 这张表**自动进整库备份**(§6 派生规则);接任务步骤见 [TASK_DEV.md](TASK_DEV.md) §4.6。
---
## 3. 非模型表
@@ -278,6 +303,7 @@ UTF-8 等价于字节序)。
| `deployment_claimed_at` | 标签写入时间 | 同上 |
| `notify_webhooks` | **通知 / Webhook 全部配置**(JSON:`{version, settings, webhooks[]}`,见 [NOTIFY.md](NOTIFY.md) §4)。**不建表**——新增/删除 webhook 都只改这一个键 | 系统 → 通知 页 |
| `step_defaults` | **步骤默认值**(JSON:`{步骤类型: {字段: 值}}`,见 [TASK_DEV.md](TASK_DEV.md) §3.1)。新建步骤时预填用。**只存与出厂值不同的字段**,这样以后调出厂默认能跟着走 | 任务 → 动作配置 页 |
| `device_battery` | **电量监控配置**(JSON:`{enabled, low, critical, skip_charging, interval}`,见 [NOTIFY.md](NOTIFY.md) §3.1)。**只存与出厂值不同的字段**(出厂:`low=20 critical=10 skip_charging=true interval=60`)。**电量本身不落库**——只放采集线程的内存缓存(重启重新采一轮),所以没有对应表、不动备份覆盖清单 | 工具 → 设备发现 → 电量监控 |
> ⚠️ `agent_api_key` 是**明文存储**,导出备份的 zip 里也含它——备份预览会固定给出"含敏感信息"告警。
> **同理 `notify_webhooks` 里的 webhook URL 本身就是凭据**(企业微信 `?key=`、钉钉 `?access_token=`、
+5 -3
View File
@@ -212,14 +212,16 @@ tail -20 logs/web.log # 无 ERROR/Traceback
SUMMARY_TABLES = tuple(sorted(t.name for t in db.metadata.tables.values()))
```
当前 14 张表:`app_meta` / `user` / `device_group` / `task_job` / `custom_action` /
当前 15 张表:`app_meta` / `user` / `device_group` / `task_job` / `custom_action` /
`apk_file` / `device` / `pending_device` / `agent_conversation` / `agent_experience` /
`experience_audit` / `agent_action` / `device_install_log` / `task_step_log`。
`experience_audit` / `agent_action` / `device_install_log` / `task_step_log` / `done_mark`。
完整说明见 [DATA_MODEL.md](DATA_MODEL.md) §6。
> ⚠️ `task_step_log`(任务步骤明细)是**唯一会持续增长**的表:它按 `KEEP_DAYS`
> ⚠️ `task_step_log`(任务步骤明细)是会持续增长的表:它按 `KEEP_DAYS`
> (默认 14 天,见 `core/step_log.py`)自动清理,但备份包里会带上保留期内的全部行。
> 设备多、任务密时导出 zip 会明显变大——需要更小的包就调小那个常量。
> `done_mark`(去重账本)也会增长,但量级是"设备数 × 天数",可以忽略;它同样有保留期
> (默认 180 天,且 `kind='all'` 的记录**永不清理**)。
> **新增一张 ORM 表,就自动进了覆盖清单**,不可能再漏(2026-09-10 动作库 `agent_action`
> 曾因手工维护漏登记,数据其实在快照里,只是清单没列 → 被误判为"没有备份")。
+1
View File
@@ -54,6 +54,7 @@
| 1 | **绝不 `adb kill-server`** | 会断掉所有设备的 adb transport,运行中任务全废 | `core/adb_helper.py` 只 connect 不 kill;Web 层硬拦截 `kill-server`/`disconnect` 字符串 |
| 2 | **绝不对 `IP:5555` 设备 `adb disconnect`** | 该地址的 adb transport 是共享的 | `adb_disconnect` **全项目零调用方**;`STFDevice.release()` 空实现 |
| 3 | **空闲设备扫描不主动 connect/disconnect** | 避免扰动共享连接 | 前台扫描对空闲设备直接返回"空闲";设备发现用 socket 探测 |
| 3b | (3 的边界)**只读 `adb -s <serial> shell …` 是允许的**,它不建立/断开 transport | 设备已在 `adb devices` 里就说明 transport 现成,读一下不扰动任何人 | 电量采集 `core/device_battery.py` 就是这么读**包括空闲设备在内**的全部在线设备(`dumpsys battery`,实测 0.2~0.35s/台);**别"顺手"给它加 connect** |
| 4 | **adb key 保持历史 key 不变** | 设备信任该 key,换 key 全部 `unauthorized` | 部署沿用 `~/.android/adbkey` |
| 5 | **生产(220)默认只读** | 生产事故成本高 | 任何写操作(pull/重启/改文件)都需负责人确认 |
| 6 | **新增持久化表必须进备份覆盖清单** | 漏登记 = 等于没备份 | `SUMMARY_TABLES` 已由模型元数据自动派生(加了 ORM 表就进清单);**手工要做的只有补 `TABLE_LABELS` 中文标签**,详见 [DEPLOY.md](DEPLOY.md) §5.2 |
+43 -1
View File
@@ -54,7 +54,7 @@ daemon 线程。所以:
| 业务 | `task.selector.invalid`(选择器连续 10 次未命中——"任务成功但什么都没做"的隐蔽故障) |
| 业务·任务自己发 | `task.notify.custom`(步骤「发通知」)/ `task.patrol.hit`(任务「公共巡检」命中)——**标题正文由任务自己写**,见下 |
| Worker | `worker.connected` · `.attempt.done` · `.attempt.error`(单次尝试级,噪音大,默认没人勾) |
| 设备 | `device.online` · `.offline` · `.discovered` · `.claimed` · `device.heartbeat_timeout` |
| 设备 | `device.online` · `.offline` · `.discovered` · `.claimed` · `device.heartbeat_timeout` · `device.battery.low` · `.battery.recovered` |
| 安装 | `apk.install.started` · `.finished` |
| 系统 | `system.backup.exported` · `.imported` · `.restored` · `.restore_failed` · `service.started` · `.stopping` · `user.login` |
| AI | `ai.audit.finished` · `.failed` · `.skipped` |
@@ -106,6 +106,31 @@ daemon 线程。所以:
- 谁能收到仍然只看 webhook 的**事件订阅**:想让某个群收任务自定义消息,就在那条
webhook 上勾 `task.notify.custom` / `task.patrol.hit`。
### 3.1 设备电量告警(`device.battery.low` / `.battery.recovered`)
采集与判定都在 `core/device_battery.py`(后台线程 `device-battery`,默认 60s 一轮,
`adb -s <serial> shell dumpsys battery` **只读**查询)。阈值配置在
**「工具 → 设备发现 → 电量监控」**,存在 `app_meta.device_battery`(见 [DATA_MODEL.md](DATA_MODEL.md) §5)。
阈值语义(**这是唯一判定处,前端只读后端算好的 `tier` 上色**):
| 档位 | 条件 | 通知 |
|---|---|---|
| `0` 正常 | `电量 >= 低电量阈值 + 5`(迟滞)或 充电中 | 从低/严重回到 0 → 发 `device.battery.recovered` |
| `1` 低 | `严重阈值 < 电量 <= 低电量阈值` | **掉到这一档才发** `device.battery.low`(`warning`) |
| `2` 严重 | `电量 <= 严重阈值` | 掉到这一档再发一次(`error`),阈值字段是严重阈值 |
- **迟滞 +5**:回升要到 `低电量阈值 + 5` 才算恢复。没有它,电量在阈值上下浮动
(充电器接触不良)会反复告警。
- **"充电中"看的是 `status:` 行**(`2` 充电中 / `5` 已充满),不是"插着电"。
所以**"插着却没充电"**(劣质线、温控停充、满电停充 `status:4`)**仍然会告警**——
那正是最该让人知道的情况。想连充电中一起报,把「充电中不告警」勾掉即可。
- **重复提醒**:同一档位持续不恢复时,每 **6 小时**再提醒一次(`core/device_battery.RE_NOTIFY_S`)。
- 只对**「设备池 ∩ 在线」**的设备告警:adb 里能看到但不归平台管的设备不吵人。
- 一周内改了阈值**不用重启**:采集线程每轮重读配置,档位下一轮就按新阈值算。
排查:`logs/core.log` 搜 `core.battery`(每轮一行「电量采集完成: N/M 台」)。
---
## 4. 配置
@@ -222,6 +247,23 @@ Bark `/…/<key>`、Slack `/services/T…/B…/X…`)→ 接口回显、发送
- 事件的 `fields` 是前端字段表与模板占位符的白名单,只写真正有用的
- 高频事件(每台设备/每次尝试都会发生的)把 `agg_window` 设大一点或 `recommend=False`
**设备一律只传 `serial`,名字自动补**(不用每个调用点自己去查名字):
群消息里没人想看 `192.168.20.110:5555`,要看「A09」。这件事在通知链路里
**统一做掉**,调用点照旧只传 `serial` 就行:
| 环节 | 做什么 |
|---|---|
| `core/device_pool.py` | 维护一份 `serial → 名称` 的**内存快照**:启动刷一次、池子增删改名/迁址时刷一次、每 60s 兜底刷一次(覆盖整库恢复这类进程外改动) |
| `web_server.py` | `notifier.set_device_name_resolver(device_pool.name_of)` 把快照接给通知 |
| `core/notifier.py` | `fill_device_names()` 在 `notify()` 与 `build_message()` 里把 `serial` 补成 `device_name`、把 `serials`/`devices` 列表逐项换成名字 |
为什么绕这一圈:`notify()` 的红线是**零 DB**,不能为了取个名字去查库(那等于在业务
线程里加一次阻塞查询)。所以名字由 `device_pool` 预先算好放内存,`notifier` 只读。
降级规则(**绝不把内容弄丢**):调用点自己传了 `device_name` 就用它的;查不到名字
(设备没命名 / 已不在池里)就保留原来的地址。解析器缺失或抛异常都只是降级,不影响发送。
---
## 8. 排障
+1 -1
View File
@@ -13,7 +13,7 @@
| [ARCHITECTURE.md](ARCHITECTURE.md) | **架构详解**:分层、启动装配顺序、线程与并发模型、设备生命周期、任务调度链路、状态机、关键设计决策与扩展点 | 所有开发者(先读这篇) |
| [DATA_MODEL.md](DATA_MODEL.md) | **数据模型**:SQLite 表与字段、schema 迁移、非模型表、`app_meta` 配置键、数据目录、备份覆盖清单 | 后端开发、运维 |
| [API.md](API.md) | **HTTP 接口全量**:按蓝图分组的路由表、鉴权、请求/响应示例、非 JSON 响应、错误分支 | 前端开发、外部接入 |
| [TASK_DEV.md](TASK_DEV.md) | **任务与步骤开发**:TaskType/TaskJob 概念、21 种步骤全表、公共巡检、选择器与定位、自定义动作、新增任务类型模板 | 写任务的开发 |
| [TASK_DEV.md](TASK_DEV.md) | **任务与步骤开发**:TaskType/TaskJob 概念、22 种步骤全表、公共巡检、选择器与定位、自定义动作、新增任务类型模板 | 写任务的开发 |
| [MCP.md](MCP.md) | **MCP 手机控制手册**:20 个 `de_*` 工具用法、写操作门控、坐标换算、接入示例 | 接入方、数字员工 |
| [MCP_DESIGN.md](MCP_DESIGN.md) | **MCP 设计文档**:边界划分、错误码、白名单/审计设计、演进方向 | 平台开发者 |
| [AI_CONSOLE.md](AI_CONSOLE.md) | **AI 控制台**:会话/SSE、经验库、动作库、巡检、Markdown 渲染、推理链、token 统计 | 使用者、平台开发者 |
+115 -5
View File
@@ -10,7 +10,7 @@
- [1. 核心概念](#1-核心概念)
- [2. 通用步骤任务 generic_steps](#2-通用步骤任务-generic_steps)
- [3. 21 种步骤全表](#3-21-种步骤全表)(含 [3.1 步骤默认值与动作录制](#31-步骤默认值与动作录制任务--动作配置))
- [3. 22 种步骤全表](#3-22-种步骤全表)(含 [3.1 步骤默认值与动作录制](#31-步骤默认值与动作录制任务--动作配置))
- [4. 容器步骤与公共参数](#4-容器步骤与公共参数)(含 [4.5 公共巡检](#45-公共巡检任务级独立于步骤画布))
- [5. 选择器与元素定位](#5-选择器与元素定位)
- [6. 自定义动作](#6-自定义动作)
@@ -97,7 +97,8 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
}
```
- **任务级参数**:`DEFAULT_PARAMS = {"max_duration": 0}`(0 = 不限时)
- **任务级参数**:`DEFAULT_PARAMS = {"max_duration": 0, "dedup_reset": "day", "dedup_hours": 6}`
(`max_duration` 0 = 不限时;`dedup_reset` 是去重有效期,见 §4.6)
- **没有默认步骤**:`steps` 只能由编辑器产出;为空时 worker 立即报错「通用步骤任务没有可执行步骤:请在「任务」页编辑该任务并添加步骤」(设备「最近错误」可见),**不会静默空跑**
- **顶层 steps 只顺序执行一次**——需要重复的动作必须显式放进 `loop` 步骤
- **每步 schema**:`{id, type, label, params}`;`id` 由前端生成保证唯一(导出为自定义动作时会剥掉 id)
@@ -108,7 +109,7 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
---
## 3. 21 种步骤全表
## 3. 22 种步骤全表
> 参数与默认值以 `tasks/generic/task.py` 为准;前端 `STEP_LIB`(`static/admin/editor.js`)负责在编辑器里呈现这些字段。
@@ -131,10 +132,11 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
| 15 | `wait` | 等待 | — | `min`(1.0)、`max`(3.0)、`vary_pace`(False) | — | 随机时长;**分片 sleep**(每 ≤0.5s 检查停止/超时),可被抢占打断;勾了 `vary_pace` 再按**本设备节奏**缩放 0.8~1.35 倍(批量跑时设备之间会逐渐错开) |
| 16 | `loop` | 循环块 | — | `loop_mode`("rounds")、`max_iterations`(10)、`loop_duration`(600) | `children` | 见 §4.1 |
| 17 | `group` | 动作组 | — | — | `children` | 子步骤**按序执行一次**(不循环);自定义动作拖入画布就是展开成 group |
| 18 | `if_el` | 条件判断 | `selector_type`、`selector_value`、`timeout`(3) | `ocr_click`(False) | `then` / `else` | 见 §4.2;条件类型除元素/OCR 外还支持 **屏幕状态**、**前台App** |
| 18 | `if_el` | 条件判断 | `selector_type`、`selector_value`、`timeout`(3) | `ocr_click`(False)、`cmp_op`("")、`cmp_value`("")、`ident_type`/`ident_value`(去重身份) | `then` / `else` | 见 §4.2;条件类型除元素/OCR 外还支持 **屏幕状态**、**前台App**、**去重**(见 §4.6);填 `cmp_op` 则改成**比元素的文本**(等于/不等于/包含/不包含,多值任一命中) |
| 19 | `notify` | 发通知 | — | `title`("")、`message`("")、`level`("info") | — | 推一条**自定义**通知(事件 `task.notify.custom`):标题正文自己写,支持 `{device} {serial} {job} {time} {app} {screen}`;谁收到取决于 webhook 的事件订阅。两者都空则跳过 |
| 20 | `stop_self` | 停止本设备 | — | `reason`("") | — | 只停**本设备**的任务(其它设备照跑):置 worker 停止位,后续步骤不再执行,任务记成**被停止而不是失败**(不触发重试) |
| 21 | `gesture` | 录制手势 | `points`(录出来的) | `speed`(1.0) | — | **纯录制回放**:把录下的轨迹点列 `[[x,y,t_ms],…]` 按原路径与时长交给设备插值,不做弧线/抖动/手速加工。与「滑动」是两套东西,见 §3.2 |
| 22 | `mark_done` | 记为已做 | —(身份元素可选填 `selector_type`/`selector_value`) | — | — | 把身份值记进**去重账本**(跨设备共享)。放动作**成功之后**——失败不记账,下次重跑会重试。**留空 = 自动跟随上面「去重」检查的身份(推荐)**;没有前置检查时才退回设备 `serial`。见 §4.6 |
### 3.2 录制手势:**纯录制、纯回放**(`core/gesture.py`)
@@ -236,6 +238,51 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
- OCR 不可用(未装 `rapidocr_onnxruntime`)时该步返回 `None`(不判定)
- 命中长文本时点击位置按关键词在文本中的比例估算(避免点到整块中心偏离)
#### 文本比对(`cmp_op` / `cmp_value`,可选)——"这台登录的是不是那个号"
光判断"元素在不在"不够用。填了 `cmp_op` 就变成:**先按选择器取到元素的文本,再拿它和
`cmp_value` 比,比对结果才是命中与否**。
`cmp_op` 取值:`等于` / `不等于` / `包含` / `不包含`(`==`/`!=`/`contains` 这类别名也认)。
不填 = 老行为(只看元素在不在)。
**`cmp_value` 可以填多个值**(换行或 `|` 分隔,一行一个):多个之间是 **OR**——
"这几个号都算我的",任一命中即命中;否定式(不等于/不包含)则是"**一个都不许出现**"。
⚠ **逗号不是分隔符**("抖音号:123,456" 这种文本本身带逗号,拆了就永远匹配不上)。
例子(抓元素拿到的是 `android.widget.TextView text:抖音号:35377983067 id:com.ss.android.ugc.aweme:id/506`):
```json
{"type": "if_el", "params": {
"selector_type": "resourceId", "selector_value": "com.ss.android.ugc.aweme:id/506",
"cmp_op": "包含",
"cmp_value": "35377983067\n35377983068", // 多个号,任一命中即可
"timeout": 3,
"then": [{"type": "stop_self", "params": {"reason": "登录的不是我的号"}}],
"else": []}}
```
要判断"**不是**我的号"就用 `不等于`("界面上是别的号"会命中,正好用来抓"号被换/掉登录")。
日志会写清"采集到什么、几个候选、命中的是哪个值",例如:
`采集到 '抖音号:35377983068' 包含 2 个候选值 → 符合,命中『35377983068』`。
**两条必须知道的规则**(都为了不误判):
| 情况 | 结果 | 为什么 |
|---|---|---|
| 元素**没找到** | 一律**未命中**(走 `else`),日志写"未找到目标,无法比对" | "没读到"绝不能被当成"和我设的不一样"——否则界面还没加载出来就误判成"账号被换了" |
| 元素在、但文本为空 | 按**空文本**参与比对(空 `不等于` 任何非空值 → 命中) | 用于抓"栏位是空的"(如未登录)。日志会打出实际读到的值,好排查 |
其它细节:
- 支持比对的类型:所有**元素类**选择器(xpath / text / resourceId / description / className…)、
**`foreground`**(比当前前台包名)、**`ocr`**(比 OCR 命中的那段文字,如 `抖音号:35377983067`)。
**`screen`(亮/熄)没有文本可比**,设了比对会被忽略并告警。
- 比对**不影响「选择器健康」统计**:统计用的仍是"元素在不在"——"找到了但值不对"是条件按预期
走了 `else`,不是选择器失效,不该攒出「连续未命中」告警。
- 读元素文本是**只读**操作(`get_text`,2 秒超时),不会点击或改动任何状态。
### 4.3 公共参数
| 参数 | 说明 |
@@ -308,6 +355,69 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
- 巡检命中会往「日志 → 步骤明细」记一行(`step_type=patrol`,只记命中不记"没事发生")。
- 巡检内部异常只记日志、**绝不影响任务主流程**(同 `notifier.notify` 的口径)。
### 4.6 去重:同一个号不要做两次(跨设备幂等)
**场景**(真实需求):一台手机登录多个账号、一共好几台手机,每个任务只让**其中一个目标号**做事
(如评论)。每天跑一次,但不知道什么时候跑完 → 会反复重跑 → **同一个号被做两次、有的号还没做**。
平台为此提供一张**「已做过」账本**(表 `done_mark`,见 [DATA_MODEL.md](DATA_MODEL.md) §2.9),
**所有设备共享一份判断**:任何一台设备做过了,其余设备的检查都会命中。
| 组件 | 放哪 | 作用 |
|---|---|---|
| 条件判断,`选择器类型 = 去重` | 动作**之前** | 命中 = 这个身份在这个任务里做过了 → 走 `then` 分支 |
| 步骤「记为已做」 | 动作**成功之后** | 把身份值记进账本 |
```json
{"type": "if_el", "params": {
"selector_type": "dedup",
"ident_type": "resourceId", "ident_value": "com.ss.android.ugc.aweme:id/506",
"then": [{"type": "stop_self", "params": {"reason": "这个号今天已经评过了"}}],
"else": [
{"type": "click", "params": {"selector_type": "text", "selector_value": "发送"}},
{"type": "mark_done", "params": {}} // 身份留空 = 自动跟随上面检查用的身份
]}}
```
**身份值**(去重的"谁")= 身份元素读出来的文本(上例=抖音号)。
身份**只需在检查侧配一次**:
| 位置 | 身份元素 | 结果 |
|---|---|---|
| 条件判断(去重) | 填账号元素 | 检查用这个身份 |
| **记为已做** | **留空(推荐)** | **自动跟随上面检查刚解析出的身份** —— 检查与记账必然用同一个 key |
| 记为已做 | 也填了 | 用自己填的;**与检查侧不一致时编辑器会告警** |
| 记为已做 | 留空 + 任务里没有去重检查 | 退回**设备 `serial`** 当身份("一号一机"场景) |
> ⚠ 为什么把「留空 = 自动跟随」做成默认:检查与记账算的是同一个 key,
> 两处各配一遍、一边填了一边忘了 → 两个 key → **去重静默失效**(最难查的那种 bug,
> 现象是"去重没生效、还是重复做")。编辑器保存时会告警「两处身份不一致」
> 「有去重检查但没有记为已做」「有记为已做但没有去重检查」。
**有效期**(去重多久算"新的一轮")在**任务级**配一次:`dedup_reset` = `day`(默认,每天一次)
/ `all`(只做一次)/ `hours`(每 `dedup_hours` 小时)。任务编辑器顶部有「去重有效期」下拉。
**四条必须知道的语义**:
| 情况 | 行为 | 为什么 |
|---|---|---|
| 身份元素**读不到值** | **不去重**,当"没做过"照常执行(日志告警) | 读不到若当成空身份,所有设备会共用一个 key,第一台记账后其余全被误跳过——宁可重复一次,也不能漏做 |
| 身份值**过长** | 同上,不去重 | 截断会让两个身份撞成同一个 key(同样是误跳过) |
| 「记为已做」放在动作失败路径上 | **不会执行** | 拆成"检查在前、记账在后"就是为了**失败不记账、下次重试** |
| `dedup_reset='all'` | 记录**永不被清理** | 清了就等于"只做一次"失效(清理任务只删 `day`/`hours` 桶) |
**看进度 / 让它重跑**:「任务 → 去重记录」页能看到"谁做过了、今天做了几个号",
可以删单条(那个号/那台设备重跑)或清空整个任务。**这一页的价值就是让你不必靠"一直重跑"来确认。**
**第二种办法(兜底,不用账本)**:进 App 的评论区后用
**条件判断 + 文本比对(包含)**,候选值填本账号的评论标识(昵称/「我」标签),命中就跳过。
它的局限要说清楚,别指望它万能:
- 依赖 App 的评论区结构,**改版就失效**
- 自己的评论常常要**滑动加载**才出现在元素树里(可能要滑几次、慢)
- 多台设备并发时可能**同时**判"没评过"(没有原子性)——所以**账本才是主拦**,这个只做兜底
---
## 5. 选择器与元素定位
@@ -412,7 +522,7 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
|------|------|
| 操作库拖拽 | 左侧分类库(interact / screen / flow),拖到画布;支持跨层级嵌套(循环套循环)、拖拽排序 |
| 参数表单 | 按步骤类型渲染对应字段(选择器行、时长、概率、循环模式等) |
| 条件分支 | `if_el` 的 then/else 两个分支区;OCR 模式下多出"命中后点击"复选框 |
| 条件分支 | `if_el` 的 then/else 两个分支区;OCR 模式下多出"命中后点击"复选框;元素/OCR/前台App 条件还多一块**文本比对**(运算符下拉 + 比对的值,见 §4.2) |
| 多选与打包 | ☑ 多选 → 打包成自定义动作 |
| 元素抓取 | 「抓取元素」打开抓取弹窗(选设备 → 截图 + 元素树 → 点选回填),见 §5.5 |
| 测试 | 「测试此步骤」真机试执行 |
+1
View File
@@ -136,6 +136,7 @@ function showSubTab(tabId, name){
if(tabId==='logs' && name==='files' && typeof loadLogs==='function') loadLogs();
if(tabId==='logs' && name==='steps' && typeof loadStepLogs==='function') loadStepLogs(true);
if(tabId==='tasks' && name==='actioncfg' && typeof loadActionConfig==='function') loadActionConfig();
if(tabId==='tasks' && name==='dedup' && typeof loadDedup==='function') loadDedup();
if(name==='groups' && typeof loadGroups==='function') loadGroups();
if(name==='apks' && typeof loadAgentStore==='function') loadAgentStore();
if(name==='devpool' && typeof loadDevPool==='function'){
+85
View File
@@ -0,0 +1,85 @@
// 去重记录(「任务 → 去重记录」页)
// 账本在 done_mark 表(跨设备幂等),后端见 core/dedup.py + web/tasks_api.py 的三个接口。
// 这一页的价值:**"谁做过了、还差谁"看得见**——用户原来不知道任务什么时候跑完,
// 只能一直重跑,而重跑本身又在制造重复。
var _dedupMarks = [];
function loadDedup(){
var sel=document.getElementById('dedup-job');
if(!sel)return;
var jobId=sel.value||'';
apiGet('/api/done_marks?limit=300'+(jobId?('&job='+encodeURIComponent(jobId)):'')).then(function(r){
if(!r||!r.ok){
var tb=document.getElementById('tb-dedup');
if(tb)tb.innerHTML='<tr><td colspan="6" class="empty">读取失败:'+esc((r&&r.error)||'')+'</td></tr>';
return;
}
_dedupMarks=r.marks||[];
renderDedupJobs(r.job_id||'');
renderDedup(r.stats||{});
});
}
// 任务下拉:用已加载的任务列表填(含"全部任务");保持当前选中项
// `_jobsData` 是全局任务列表(monitor.js 声明、tasks.js 的 loadTasks 填充)
function renderDedupJobs(current){
var sel=document.getElementById('dedup-job');
if(!sel)return;
var jobs=_jobsData||[];
if(!jobs.length)return; // 任务列表还没加载就先留着旧选项
var cur=current!==undefined?current:(sel.value||'');
var html='<option value="">全部任务</option>';
jobs.forEach(function(j){
html+='<option value="'+esc(j.id)+'">'+esc(j.name||j.id)+'</option>';
});
sel.innerHTML=html;
sel.value=cur;
}
function renderDedup(stats){
var tb=document.getElementById('tb-dedup');
if(!tb)return;
var st=document.getElementById('dedup-stats');
if(st){
st.textContent='共 '+stats.total+' 条 · 涉及 '+stats.devices+' 台设备 / '
+stats.identities+' 个身份值|今天已做 '+stats.today_devices+' 台 · '
+stats.today_identities+' 个身份值';
}
if(!_dedupMarks.length){
tb.innerHTML='<tr><td colspan="6" class="empty">暂无记录(任务里用了「去重」条件 + 「记为已做」并成功做完之后才会有)</td></tr>';
return;
}
var KIND={day:'每天一次',all:'只做一次',hours:'按小时'};
tb.innerHTML=_dedupMarks.map(function(m){
var who=m.device_name?('<strong>'+esc(m.device_name)+'</strong><br>'
+'<span style="font-size:11px;color:#94a3b8;font-family:var(--mono)">'+esc(m.serial)+'</span>')
:('<span style="font-family:var(--mono)">'+esc(m.serial||'-')+'</span>');
return '<tr><td style="font-size:12px">'+esc(m.created_at)+'</td>'+
'<td>'+who+'</td>'+
'<td style="font-family:var(--mono)">'+esc(m.identity||'-')+'</td>'+
'<td><span class="label label-default">'+esc(KIND[m.kind]||m.kind)+'</span></td>'+
'<td>'+esc(m.job_name||m.job_id||'-')+'</td>'+
'<td><button class="btn btn-xs btn-danger" data-id="'+m.id+'" '+
'onclick="deleteDedupMark(this.dataset.id)" data-perm="tasks" '+
'title="删掉这条 → 这台设备/这个身份下次会重新执行">删除</button></td></tr>';
}).join('');
}
function deleteDedupMark(id){
if(!confirm('删掉这条去重记录?\n\n删掉之后,这台设备/这个身份下次跑该任务时会重新执行一遍。'))return;
apiPost('/api/done_marks/delete',{id:id}).then(function(r){
if(r&&r.ok){showToast(r.msg||'已删除','success');loadDedup();}
else showToast('删除失败: '+((r&&r.error)||''),'error');
});
}
function clearJobDedup(){
var sel=document.getElementById('dedup-job');
var jobId=sel?sel.value:'';
if(!jobId){showToast('先在左边选一个任务("清空本任务记录"要指定任务)','error');return;}
if(!confirm('清空这个任务的全部去重记录?\n\n清空之后,所有设备/身份下次跑该任务都会重新执行一遍。'))return;
apiPost('/api/done_marks/clear',{job:jobId}).then(function(r){
if(r&&r.ok){showToast(r.msg||'已清空','success');loadDedup();}
else showToast('清空失败: '+((r&&r.error)||''),'error');
});
}
+173 -31
View File
@@ -19,13 +19,20 @@ var STEP_LIB=[
{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:'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,cmp_op:'',cmp_value:'',ident_type:'text',ident_value:'',then:[],else:[]}},
{type:'mark_done',label:'记为已做',icon:'✅',cat:'flow',params:{selector_type:'text',selector_value:''}},
{type:'notify',label:'发通知',icon:'🔔',cat:'flow',params:{title:'',message:'',level:'info'}},
{type:'stop_self',label:'停止本设备',icon:'⛔',cat:'flow',params:{reason:''}},
{type:'gesture',label:'录制手势',icon:'⏺',cat:'interact',params:{points:[],speed:1.0}}
];
// 步骤默认值(「任务 → 动作配置」页配置,见 core/step_defaults.py);新建步骤时预填
let _stepDefaults = null;
// 去重有效期选了「按小时」才显示小时数输入框
function _toggleDedupHours(){
var sel=document.getElementById('f-dedup_reset');
var wrap=document.getElementById('f-dedup_hours_wrap');
if(sel&&wrap)wrap.style.display=(sel.value==='hours')?'inline-flex':'none';
}
var STEP_LIB_CATS=[['interact','交互操作'],['screen','屏幕与App'],['flow','流程控制']];
var _customActions=[]; // 后端加载的自定义动作
var _stepIdCounter=0;
@@ -50,30 +57,41 @@ function _stripIds(steps){
function _findLib(type){return STEP_LIB.find(function(s){return s.type===type;});}
// 选择器组件(类型下拉 + 值输入 + 抓取元素 + 测试按钮),click/long_click/swipe_until/wait_el/if_el 复用
// withOcr=true 时额外提供 OCR识别 选项(图片/画布里的文字,仅条件判断支持)
function _selRowHtml(p, extra, withOcr){
// withOcr(条件判断用)额外提供"屏幕状态 / 前台App"两类非元素条件
var types=withOcr?['xpath','description','text','resourceId','descriptionContains','className','ocr','screen','foreground']:
['xpath','description','text','resourceId','descriptionContains','className'];
// withDedup=true 时额外提供「去重」条件类型(身份做过了吗,见 core/dedup.py)
// keys 可换字段名(默认 selector_type/selector_value)——「去重」的身份元素用
// ident_type/ident_value,好让同一个条件里既能有主选择器又能有身份元素
function _selRowHtml(p, extra, withOcr, withDedup, keys){
keys=keys||{};
var kType=keys.type||'selector_type', kVal=keys.value||'selector_value';
var typeList=['xpath','description','text','resourceId','descriptionContains','className'];
// withOcr(条件判断用)额外提供"屏幕状态 / 前台App / 去重"三类非元素条件
var types=withOcr?typeList.concat(['ocr','screen','foreground']):typeList.slice();
if(withDedup)types.push('dedup');
var labels={xpath:'xpath',description:'description',text:'text',resourceId:'resourceId',
descriptionContains:'descriptionContains',className:'className',ocr:'OCR识别',
screen:'屏幕状态',foreground:'前台App是'};
screen:'屏幕状态',foreground:'前台App是',dedup:'去重(这个身份做过了吗)'};
var opts=types.map(function(d){
return '<option value="'+d+'"'+(p.selector_type===d?' selected':'')+'>'+labels[d]+'</option>';}).join('');
return '<option value="'+d+'"'+(p[kType]===d?' selected':'')+'>'+labels[d]+'</option>';}).join('');
var toggle=withOcr?' onchange="_stepEditor._condTypeChanged(this)"':'';
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 isScreen=p.selector_type==='screen', isFg=p.selector_type==='foreground';
var isScreen=p[kType]==='screen', isFg=p[kType]==='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>';
valHtml='<select class="form-control" data-key="'+kVal+'" style="max-width:140px">'+
'<option value="off"'+(p[kVal]!=='on'?' selected':'')+'>熄屏时就命中</option>'+
'<option value="on"'+(p[kVal]==='on'?' selected':'')+'>亮屏时就命中</option></select>';
}else{
valHtml='<input type="text" class="form-control" data-key="selector_value" value="'+esc(p.selector_value||'')+
valHtml='<input type="text" class="form-control" data-key="'+kVal+'" value="'+esc(p[kVal]||'')+
'" 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>'+
var head='<div class="form-group"'+(keys.hideValue?' style="grid-column:span 3"':'')+'>'+
'<label>选择器类型</label><select class="form-control" data-key="'+kType+'"'+toggle+'>'+opts+'</select></div>';
// hideValue:「去重」条件不需要 selector_value(身份元素在 ident_value 里),
// 与其显示一个没人用的输入框,不如只留类型下拉
if(keys.hideValue)return '<div class="form-row">'+head+'</div>'+(extra||'');
return '<div class="form-row">'+head+
'<div class="form-group" style="grid-column:span 2"><label>'+valLabel+'</label><div class="sel-picker">'+
valHtml+'</div></div></div>'+(extra||'');
}
@@ -106,7 +124,7 @@ var _stepEditor={
_steps:[],
_selected:[],
_dragSuppressClick:false,
init:function(steps,maxDuration,draftKey,canvasId,libId){
init:function(steps,maxDuration,draftKey,canvasId,libId,taskExtra){
this._draftKey=draftKey||'new';
this._selected=[];
this._updatePackBtns();
@@ -147,6 +165,25 @@ var _stepEditor={
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);
}
// 去重有效期(任务级):**只在这一处配**——「条件判断→去重」和「记为已做」两处
// 各填一份的话,填不一致就会算出两个不同的 key、去重静默失效,所以强制共用。
var drHost=document.querySelector('#step-editor .se-head');
if(drHost&&!document.getElementById('f-dedup_reset')){
var ex=taskExtra||{};
var dr=document.createElement('span');
dr.style.cssText='font-size:11px;display:flex;align-items:center;gap:4px';
var selStyle='padding:2px 6px;border:1px solid var(--border);border-radius:4px;font-size:12px';
dr.innerHTML='去重有效期 <select id="f-dedup_reset" style="'+selStyle+'">'+
'<option value="day">每天一次</option>'+
'<option value="all">只做一次</option>'+
'<option value="hours">按小时</option></select>'+
'<span id="f-dedup_hours_wrap" style="display:none;align-items:center;gap:3px">每 '+
'<input type="number" id="f-dedup_hours" value="'+(ex.dedup_hours||6)+'" min="1" max="168" style="width:52px;'+selStyle+'"> 小时</span>';
drHost.appendChild(dr);
document.getElementById('f-dedup_reset').value=ex.dedup_reset||'day';
_toggleDedupHours();
document.getElementById('f-dedup_reset').addEventListener('change',_toggleDedupHours);
}
},
_ensureIds:function(steps){
var self=this;
@@ -423,17 +460,60 @@ var _stepEditor={
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>';
}else if(step.type==='if_el'){
// 屏幕状态 / 前台App 是"直接读设备当前状态",没有等待超时、也没有选择器可抓
var dyn=(p.selector_type==='screen'||p.selector_type==='foreground');
// 屏幕状态 / 前台App / 去重 是"直接读设备当前状态或账本",没有等待超时、也没有选择器可抓
var isDedup=(p.selector_type==='dedup');
var dyn=(p.selector_type==='screen'||p.selector_type==='foreground'||isDedup);
// 文本比对:屏幕状态没有文本可比(亮/熄)、去重也不比文本,只有元素/OCR/前台 App 才给这块
var cmpRow=dyn?'':'<div class="form-row"><div class="form-group"><label>文本比对</label>'+
'<select class="form-control" data-key="cmp_op">'+
[['','不比对(只看元素在不在)'],['等于','等于'],['不等于','不等于'],['包含','包含'],['不包含','不包含']]
.map(function(o){return '<option value="'+o[0]+'"'+((p.cmp_op||'')===o[0]?' selected':'')+'>'+o[1]+'</option>';}).join('')+
'</select></div>'+
'<div class="form-group" style="grid-column:span 2"><label>比对的值(可多行,任一命中即可)</label>'+
'<textarea class="form-control" data-key="cmp_value" rows="2" style="font-family:var(--mono)" '+
'placeholder="如 35377983067&#10;35377983068">'+esc(p.cmp_value||'')+'</textarea>'+
'<div class="help">一行一个(也认 <code>|</code> 分隔):<b>任意一个命中就算命中</b>——多个号的场景把号都列上。'+
'<b>逗号不当分隔符</b>,因为要比对的文本本身常带逗号</div></div></div>';
// 「去重」条件:身份元素用 ident_type/ident_value(主选择器那行只剩类型下拉)
var identRow=isDedup?_selRowHtml(p,
'<div class="help"><b>身份元素</b>:填"账号"那个元素(点「抓取元素」选,如抖音号那个 TextView)。'
+'<b>留空 = 用本设备当身份</b>(一号一机时这样就行)。'
+'<br>身份<b>读不到时不会去重</b>(当"没做过"照常执行)——宁可重复一次,也不会误判成"做过"而漏做。'
+'<br>有效期在上方任务级的<b>「去重有效期」</b>里设,检查和记账共用一套(不用在步骤里各填一遍)。</div>',
false, false, {type:'ident_type', value:'ident_value'}):'';
h+=_selRowHtml(p,
(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')+'">'+
'<input type="checkbox" data-key="ocr_click" '+(p.ocr_click?'checked':'')+'><label>OCR 命中后自动点击该文字</label></div>')+
'<div class="help">'+(dyn
'<input type="checkbox" data-key="ocr_click" '+(p.ocr_click?'checked':'')+'><label>OCR 命中后自动点击该文字</label></div>'+
cmpRow)+
'<div class="help">'+(isDedup
? '命中 → 执行下方"✅ 找到时"分支;未命中 → 执行"❌ 未找到时"分支。'
+'<b>去重</b>读的是平台的「去重账本」(所有设备共享一份,见「任务 → 去重记录」):'
+'<b>任何一台设备做过了,其余设备的这一步都会命中</b>——'
+'所以多个手机跑同一个任务、反复重跑,也不会把同一个号做两次。'
+'<br>建议这样摆:<b>命中分支</b>放「停止本设备」或「发通知」;'
+'<b>未命中分支</b>里放要做的动作(评论…),动作后面跟一个「记为已做」。'
+'<br>⚠ 记账要放在动作<b>成功之后</b>(用「记为已做」步骤),失败就不记账、下次还会重试。'
: (dyn
? '命中 → 执行下方"✅ 找到时"分支;未命中 → 执行"❌ 未找到时"分支。<b>屏幕状态</b>读系统 mWakefulness(亮/熄),'
+'<b>前台App是</b>判断当前前台是不是某个包名——都只看当前状态,不等待。'
: '超时内找到元素 → 执行下方"✅ 找到时"分支;未找到 → 执行"❌ 未找到时"分支。两分支可放任意步骤,也可再嵌套条件判断/循环;'
+'<b>OCR识别</b>可匹配图片/画布里的文字(截屏识别,跨平台)')+'</div>', true);
+'<b>OCR识别</b>可匹配图片/画布里的文字(截屏识别,跨平台)'
+'<br><b>文本比对</b>(可选):填了就把找到的那个元素的<b>文本</b>和"比对的值"比,'
+'比中了才算命中。例:元素文本是 <code>抖音号:35377983067</code>,'
+'想判断"这台登录的是不是这个号",选<b>包含</b>、值填 <code>35377983067</code> 即可(要整段一模一样才用"等于")。'
+'多个值一行一个,<b>任一命中即可</b>——"这几个号都算我的"。'
+'<br>⚠ 元素<b>没找到</b>时一律算未命中(走"未找到时"分支)——"没读到"不会被当成"和我设的不一样"。'))+'</div>',
true, true, isDedup?{hideValue:true}:null)+identRow;
}else if(step.type==='mark_done'){
h+=_selRowHtml(p,
'<div class="help">把身份值记进平台「去重账本」(所有设备共享一份)。'
+'放在动作<b>成功之后</b>——动作失败就不会记账,下次重跑还会重试这台设备。'
+'<br>✅ <b>留空 = 自动跟随上面「去重」检查的身份</b>(推荐):'
+'检查与记账必须用<b>同一个身份字符串</b>,key 才算得到一起;'
+'两处各配一遍的话,一边填了另一边忘了去重就会失效。'
+'<br>没有前置去重检查、又留空时,退回用<b>本设备</b>当身份(一号一机场景)。'
+'<br>有效期在上方任务级的<b>「去重有效期」</b>里设。</div>');
}
// 共享:触发概率(所有步骤通用,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,&lt;100 表示按概率执行(如 30=隔几次才触发一次,适合"偶尔点赞")</div></div></div>';
@@ -592,10 +672,7 @@ var _stepEditor={
if(!list)return;
if(!r||!r.ok){list.innerHTML='<div class="se-empty">'+esc((r&&r.error)||'获取设备失败')+'</div>';return;}
list.innerHTML=(r.devices||[]).map(function(d){
return '<div class="el-device-card" onclick="_stepEditor._runTestStep(\''+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></div>';
return self._devCard(d, "_stepEditor._runTestStep('"+esc(d.serial)+"')");
}).join('')||'<div class="se-empty">无可用设备</div>';
});
},
@@ -808,6 +885,8 @@ var _stepEditor={
validate:function(steps){
// 校验步骤配置,返回警告列表
var warnings=[];
// 去重:收集「条件判断→去重」与「记为已做」各自用的身份元素,最后比对是否一致
var dedupIdents=[], hasDedupCheck=false, hasMarkDone=false;
function check(arr,prefix){
arr.forEach(function(s,i){
var name=prefix?(prefix+'.'+(i+1)):('步骤'+(i+1));
@@ -844,9 +923,30 @@ var _stepEditor={
warnings.push(name+' "'+label+'": 录制手势还没有轨迹,执行时会被跳过。');
}
}else if(s.type==='if_el'){
if(!s.params.selector_value){
// 「去重」「屏幕状态」这两类条件**不用文本比对**(面板里那块是隐藏的)。
// 所以:① 留着旧 cmp_op/cmp_value 不该报警告(用户看不到、也清不掉,
// 只会每次保存都被打扰);② 也别报"会被忽略"——那是设计如此。
var cmpUsed=(s.params.selector_type!=='screen'&&s.params.selector_type!=='dedup');
if(s.params.selector_type!=='dedup'&&!s.params.selector_value){
warnings.push(name+' "'+label+'": 条件判断的选择器为空,会直接走"未找到"分支。');
}
if(cmpUsed&&s.params.cmp_op&&!String(s.params.cmp_value||'').trim()){
warnings.push(name+' "'+label+'": 选了"文本比对('+s.params.cmp_op+')"但没填比对的值,'
+'执行时永远不命中(相当于每次都走"未找到时"分支)。');
}
if(s.params.selector_type==='dedup'){
hasDedupCheck=true;
// 空值也收进来:一边填了、另一边没填 = 两处身份不一致(去重会对不上),
// 必须报出来 —— 只收非空的话,这种最常见的不一致反而检测不到
dedupIdents.push({where:name,type:s.params.ident_type||'text',
value:(s.params.ident_value||'').trim()});
}
}else if(s.type==='mark_done'){
hasMarkDone=true;
// 留空 = 自动跟随去重检查的身份(执行器就是这么做的),所以**空值不参与比对**;
// 只有"填了、且和检查侧不一样"才是真问题
var mv=(s.params.selector_value||'').trim();
if(mv)dedupIdents.push({where:name,type:s.params.selector_type||'text',value:mv});
}
_childArrs(s).forEach(function(a){
if(a.length)check(a,name+' 子步骤');
@@ -854,6 +954,28 @@ var _stepEditor={
});
}
check(steps,'');
// 去重的两个坑,保存时直接点出来(配错的话去重会「静默失效」,最难查)
if(hasDedupCheck&&!hasMarkDone){
warnings.push('任务里有「条件判断→去重」但没有「记为已做」:做完动作不会记账,'
+'下次重跑还会再做一遍(去重起不到作用)。请在动作后面加一个「记为已做」。');
}
if(hasMarkDone&&!hasDedupCheck){
warnings.push('任务里有「记为已做」但没有「条件判断→去重」:只会记账、不会拦重复。'
+'请在动作前面加一个条件判断、类型选「去重」。');
}
if(dedupIdents.length>1){
var first=dedupIdents[0];
var showId=function(x){ return x.value?('「'+x.value+'」'):'(留空 → 用设备当身份)'; };
dedupIdents.slice(1).forEach(function(d){
if(d.value!==first.value||d.type!==first.type){
warnings.push('去重两处的身份不一致:'+first.where+' 是 '+showId(first)
+',而 '+d.where+' 是 '+showId(d)
+' —— 身份不一致会算出两个不同的 key,去重会失效'
+'(检查查不到记账 / 或者反过来,结果就是重复或漏做)。'
+'建议把「记为已做」的身份元素留空(自动跟随检查),或两处填成同一个元素。');
}
});
}
return warnings;
},
_syncParams:function(container,steps){
@@ -883,11 +1005,16 @@ var _stepEditor={
self._syncParams(childWrap,steps[i].params.children);
}
// if_el 双分支(then/else 在 .if-branch 内,不会与上面的直接子级选择器冲突)
var thenWrap=card.querySelector(':scope > .if-branch .step-children.if-then');
//
// ⚠ `.if-branch` 与 `.step-children` 之间**必须是 `>`(直接子级)**:
// 写成后代(空格)时,条件判断里再嵌套一个条件判断的话,本卡片会**匹配到内层
// 那个 if_el 的分支容器**(文档顺序上更靠前),于是把内层分支的子步骤当成自己的
// 去同步 —— 症状:自己的步骤"填了值保存就没了"、步骤名被内层的容器步骤覆盖。
var thenWrap=card.querySelector(':scope > .if-branch > .step-children.if-then');
if(thenWrap&&steps[i].params&&Array.isArray(steps[i].params.then)){
self._syncParams(thenWrap,steps[i].params.then);
}
var elseWrap=card.querySelector(':scope > .if-branch .step-children.if-else');
var elseWrap=card.querySelector(':scope > .if-branch > .step-children.if-else');
if(elseWrap&&steps[i].params&&Array.isArray(steps[i].params.else)){
self._syncParams(elseWrap,steps[i].params.else);
}
@@ -918,8 +1045,20 @@ var _stepEditor={
// 直接弹设备选择页(独立模态框,不影响任务编辑窗口)
this._showDevicePicker();
},
// 设备选择卡片(「抓取元素」与「测试此步骤」两处共用):
// **设备名优先**,型号与地址降为副标题——同型号好几台时只有平台起的名字
// (A08)能认出"是哪一台",光看 IP 选错设备是常事。
_devCard:function(d,onclick){
var sub=[d.model||d.product||'', d.serial||''].filter(Boolean).join(' · ');
return '<div class="el-device-card" onclick="'+onclick+'">'+
'<div class="edc-icon">📱</div>'+
'<div class="edc-info"><div class="edc-name">'+esc(d.name||d.serial)+'</div>'+
'<div class="edc-serial">'+esc(sub)+'</div></div>'+
(d.status?'<span class="edc-status">'+esc(d.status)+'</span>':'')+'</div>';
},
// 步骤1:设备选择页
_showDevicePicker:function(){
var self=this;
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>';
@@ -930,11 +1069,7 @@ var _stepEditor={
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>';
return self._devCard(d, "_stepEditor._showElPicker('"+esc(d.serial)+"')");
}).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>';
@@ -1338,6 +1473,13 @@ async function saveTask(jobId){
}
const mdEl=document.getElementById('f-max_duration');
params={max_duration:mdEl?parseInt(mdEl.value)||0:0,steps:steps};
// 去重有效期(任务级;「条件判断→去重」与「记为已做」共用这一套)
const drEl=document.getElementById('f-dedup_reset');
params.dedup_reset=drEl?drEl.value:'day';
if(drEl&&drEl.value==='hours'){
const dhEl=document.getElementById('f-dedup_hours');
params.dedup_hours=Math.max(1,parseInt(dhEl&&dhEl.value)||6);
}
// 公共巡检(任务级,独立于步骤画布;实现在 tasks.js + core/patrol.py)
if(typeof patrolCollect==='function'){
const ws=patrolCollect();
+20 -1
View File
@@ -13,6 +13,9 @@ function _sortValue(dev,key){
// 按状态优先级排序:running>connecting>idle>done>error>released
const order={running:0,connecting:1,idle:2,done:3,released:4,error:5,failed:6};
v=order[dev.worker_status]||99;
}else if(key==='battery'){
// 没采到电量的排最后(用 -1 而不是 0,0% 是真实电量)
v=dev.battery?dev.battery.level:-1;
}else{
v=dev[key]||'';
}
@@ -325,6 +328,22 @@ function renderDevices(){
applyListPager('devices');
initListSearch('devices','search-devices');
}
// 电量单元格:告警档位(tier 0 正常/1 低/2 严重)由后端算好,前端只上色——
// 阈值只在「工具 → 设备发现 → 电量监控」一处定义,两边不重复判
function _battCell(dev){
const b=dev.battery;
if(!b)return '<span class="text-muted">-</span>';
const t=b.tier||0;
const cls=t===2?'label-danger':(t===1?'label-warning':'label-success');
const mins=b.at?Math.floor((Date.now()/1000-b.at)/60):-1;
const ageTxt=(mins<0?'':(mins<1?'刚刚':mins+' 分钟前'));
const chg=b.charging?' ⚡':'';
const tip=(t===2?'电量严重不足':(t===1?'电量低':'电量正常'))
+(b.charging?'(充电中)':'')+(ageTxt?'('+ageTxt+'采集)':'')
+(dev.present?'':'(设备已离线,显示最后一次读数)');
const stale=dev.present?'':'<div style="font-size:11px;color:#94a3b8">'+ageTxt+'</div>';
return '<span class="label '+cls+'" title="'+tip+'">'+b.level+'%'+chg+'</span>'+stale;
}
function renderDeviceRow(dev){
const offline=dev.present?'':' <span class="label label-danger">离线</span>';
const st=STATUS_MAP[dev.worker_status]||{t:dev.worker_status,c:'default'};
@@ -366,7 +385,7 @@ function renderDeviceRow(dev){
const checked=_selectedSerials.has(dev.serial)?'checked':'';
const rowCls=_selectedSerials.has(dev.serial)?'row-checked':'';
const cb='<input type="checkbox" '+checked+' onclick="toggleDevice(\''+esc(dev.serial)+'\',this.checked)">';
return '<tr class="'+rowCls+'"><td class="col-cb">'+cb+'</td><td>'+devLabel+'</td><td>'+esc(dev.model)+'</td><td>'+wb+'</td><td>'+fg+'</td><td>'+prog+'</td><td>'+tname+retry+endInfo+'</td><td class="text-muted">'+esc(dev.current_action||'-')+warnInfo+'</td><td class="err-text" title="'+esc(dev.last_error||'')+'">'+esc(dev.last_error||'-')+'</td><td>'+btns+'</td></tr>';
return '<tr class="'+rowCls+'"><td class="col-cb">'+cb+'</td><td>'+devLabel+'</td><td>'+esc(dev.model)+'</td><td>'+_battCell(dev)+'</td><td>'+wb+'</td><td>'+fg+'</td><td>'+prog+'</td><td>'+tname+retry+endInfo+'</td><td class="text-muted">'+esc(dev.current_action||'-')+warnInfo+'</td><td class="err-text" title="'+esc(dev.last_error||'')+'">'+esc(dev.last_error||'-')+'</td><td>'+btns+'</td></tr>';
}
async function loadJobsForMonitor(){
+2 -1
View File
@@ -329,7 +329,8 @@ function openTaskModal(jobId, prefill){
// AI 草稿用一个唯一 key:既避开 localStorage 里"新建任务"的旧草稿覆盖预填内容,
// 又保留自动存草稿(保存成功时 saveTask 会 clearDraft 清掉它)。
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,
null,null,{dedup_reset:(j.params&&j.params.dedup_reset)||'day',dedup_hours:(j.params&&j.params.dedup_hours)||6});
patrolInit(j.params&&j.params.watchers);
document.getElementById('modal-footer').innerHTML=
+57
View File
@@ -43,6 +43,7 @@ async function loadToolsDevices(force){
// ================== 设备自动发现(扫描 → 待连接池 → 确认) ==================
function loadDiscovery(){
loadBattery(); // 电量监控与发现同住一个子面板,跟着它的 10s 轮询一起刷新状态行
apiGet('/api/devices/discovery').then(r=>{
if(!r||!r.ok)return;
const status=document.getElementById('discovery-status');
@@ -116,6 +117,62 @@ function loadDiscovery(){
}
});
}
// ================== 设备电量监控(定时采集 + 低电量告警) ==================
// 电量本身随 /api/status 下发(监控页电量列 / 大屏卡片),这里只管配置与采集器状态。
let _batteryLoaded=false; // 表单只回填一次:10s 轮询不能把用户正在输的值冲掉
function loadBattery(){
apiGet('/api/devices/battery').then(r=>{
if(!r||!r.ok)return;
const en=document.getElementById('battery-enabled');
if(!en)return; // 面板不在页面上
const s=r.settings||{};
if(!_batteryLoaded){
en.checked=!!s.enabled;
document.getElementById('battery-low').value=s.low;
document.getElementById('battery-critical').value=s.critical;
document.getElementById('battery-skip-charging').checked=!!s.skip_charging;
document.getElementById('battery-interval').value=s.interval;
_batteryLoaded=true;
}
const st=document.getElementById('battery-status');
if(r.scanning)st.textContent='采集中...';
else if(r.last_scan)st.textContent='上次采集 '+r.last_scan[0]+' · 成功 '+r.last_scan[1]+'/'+r.last_scan[2]+' 台';
else st.textContent='尚未采集';
});
}
function saveBatterySettings(){
const body={
enabled:document.getElementById('battery-enabled').checked,
low:document.getElementById('battery-low').value,
critical:document.getElementById('battery-critical').value,
skip_charging:document.getElementById('battery-skip-charging').checked,
interval:document.getElementById('battery-interval').value,
};
apiPost('/api/devices/battery/settings',body).then(r=>{
if(!r||!r.ok){showToast('保存失败: '+((r&&r.error)||''),'error');return;}
showToast(r.msg||'已保存','success');
// 回填后端钳制后的真实值(填 999 会被钳到 100,严重阈值高于低电量会被压回来)
const s=r.settings;
if(s){
document.getElementById('battery-low').value=s.low;
document.getElementById('battery-critical').value=s.critical;
document.getElementById('battery-interval').value=s.interval;
}
});
}
function scanBatteryNow(){
apiPost('/api/devices/battery/scan',{}).then(r=>{
if(r&&r.ok){
showToast(r.msg||'采集已启动','success');
setTimeout(loadBattery,3000);
if(typeof loadMonitor==='function')setTimeout(loadMonitor,3000); // 监控页设备表跟着更新
}else showToast('采集失败: '+((r&&r.error)||''),'error');
});
}
function reconnectPoolDev(serial){
apiPost('/api/devices/discovery/reconnect',{serial}).then(r=>{
if(r&&r.ok){
+260 -7
View File
@@ -41,7 +41,7 @@ from core.device_worker import BaseWorker, _update_status
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 import notifier, step_log, humanize, patrol, gesture
from core import notifier, step_log, humanize, patrol, gesture, dedup
_log = get_logger("task.generic")
@@ -113,7 +113,11 @@ STEP_TYPES = [
"params": {"children": []}},
{"type": "if_el", "label": "条件判断", "icon": "❓",
"params": {"selector_type": "xpath", "selector_value": "", "timeout": 3,
"cmp_op": "", "cmp_value": "",
"ident_type": "text", "ident_value": "",
"then": [], "else": []}},
{"type": "mark_done", "label": "记为已做", "icon": "✅",
"params": {"selector_type": "text", "selector_value": ""}},
{"type": "notify", "label": "发通知", "icon": "🔔",
"params": {"title": "", "message": "", "level": "info"}},
{"type": "stop_self", "label": "停止本设备", "icon": "⛔",
@@ -125,10 +129,88 @@ STEP_TYPES = [
DEFAULT_PARAMS = {
"max_duration": 0, # 最大运行时长(秒),0=不限时
# 去重账本的有效期(任务级,**只在这一处配**)。检查与记账两处各填一份的话,
# 填不一致就会算出两个不同的 key、去重静默失效——所以强制任务级。
# day(默认,每天一次)/ all(只做一次,永不清理)/ hours(每 dedup_hours 小时)
"dedup_reset": "day",
"dedup_hours": 6,
# 注意:**没有默认 steps**。步骤由前端步骤编辑器编排产出,新建任务时从零开始拖;
# 缺 steps 的任务执行时会明确报错(见 run_task),不会静默空跑。
}
# 「条件判断」文本比对时读元素文本的超时(秒):只读一次属性,正常几十毫秒
_TEXT_READ_TIMEOUT = 2
# 「条件判断」的文本比对运算符:中文为主(界面上就是这个),英文/符号也认,
# 方便 AI 建任务或手写 JSON 时不用猜用哪个词。
_CMP_ALIASES = {
"等于": ("等于", "==", "=", "eq", "equals", "equal"),
"不等于": ("不等于", "!=", "<>", "ne", "not_equal", "notequal"),
"包含": ("包含", "含", "contains", "in", "has"),
"不包含": ("不包含", "不含", "not_contains", "notcontains", "excludes"),
}
# 界面上给的运算符(顺序即下拉框顺序);上面那张表另收了英文/符号别名。
# AI 草稿校验(core/task_draft.py)用它拦"运算符拼错 -> 永远不命中"。
CMP_OPS = tuple(_CMP_ALIASES)
def _canon_op(op):
"""运算符规范化成中文那个(认别名);认不出返回 None。"""
key = (op or "").strip().lower()
for canon, aliases in _CMP_ALIASES.items():
if key in [a.lower() for a in aliases]:
return canon
return None
def split_cmp_values(raw):
"""把"比对的值"拆成多个候选(**任一命中即可**,即 OR 语义)。
分隔符只有两个:**换行** 和 `|`。
**刻意不拆英文/中文逗号**——要比对的文本本身就常常带逗号
(如 `抖音号:123,456`),拆了就永远匹配不上。界面上是一行一个。
"""
if raw is None:
return []
s = str(raw).replace("\r\n", "\n").replace("\r", "\n").replace("|", "\n")
return [v.strip() for v in s.split("\n") if v.strip()]
def _cmp_hit(got, op, want):
"""核心比对:返回 (是否命中, 命中的那个值/惹事的值)。
多个候选值之间是 **OR**("这个号或那个号都算我"):
· 等于 → 文本等于**任一**候选即命中
· 包含 → 文本含**任一**候选即命中
· 不等于 → 文本**不在**任何候选里才命中
· 不包含 → **一个**候选都不出现才命中
认不出的运算符、或候选全为空 → 一律不命中("看不懂就算不命中",比猜一个更好排查)。
"""
got = (got or "").strip()
wants = split_cmp_values(want)
canon = _canon_op(op)
if canon is None or not wants:
return False, ""
if canon == "等于":
hit = got in wants
return hit, (got if hit else "")
if canon == "包含":
for v in wants:
if v in got:
return True, v
return False, ""
if canon == "不等于":
return (got not in wants), ""
for v in wants: # 不包含
if v in got:
return False, v # 命中了"不该出现"的那个 -> 条件不成立
return True, ""
def _text_matches(got, op, want):
"""元素文本比对(布尔版,多处调用只看结果)。"""
return _cmp_hit(got, op, want)[0]
class GenericStepsWorker(BaseWorker):
"""通用步骤 worker:按 params.steps 顺序执行步骤链。
@@ -149,6 +231,10 @@ class GenericStepsWorker(BaseWorker):
self._path = []
self._step_rows = 0 # 本次运行已记录的明细条数(封顶见 _record_step)
self._step_capped = False
# 「去重」条件最近一次解析出的身份:`mark_done` 留空时复用它,
# 保证"检查"与"记账"用的是**同一个身份字符串**(否则 key 对不上、去重失效)
self._last_ident = ""
self._dedup_seen = False # 本轮是否跑过「去重」检查(区分"没配"与"配了但没读到")
# 公共巡检(任务级配置,独立于步骤画布;见 core/patrol.py):
# _wid 内部标识,用来记"下次到点 / 上次命中"两个节奏
self._watchers = []
@@ -576,20 +662,51 @@ class GenericStepsWorker(BaseWorker):
selector_type=ocr 时用屏幕 OCR 匹配文字(图片/画布/WebView 里的文字也能找到),
命中且 ocr_click=True 时自动点击该文字中心。
分支里可放任意步骤(含循环/嵌套条件判断),实现"有弹窗就关掉、没弹窗就继续"这类逻辑。
**文本比对(cmp_op / cmp_value,可选)**:光"元素在不在"不够用——常见需求是
"这台机器上登录的抖音号是不是我配的那个"。填了 cmp_op 就变成:
先按选择器**取到元素的文本**,再拿它和 cmp_value 比(等于/不等于/包含/不包含),
比对结果才是命中与否。例:`resourceId=com.ss.android.ugc.aweme:id/506` 的文本是
`抖音号:35377983067`,cmp_op=包含、cmp_value=35377983067 就是"这台上登录的是它"。
`cmp_value` **可以填多个值**(换行或 `|` 分隔,见 `split_cmp_values`),
多个之间是 **OR**——"这几个号都算我的"。要跑多个号的场景就靠它。
`selector_type="dedup"` 时换成**去重判断**(跨设备幂等,见 `core/dedup.py`):
"这个身份在这个任务里做过了吗"——命中=做过。身份元素填在 `ident_value`
(留空则用设备 serial,适合一号一机);有效期取任务级 `dedup_reset`。
多台手机跑同一任务时,任何一台做过了,其余设备的这一步都会命中。
返回 True=命中 / False=未命中 / None=缺选择器或 OCR 不可用。
"""
sel_type = params.get("selector_type", "xpath")
sel_val = params.get("selector_value", "")
timeout = float(params.get("timeout", 3))
if not sel_val:
cmp_op = str(params.get("cmp_op") or "").strip()
cmp_val = str(params.get("cmp_value") or "")
# 去重条件不用 selector_value(身份元素在 ident_value 里),所以别按"缺选择器"拦
if not sel_val and sel_type != "dedup":
_log.warning(f"[{self.serial}] if_el 缺少选择器,跳过")
return None
found = False
# 非元素类条件(屏幕状态/前台 App):不参与"选择器健康"统计——它们不是选择器,
got = None # 比对用的"采集到的值":元素文本 / OCR 命中文本 / 前台包名
# 非元素类条件(屏幕状态/前台 App/去重):不参与"选择器健康"统计——它们不是选择器,
# 连续未命中不该被记成"选择器失效"
dynamic = sel_type in ("screen", "foreground")
dynamic = sel_type in ("screen", "foreground", "dedup")
detail = ""
if sel_type == "screen":
if sel_type == "dedup":
# 去重条件:**这个身份在这个任务里做过了吗**(跨设备共享一份账本,
# 见 core/dedup.py)。命中=做过 → 走 then 分支(通常放"跳过/停止/发通知")。
kind, hours = self._dedup_policy()
self._dedup_seen = True # 供「记为已做」区分"没配检查"与"配了但没读到"
ident = self._dedup_identity(d, params, "ident_type", "ident_value")
if ident is None:
found = False # 身份判定不了 → 当"没做过",照常往下做(宁可重复不误拦)
detail = "去重:身份读不到,按未做过处理"
else:
self._last_ident = ident # 供后面「记为已做」复用(见 _exec_mark_done)
found = dedup.check(self.ctx.get("job_id", ""), ident, kind, hours)
detail = f"去重:身份『{ident}』{'做过' if found else '没做过'}(有效期 {kind})"
elif sel_type == "screen":
# 屏幕亮/熄:selector_value 填 on / off(也认 熄屏/灭屏 这类中文)
want = str(sel_val).strip().lower()
want_off = want in ("off", "0", "false", "no", "熄屏", "灭屏", "黑屏")
@@ -599,6 +716,7 @@ class GenericStepsWorker(BaseWorker):
f"(判断{'熄屏' if want_off else '亮屏'})")
elif sel_type == "foreground":
cur = current_package(d)
got = cur or ""
found = cur == str(sel_val).strip()
detail = f"前台='{cur or '未知'}'(期望 {sel_val})"
elif sel_type == "ocr":
@@ -614,6 +732,7 @@ class GenericStepsWorker(BaseWorker):
_log.warning(f"[{self.serial}] OCR 截图失败")
else:
found, center, matched = find_on_screen(img, sel_val)
got = matched or "" if found else ""
if found:
if params.get("ocr_click") and center:
d.click(*center)
@@ -634,16 +753,150 @@ class GenericStepsWorker(BaseWorker):
except Exception as e:
_log.warning(f"[{self.serial}] 条件判断异常: {e}")
found = False
# 元素"在不在"的原始结果:选择器健康统计要用它、**不能**用比对结果——
# "找到了但文本不对"是条件按预期走了 else 分支,不是选择器失效。
existed = found
# ---- 文本比对(可选)----
if cmp_op and sel_type not in ("screen", "dedup"):
if not found:
# 元素都没找到就没什么可比的:算未命中(走 else 分支)。
# 这条规则很重要——"没读到"永远不能被当成"和我设的不一样",
# 否则界面还没加载出来时会误判成"账号被别人换了"。
nofind = f"未找到目标,无法比对({cmp_op} {cmp_val})"
detail = f"{detail};{nofind}" if detail else nofind
else:
if got is None:
got = self._read_element_text(d, sel_type, sel_val)
hit, hit_val = _cmp_hit(got, cmp_op, cmp_val)
n_val = len(split_cmp_values(cmp_val))
if hit:
extra = f",命中『{hit_val}』" if hit_val else ""
else:
extra = f",因为出现了『{hit_val}』" if hit_val else ""
detail = (f"采集到 '{got}' {cmp_op} {n_val} 个候选值 → "
f"{'符合' if hit else '不符合'}{extra}")
found = hit
elif cmp_op and str(cmp_val or "").strip() and sel_type in ("screen", "dedup"):
# 只在**真的配了比对的值**时才提醒:从元素类切到「去重/屏幕状态」时
# 界面上那块是隐藏的,旧值会留在 params 里,那种残留每轮都刷警告没意义
_log.warning(f"[{self.serial}] {sel_type} 没有文本可比,已忽略文本比对设置")
if dynamic:
_log.info(f"[{self.serial}] 条件判断 {detail} → {'命中' if found else '未命中'}")
else:
self._track_selector_health(sel_val, found)
self._track_selector_health(sel_val, existed)
branch = params.get("then" if found else "else") or []
self.set_action(f"条件{'命中' if found else '未命中'} → 执行{'找到' if found else '未找到'}分支({len(branch)}步)")
if cmp_op:
cmp_txt = "比对命中" if found else "比对未命中"
else:
cmp_txt = "命中" if found else "未命中"
self.set_action(f"条件{cmp_txt} → 执行{'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)
return found
def _dedup_policy(self):
"""任务级去重有效期(`dedup_reset` / `dedup_hours`)→ (kind, hours)。
**刻意放任务级**:检查与记账两处各填一份的话,填不一致就会算出两个不同的 key、
去重静默失效(最难查的那种 bug)。认不出的值一律回退 `day`。
"""
p = self.params or {}
kind = str(p.get("dedup_reset") or dedup.KIND_DAY).strip()
if kind not in dedup.RESET_KINDS:
kind = dedup.KIND_DAY
try:
hours = int(p.get("dedup_hours") or dedup.DEFAULT_HOURS)
except (TypeError, ValueError):
hours = dedup.DEFAULT_HOURS
return kind, max(1, hours)
def _dedup_identity(self, d, params, tkey, vkey):
"""算去重身份:填了身份元素就读它的文本;留空则用设备 serial(一号一机场景)。
返回 None = **无法判定**(读不到值)→ 调用方必须"不去重"。
宁可漏拦一次,也不能把"读不到"当成空身份——那会让所有设备共用一个 key,
第一台记账之后其余全被误跳过(同一个号一台都没做,却被判"做过")。
"""
sel_val = (params.get(vkey) or "").strip()
if not sel_val:
return self.serial
txt = self._read_element_text(d, params.get(tkey) or "text", sel_val)
if not txt:
_log.warning(f"[{self.serial}] 去重身份元素读不到值({sel_val})→ 本次不去重")
return None
return txt
def _exec_mark_done(self, d, params, depth=0):
"""「记为已做」:把身份值记进去重账本(跨设备幂等)。
**放在动作成功之后**:动作失败就不记账,下次重跑会重试该设备——
拆成"检查在前、记账在后"两步,天然就是"成功才记账、失败不丢"。
身份读不到时**不记账**(记一个空身份会把别人挡住)。
**身份留空 = 复用前面「去重」条件刚解析出的那个身份**(推荐这样配):
检查与记账必须用**同一个身份字符串**,key 才算得到一起;让用户在两处各配一遍
元素,一旦不一致(填了一个、另一个忘了)去重就静默失效——这是最容易踩的坑,
所以默认行为改成"自动跟随上面的检查"。
"""
kind, hours = self._dedup_policy()
if (params.get("selector_value") or "").strip():
ident = self._dedup_identity(d, params, "selector_type", "selector_value")
elif self._last_ident:
ident = self._last_ident
_log.info(f"[{self.serial}] 记为已做:复用上面去重检查的身份『{ident}』")
elif self._dedup_seen:
# 上面**跑了**去重检查、但身份读不到(元素没出来/页面没到)。
# 这时**绝不能**退回设备身份记一笔:那会在"什么都没做成"的情况下
# 把设备标成已做。不记 → 下次重跑会重试。
ident = None
_log.warning(f"[{self.serial}] 记为已做:上面去重检查没读到身份(页面没到?)"
f"→ 本次不记账,下次重跑会重试")
else:
# 任务里根本没有前置去重检查、又没填身份元素 → 退回设备身份(一号一机场景)
ident = self.serial
_log.info(f"[{self.serial}] 记为已做:没填身份元素也没有前置去重检查 → "
f"按设备身份记账({ident})")
if not ident:
self.set_action("记为已做:身份读不到 → 本次不记(下次重跑会重试)")
return True
first = dedup.mark(self.ctx.get("job_id", ""), ident, kind, hours,
serial=self.serial,
device_name=self.ctx.get("device_name", ""),
job_name=self.ctx.get("job_name", ""))
who = "首次" if first else "已存在(别处先记过)"
_log.info(f"[{self.serial}] 记为已做:身份『{ident}』 → {who}")
self.set_action(f"记为已做:『{ident}』{who}")
return True
def _read_element_text(self, d, sel_type, sel_val):
"""取选中元素的文本(文本比对用)。读不到返回 ""。
只读操作,不改任何状态;失败静默返回空串,由调用方按"空文本"去比对
(日志里会打出实际读到的值,排查时能看出是空还是不对)。
注意 u2 两种元素的 exists 语义不同,别"统一"掉:
· `d.xpath(...)` → XPathSelector.exists 是**纯 bool 属性**
· `d(text=...)` → UiObject.exists 是**可调用的 Exists 对象**
(`bool()` 即时判断、`exists(timeout=t)` 带等待)——两种写法都对
"""
try:
if sel_type == "xpath":
el = d.xpath(sel_val)
if not el.exists:
return ""
return (el.get_text() or "").strip()
el = d(**{sel_type: sel_val})
if not el.exists:
return ""
# 限时读:元素在"检查"与"读"之间消失时,别挂满默认等待(默认可能是 10s)
return (el.get_text(timeout=_TEXT_READ_TIMEOUT) or "").strip()
except Exception as e:
_log.warning(f"[{self.serial}] 读取元素文本失败(按空文本比对): {e}")
return ""
def _exec_swipe(self, d, params, depth=0):
"""滑动一次(默认拟人:弧线 + 抖动 + 本设备手速,见 core/humanize.py)。
+62 -1
View File
@@ -309,7 +309,8 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
.el-device-card:hover{border-color:var(--primary);box-shadow:var(--shadow-md);transform:translateY(-1px)}
.el-device-card .edc-icon{width:36px;height:36px;background:#f1efe8;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);font-family:var(--mono)}
.el-device-card .edc-name{font-size:13.5px;font-weight:700;color:var(--text)}
.el-device-card .edc-serial{font-size:12px;color:var(--text-light);margin-top:2px;font-family:var(--mono)}
.el-device-card .edc-model{font-size:12px;color:var(--text-light);margin-top:2px}
.el-device-card .edc-status{font-size:11px;padding:2px 10px;border-radius:99px;background:var(--success-soft);color:#15803d;flex-shrink:0;font-weight:600}
.el-picker-main{display:flex;flex:1;overflow:hidden}
@@ -553,6 +554,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
<th class="col-cb"><input type="checkbox" id="cb-all" onclick="toggleAllDevices(this.checked)" title="全选/取消全选"></th>
<th data-sort="serial" onclick="_lpSort('devices','serial')">设备</th>
<th data-sort="model" onclick="_lpSort('devices','model')">型号</th>
<th data-sort="battery" onclick="_lpSort('devices','battery')" title="后台每分钟采集一次(dumpsys battery);颜色按告警档位上色,阈值在「工具 → 设备发现 → 电量监控」里配">电量</th>
<th data-sort="worker_status" onclick="_lpSort('devices','worker_status')">任务状态</th>
<th data-sort="foreground_app" onclick="_lpSort('devices','foreground_app')">前台App</th>
<th data-sort="progress" onclick="_lpSort('devices','progress')">进度</th>
@@ -588,6 +590,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
<button class="sub-tab active" data-sub="plan" onclick="showSubTab('tasks','plan')">任务计划</button>
<button class="sub-tab" data-sub="actions" onclick="showSubTab('tasks','actions')">自定义动作</button>
<button class="sub-tab" data-sub="actioncfg" onclick="showSubTab('tasks','actioncfg')">动作配置</button>
<button class="sub-tab" data-sub="dedup" onclick="showSubTab('tasks','dedup')">去重记录</button>
</div>
<!-- 子分栏 1:任务计划 -->
@@ -847,6 +850,32 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
</div>
<div id="act-lib-list" style="flex:1;overflow-y:auto;min-height:200px;display:flex;flex-direction:column;gap:8px"></div>
</div>
<!-- 子分栏 4:去重记录(done_mark 表 = 跨设备"已做过"账本) -->
<div class="sub-panel" id="tasks-sub-dedup">
<div class="help">
<b>去重账本</b>:任务里用「条件判断 → 去重」+「记为已做」两个步骤,把"这个身份做过了"记在这里,
<b>所有设备共享一份</b>——所以多个手机跑同一个任务、反复重跑,也不会把同一个号做两次。<br>
<span class="text-muted">想让它重跑:删掉对应那条(单个号/单台设备)或「清空本任务记录」。
有效期在任务编辑器的<b>「去重有效期」</b>里设(每天一次 / 只做一次 / 按小时);「只做一次」的记录不会被自动清理。</span>
</div>
<div class="toolbar" style="gap:10px;flex-wrap:wrap">
<select class="form-control" id="dedup-job" style="width:280px" onchange="loadDedup()">
<option value="">全部任务</option>
</select>
<button class="btn" onclick="loadDedup()">刷新</button>
<button class="btn btn-danger" onclick="clearJobDedup()" data-perm="tasks">清空本任务记录</button>
<span class="text-muted" id="dedup-stats"></span>
</div>
<table class="table">
<thead><tr>
<th style="width:160px">时间</th><th style="width:90px">设备</th>
<th style="width:150px">身份值</th><th style="width:110px">有效期</th>
<th>任务</th><th style="width:120px">操作</th>
</tr></thead>
<tbody id="tb-dedup"><tr><td colspan="6" class="empty">暂无记录</td></tr></tbody>
</table>
</div>
</div>
<!-- ========== Tab 4: 日志 ========== -->
@@ -1205,6 +1234,37 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
</table>
</div>
</div>
<!-- 电量监控:定时采集设备电量(大屏 + 监控页设备列表展示),低于阈值推 webhook 通知 -->
<div style="border-bottom:1px solid var(--border);margin-bottom:10px;padding-bottom:10px">
<div class="toolbar" style="flex-wrap:wrap">
<label style="display:inline-flex;align-items:center;gap:4px">
<input type="checkbox" id="battery-enabled" onchange="saveBatterySettings()"> 电量监控
</label>
<span class="text-muted" style="margin-left:8px">低电量</span>
<input type="number" id="battery-low" style="width:70px" min="1" max="100"
title="电量低于这个值就告警">
<span class="text-muted">% · 严重</span>
<input type="number" id="battery-critical" style="width:70px" min="1" max="100"
title="低于这个值时通知等级升到「严重」(error)。不能高于低电量阈值。">
<span class="text-muted">%</span>
<label style="display:inline-flex;align-items:center;gap:4px;margin-left:10px"
title="充电中(status=充电中/已充满)不告警——插着充电器说明正在补电。注意「插着却没充电」(劣质线/温控/满电停充)仍会告警,那正是最该知道的情况。">
<input type="checkbox" id="battery-skip-charging"> 充电中不告警
</label>
<span class="text-muted" style="margin-left:8px">采集间隔</span>
<input type="number" id="battery-interval" style="width:70px" min="30" max="3600">
<span class="text-muted">秒</span>
<button class="btn" onclick="saveBatterySettings()">保存设置</button>
<button class="btn" onclick="scanBatteryNow()">立即采集</button>
<span id="battery-status" class="text-muted"></span>
</div>
<div class="help">定时用 <code>dumpsys battery</code>(只读)采集池内在线设备的电量,显示在
<b>「监控 → 设备列表」的电量列</b>与<b>大屏卡片</b>上。<br>
低于阈值时推 webhook 通知(事件 <code>device.battery.low</code>,要在「系统 → 通知」里勾选订阅才发):
<b>掉到低电量报一次、掉到严重再报一次、恢复再报一次</b>,不做重复刷屏。<br>
电量只放内存、<b>不落库</b>(重启后重新采一轮),所以不占数据库、不影响备份。
设备离线时显示最后一次读数(置灰),不会变成空白。</div>
</div>
<div class="toolbar">
<input type="text" id="devpool-serial" class="form-control" style="width:220px;font-family:monospace"
placeholder="serial,如 100.100.10.20:5555" onkeydown="if(event.key==='Enter')addPoolDev()">
@@ -1420,5 +1480,6 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
<script src="/static/admin/notify.js"></script>
<script src="/static/admin/steplog.js"></script>
<script src="/static/admin/recorder.js"></script>
<script src="/static/admin/dedup.js"></script>
</body>
</html>
+34 -3
View File
@@ -73,6 +73,17 @@ main{position:relative;z-index:2;height:calc(100vh - 88px);overflow-y:auto;paddi
.card .info .name{font-size:13.5px;font-weight:700;color:#f1f4f8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.card .info .ip{font-family:var(--mono);font-size:12px;color:var(--muted);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.card .info .model{font-size:10.5px;color:var(--faint);margin-top:2px}
/* 型号 + 电量同一行(省一行高度,卡片本来就矮) */
.card .info .meta{display:flex;align-items:center;gap:8px;margin-top:2px;white-space:nowrap;overflow:hidden}
.card .info .meta .model{margin-top:0;overflow:hidden;text-overflow:ellipsis}
.card .info .batt{margin-left:auto;display:inline-flex;align-items:center;gap:2px;font-size:11px;color:var(--faint);flex:0 0 auto}
.card .info .batt .lv{font-family:var(--mono);font-weight:700}
.card .info .batt.ok .lv{color:var(--green)}
.card .info .batt.low .lv{color:var(--amber)}
.card .info .batt.crit .lv{color:var(--red)}
.card .info .batt.crit{animation:pulse 1.6s infinite}
.card .info .batt.stale{opacity:.4}
.card .info .batt .chg{color:var(--teal)}
.card .info .action{font-size:11.5px;color:var(--muted);margin-top:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-height:16px}
.card .info .action b{color:var(--amber);font-weight:600}
.card .info .prog{margin-top:7px;height:4px;background:#1d242e;border-radius:99px;overflow:hidden}
@@ -202,8 +213,8 @@ async function refreshStatus(){
const r=await apiGet('/api/status');
if(!r||!r.ok)return;
const devs=(r.devices||[]).sort((a,b)=>a.serial.localeCompare(b.serial));
const changed=JSON.stringify(devs.map(d=>[d.serial,d.present,d.worker_status,d.task_job,d.current_action,d.progress,d.device_name,d.model]))!==
JSON.stringify(_devices.map(d=>[d.serial,d.present,d.worker_status,d.task_job,d.current_action,d.progress,d.device_name,d.model]));
const changed=JSON.stringify(devs.map(d=>[d.serial,d.present,d.worker_status,d.task_job,d.current_action,d.progress,d.device_name,d.model,d.battery]))!==
JSON.stringify(_devices.map(d=>[d.serial,d.present,d.worker_status,d.task_job,d.current_action,d.progress,d.device_name,d.model,d.battery]));
_devices=devs;
renderStats(devs);
if(changed)renderGrid(visibleDevs(devs));
@@ -249,6 +260,22 @@ function fmtProgress(p){
}
function shortIp(serial){return String(serial||'').replace(/:5555$/,'');}
// 电量:告警档位(tier 0 正常/1 低/2 严重)由后端算好,前端只上色——
// 阈值只在「工具 → 设备发现 → 电量监控」一处定义,前端不重复判,免得两边不一致
function battHtml(d){
const b=d.battery;
if(!b)return '<span class="batt">🔋<span class="lv">—</span></span>';
const t=b.tier||0;
const cls=t===2?'crit':(t===1?'low':'ok');
const chg=b.charging?'<span class="chg" title="充电中">⚡</span>':'';
const mins=b.at?Math.floor((Date.now()/1000-b.at)/60):-1;
const minsTxt=(mins<0?'未知':(mins<1?'刚刚':mins+' 分钟前'));
const tip=(t===2?'电量严重不足':(t===1?'电量低':'电量正常'))
+'('+minsTxt+'采集'+(d.present?'':',设备已离线,显示最后一次读数')+')';
return '<span class="batt '+cls+(d.present?'':' stale')+'" title="'+tip+'">🔋'
+'<span class="lv">'+b.level+'%</span>'+chg+'</span>';
}
function cardHtml(d, i){
const cls=cardCls(d);
const serial=esc(d.serial);
@@ -268,7 +295,8 @@ function cardHtml(d, i){
'<div class="info">'+
'<div class="name">'+name+'</div>'+
'<div class="ip">'+ip+'</div>'+
'<div class="model">'+esc(d.model||'')+'</div>'+
'<div class="meta"><span class="model">'+esc(d.model||'')+'</span>'
+'<span class="batt-wrap">'+battHtml(d)+'</span></div>'+
action+fmtProgress(d.progress)+
'<div class="hint">点击进入设备操作</div>'+
'</div>'+
@@ -306,6 +334,9 @@ function renderGrid(devs){
card.querySelector('.name').textContent=d.device_name||d.model||'设备';
card.querySelector('.ip').textContent=shortIp(serial);
card.querySelector('.model').textContent=d.model||'';
// 电量(与型号同一行,见 .meta)
const bw=card.querySelector('.batt-wrap');
if(bw)bw.innerHTML=battHtml(d);
const action=d.current_action
?(d.task_job?'<b>'+esc(d.task_job)+'</b> · ':'')+esc(d.current_action)
:(d.task_job?'<b>'+esc(d.task_job)+'</b>':'');
+35 -1
View File
@@ -4,7 +4,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
from flask import Blueprint, jsonify, request
from core import device_pool, device_discovery
from core import device_battery, device_pool, device_discovery
from core.adb_helper import adb_connect, adb_connect_light
from core.logger import get_logger
from web.auth import perm_required, PERM_DEVICES
@@ -411,6 +411,40 @@ def api_devices_discovery_settings():
return jsonify({"ok": True, "msg": msg})
# ================== API:设备电量(采集/阈值/告警) ==================
# 电量本身通过 /api/status 随设备列表一起下发(大屏/监控同一份数据);
# 这里是它的配置与采集器状态。低电量告警走通知子系统(事件 device.battery.low)。
@bp.route("/api/devices/battery", methods=["GET"])
@perm_required(PERM_DEVICES)
def api_devices_battery():
"""电量采集器状态 + 当前配置(面板显示"上次采集 12:00 · 14/14 台")。"""
try:
return jsonify({"ok": True, **device_battery.get_status()})
except Exception as e:
_log.warning(f"电量状态获取失败: {e}")
return jsonify({"ok": False, "error": str(e)[:120]}), 503
@bp.route("/api/devices/battery/settings", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_battery_settings():
"""保存电量监控配置:{enabled?, low?, critical?, skip_charging?, interval?}。"""
data = request.json or {}
ok, msg, saved = device_battery.save_settings(data)
if not ok:
return jsonify({"ok": False, "error": msg}), 400
return jsonify({"ok": True, "msg": msg, "settings": saved})
@bp.route("/api/devices/battery/scan", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_battery_scan():
"""立即采集一次(后台执行,约 1-3 秒;看「上次采集」或大屏刷新确认)。"""
if not device_battery.scan_once():
return jsonify({"ok": False, "error": "上一轮采集还没跑完,稍等一下"}), 409
return jsonify({"ok": True, "msg": "采集已启动(约 1-3 秒)"})
# ================== API:Tailscale 管理(仅管理员) ==================
# 通过 Tailscale 官方 API v2 管理 tailnet 设备(列表/改名/授权/密钥不过期/删除/生成 auth key)。
# 设备 IP 由 tailnet 自动分配,API 无法修改,列表只读展示。
+6 -1
View File
@@ -6,8 +6,13 @@ import subprocess
import shlex
from concurrent.futures import ThreadPoolExecutor, as_completed
from flask import (Blueprint, jsonify, request, Response, render_template)
from flask import (Blueprint, jsonify, request, Response, render_template,
render_template_string)
from flask_login import login_required
# /locate 定位页要把 serial 拼进 HTML,而 render_template_string 的模板名是
# "<template>"、**不会自动转义**(serial 来自 query,不转义就是一个反射 XSS),
# 所以这里显式转义。blueprint 拆分时漏掉了这两个 import,/locate 一直 500。
from markupsafe import escape as _esc
from core import device_pool
from core.adb_helper import (screenshot, list_installed_apps, adb_connect,
+61 -1
View File
@@ -9,7 +9,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError as
from flask import (Blueprint, jsonify, request, Response)
from flask_login import login_required, current_user
from core import device_pool, uiauto_helper, step_defaults
from core import device_pool, uiauto_helper, step_defaults, dedup
from core.adb_helper import adb_connect_light
from core.models import db, DeviceGroup, TaskJob, CustomAction
from core.logger import get_logger
@@ -320,6 +320,56 @@ def api_step_defaults_save():
return jsonify({"ok": True, "msg": msg, **step_defaults.as_payload()})
# ================== API:去重记录(「任务 → 去重记录」页) ==================
#
# 账本在 done_mark 表(跨设备幂等,见 core/dedup.py);任务步骤怎么用它见
# tasks/generic/task.py 的 `_exec_mark_done` 与 if_el 的 `selector_type=dedup`。
@bp.route("/api/done_marks")
@login_required
def api_done_marks():
"""去重记录列表 + 统计(?job=<任务ID> 只看某个任务)。"""
job_id = (request.args.get("job") or "").strip()
try:
limit = int(request.args.get("limit") or 200)
except ValueError:
limit = 200
try:
rows, stats = dedup.list_marks(job_id, limit)
except Exception as e:
_log.warning(f"读取去重记录失败: {e}")
return jsonify({"ok": False, "error": str(e)[:120]}), 503
return jsonify({"ok": True, "marks": rows, "stats": stats, "job_id": job_id})
@bp.route("/api/done_marks/delete", methods=["POST"])
@perm_required(PERM_TASKS)
def api_done_marks_delete():
"""删一条记录 → 那个号/那台设备可以重跑。"""
data = request.json or {}
mark_id = data.get("id")
if mark_id in (None, ""):
return jsonify({"ok": False, "error": "缺少 id"}), 400
if not dedup.delete_mark(mark_id):
return jsonify({"ok": False, "error": "记录不存在或删除失败"}), 404
_log.info("删除去重记录 #%s by %s", mark_id, getattr(current_user, "username", ""))
return jsonify({"ok": True, "msg": "已删除(该设备/身份下次会重新执行)"})
@bp.route("/api/done_marks/clear", methods=["POST"])
@perm_required(PERM_TASKS)
def api_done_marks_clear():
"""清空某个任务的全部记录 → 整批重跑。"""
data = request.json or {}
job_id = (data.get("job") or "").strip()
if not job_id:
return jsonify({"ok": False, "error": "缺少任务ID"}), 400
n = dedup.clear_job(job_id)
_log.info("清空去重记录:任务 %s 共 %s 条 by %s", job_id, n,
getattr(current_user, "username", ""))
return jsonify({"ok": True, "msg": f"已清空 {n} 条记录(该任务下次会全部重跑)", "deleted": n})
# ================== API:用户管理 CRUD ==================
@bp.route("/api/custom_actions")
@@ -459,6 +509,16 @@ def api_uiauto_devices():
})
seen.add(serial)
added += 1
# 设备名用**平台起的名字**(A08 / cs1):uiautodev 那份 name 是设备自己的
# codename(实测一柜子机器全叫 "earth"),同型号好几台时等于没有——
# 而抓取元素/试执行必须选对设备,所以池外设备宁可退回地址(地址至少唯一)。
try:
names = {d["serial"]: (d.get("name") or "") for d in device_pool.list_devices()}
except Exception:
names = {}
for d in data:
s = d.get("serial") or ""
d["name"] = names.get(s) or s
if added:
_log.info(f"抓取元素设备列表补全 {added} 台在线设备: {list(seen)}")
return jsonify({"ok": True, "devices": data})
+26
View File
@@ -98,10 +98,19 @@ device_pool.init_app(app)
# 设备自动发现:定时扫描网段 → 待连接池(确认后才入池)
from core import device_discovery
device_discovery.init_app(app)
# 设备电量采集:定时 dumpsys battery(只读)→ 内存缓存 → /api/status 展示 + 低电量告警
from core import device_battery
device_battery.init_app(app)
# 去重账本(跨设备幂等):绑 app 供任务线程/Web/清理任务自推 context
from core import dedup
dedup.init_app(app)
mgr = TaskManager(app=app)
# 设备换地址(认领/迁址)后同步分组与任务的引用:它们的"内存副本"在 TaskManager 里
# (调度用内存对象),device_pool 只改库不改内存不生效,所以在此注册回调打通
device_pool.set_move_hook(mgr.sync_device_serial)
# 通知里要显示设备名(A08)而不是地址(IP):notifier 只读 device_pool 的**内存**名称
# 快照,不查库(notify 的红线是零 DB)。快照在 device_pool.init_app 里已刷过一次。
_notifier.set_device_name_resolver(device_pool.name_of)
apk_mgr = ApkManager(app=app)
# Web 层按模块拆分(web/ 蓝图包):认证/监控/任务/管理/工具/设备池/APK/Tailscale
@@ -128,6 +137,20 @@ def _purge_step_log_async():
threading.Thread(target=_purge_step_log, daemon=True).start()
def _purge_done_mark():
"""清理过期的去重记录(每日 04:23)。
**只清 day/hours 桶**:`kind='all'`("只做一次")的记录清了就等于去重失效,
见 core/dedup.purge_old。量级很小(设备数 × 天数),单条 DELETE 足够。
"""
try:
from core.dedup import purge_old
with app.app_context():
purge_old()
except Exception as e:
_log.warning(f"去重记录清理失败(不影响主服务): {e}")
# 经验库每日 AI 巡检(凌晨 03:47):评审标记疑似问题经验,删除只走人工确认。
# 巡检线程在 run_experience_audit 内自建 app context,不依赖这里。
try:
@@ -139,6 +162,8 @@ try:
# 步骤明细清理:超过保留期(core/step_log.KEEP_DAYS)的记录每天删一次。
# 不清理的话这张表是唯一无界增长的表,会一路顶大备份包。
_audit_sched.add_job(_purge_step_log, CronTrigger(hour=4, minute=13))
# 去重记录清理:只清 day/hours 桶(all 永不清理,清了等于"只做一次"失效)
_audit_sched.add_job(_purge_done_mark, CronTrigger(hour=4, minute=23))
_audit_sched.start()
_log.info("经验库每日巡检已注册(03:47 Asia/Shanghai)")
except Exception as e:
@@ -352,4 +377,5 @@ if __name__ == "__main__":
pass
mgr.shutdown()
device_discovery.shutdown()
device_battery.shutdown()
_stop_uiauto()