From 5c88cc638407a3deb1f601329fe00509941b5db1 Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Thu, 24 Sep 2026 10:40:08 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(=E6=9D=A1=E4=BB=B6=E5=88=A4=E6=96=AD):?= =?UTF-8?q?=20=E6=AF=94=E5=AF=B9=E7=9A=84=E5=80=BC=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=A4=9A=E4=B8=AA=EF=BC=88=E4=BB=BB=E4=B8=80=E5=91=BD=E4=B8=AD?= =?UTF-8?q?=E5=8D=B3=E5=8F=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求:`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 分支、日志含候选数与命中值。 --- doc/TASK_DEV.md | 16 ++++++-- static/admin/editor.js | 9 +++-- tasks/generic/task.py | 83 ++++++++++++++++++++++++++++++++---------- 3 files changed, 82 insertions(+), 26 deletions(-) diff --git a/doc/TASK_DEV.md b/doc/TASK_DEV.md index eff395c..093f0e7 100644 --- a/doc/TASK_DEV.md +++ b/doc/TASK_DEV.md @@ -244,18 +244,26 @@ from .generic import task # 触发 @register_task(当前唯一任务类型) `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", "timeout": 3, - "then": [{"type": "stop_self", "params": {"reason": "登录的不是目标抖音号"}}], + "cmp_op": "包含", + "cmp_value": "35377983067\n35377983068", // 多个号,任一命中即可 + "timeout": 3, + "then": [{"type": "stop_self", "params": {"reason": "登录的不是我的号"}}], "else": []}} ``` -要判断"**不是**这个号"就用 `不等于`(`cmp_op=不等于` 时"界面上是别的号"会命中,正好用来抓 -"号被换/掉登录")。 +要判断"**不是**我的号"就用 `不等于`("界面上是别的号"会命中,正好用来抓"号被换/掉登录")。 + +日志会写清"采集到什么、几个候选、命中的是哪个值",例如: +`采集到 '抖音号:35377983068' 包含 2 个候选值 → 符合,命中『35377983068』`。 **两条必须知道的规则**(都为了不误判): diff --git a/static/admin/editor.js b/static/admin/editor.js index 4557ce3..780bdc9 100644 --- a/static/admin/editor.js +++ b/static/admin/editor.js @@ -431,9 +431,11 @@ var _stepEditor={ [['','不比对(只看元素在不在)'],['等于','等于'],['不等于','不等于'],['包含','包含'],['不包含','不包含']] .map(function(o){return '';}).join('')+ ''+ - '
'+ - '
'; + '
'+ + ''+ + '
一行一个(也认 | 分隔):任意一个命中就算命中——多个号的场景把号都列上。'+ + '逗号不当分隔符,因为要比对的文本本身常带逗号
'; h+=_selRowHtml(p, (dyn?'':'
'+ '
'+ @@ -447,6 +449,7 @@ var _stepEditor={ +'
文本比对(可选):填了就把找到的那个元素的文本和"比对的值"比,' +'比中了才算命中。例:元素文本是 抖音号:35377983067,' +'想判断"这台登录的是不是这个号",选包含、值填 35377983067 即可(要整段一模一样才用"等于")。' + +'多个值一行一个,任一命中即可——"这几个号都算我的"。' +'
⚠ 元素没找到时一律算未命中(走"未找到时"分支)——"没读到"不会被当成"和我设的不一样"。')+'
', true); } // 共享:触发概率(所有步骤通用,0~100) diff --git a/tasks/generic/task.py b/tasks/generic/task.py index 69bbe51..016ccb4 100644 --- a/tasks/generic/task.py +++ b/tasks/generic/task.py @@ -145,26 +145,62 @@ _CMP_ALIASES = { CMP_OPS = tuple(_CMP_ALIASES) -def _text_matches(got, op, want): - """元素文本比对:got(采集到的)对 want(用户设的)做 op 判断。 - - op 见 `_CMP_ALIASES`(等于/不等于/包含/不包含)。认不出的 op 一律返回 False - ——条件判断"看不懂就算不命中",比猜一个更容易排查。 - """ - got = (got or "").strip() - want = (want or "").strip() +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]: - if canon == "等于": - return got == want - if canon == "不等于": - return got != want - if canon == "包含": - # 想匹配空串等于"永远命中",那是误配,当不命中处理 - return bool(want) and want in got - return bool(want) and want not in got - return False + 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): @@ -619,6 +655,9 @@ class GenericStepsWorker(BaseWorker): 先按选择器**取到元素的文本**,再拿它和 cmp_value 比(等于/不等于/包含/不包含), 比对结果才是命中与否。例:`resourceId=com.ss.android.ugc.aweme:id/506` 的文本是 `抖音号:35377983067`,cmp_op=包含、cmp_value=35377983067 就是"这台上登录的是它"。 + + `cmp_value` **可以填多个值**(换行或 `|` 分隔,见 `split_cmp_values`), + 多个之间是 **OR**——"这几个号都算我的"。要跑多个号的场景就靠它。 返回 True=命中 / False=未命中 / None=缺选择器或 OCR 不可用。 """ sel_type = params.get("selector_type", "xpath") @@ -698,8 +737,14 @@ class GenericStepsWorker(BaseWorker): else: if got is None: got = self._read_element_text(d, sel_type, sel_val) - hit = _text_matches(got, cmp_op, cmp_val) - detail = f"采集到 '{got}' {cmp_op} '{cmp_val}' → {'符合' if hit else '不符合'}" + 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 sel_type == "screen": _log.warning(f"[{self.serial}] 屏幕状态没有文本可比,已忽略文本比对设置") From 876224f876928e8134d2f7e72200c939b7207f87 Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Thu, 24 Sep 2026 10:55:29 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(=E5=8E=BB=E9=87=8D):=20=E8=B7=A8?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E3=80=8C=E5=B7=B2=E5=81=9A=E8=BF=87=E3=80=8D?= =?UTF-8?q?=E8=B4=A6=E6=9C=AC=20=E2=80=94=E2=80=94=20=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E5=8F=B7=E4=B8=8D=E4=BC=9A=E5=81=9A=E4=B8=A4=E6=AC=A1?= =?UTF-8?q?=20+=20=E3=80=8C=E8=B0=81=E5=81=9A=E8=BF=87=E4=BA=86=E3=80=8D?= =?UTF-8?q?=E7=9C=8B=E5=BE=97=E8=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户场景(他原话):一台手机登录 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 拉必然冲突——这份是超集,合一次两份都进。) --- README.md | 11 +- core/dedup.py | 235 +++++++++++++++++++++++++++++++++++ core/models.py | 34 +++++ core/system_backup.py | 1 + core/task_draft.py | 21 +++- doc/AI_CONSOLE.md | 6 +- doc/AI_TASK_GEN.md | 2 +- doc/API.md | 14 +++ doc/ARCHITECTURE.md | 11 +- doc/DATA_MODEL.md | 25 ++++ doc/DEPLOY.md | 8 +- doc/README.md | 2 +- doc/TASK_DEV.md | 65 +++++++++- static/admin/base.js | 1 + static/admin/dedup.js | 85 +++++++++++++ static/admin/editor.js | 137 +++++++++++++++++--- static/admin/tasks.js | 3 +- tasks/generic/task.py | 97 +++++++++++++-- templates/admin/monitor.html | 28 +++++ web/tasks_api.py | 52 +++++++- web_server.py | 19 +++ 21 files changed, 803 insertions(+), 54 deletions(-) create mode 100644 core/dedup.py create mode 100644 static/admin/dedup.js diff --git a/README.md b/README.md index 957c544..fee48bd 100644 --- a/README.md +++ b/README.md @@ -47,11 +47,12 @@ | **设备池** | 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 上传/解析/批量安装;设备已装应用与版本查询;剪贴板注入 | @@ -157,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 # 工具定义 + 平台登录 + 门控 @@ -283,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` 记为已做(配套去重) | 要点: @@ -405,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) | 部署与运维(含生产容器、备份导入、故障排查) | diff --git a/core/dedup.py b/core/dedup.py new file mode 100644 index 0000000..0942d7d --- /dev/null +++ b/core/dedup.py @@ -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 diff --git a/core/models.py b/core/models.py index bddeb68..63ce3e1 100644 --- a/core/models.py +++ b/core/models.py @@ -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) diff --git a/core/system_backup.py b/core/system_backup.py index 3ebbac8..ab99fa2 100644 --- a/core/system_backup.py +++ b/core/system_backup.py @@ -57,6 +57,7 @@ TABLE_LABELS = { "agent_experience": "经验库", "experience_audit": "经验巡检", "agent_action": "动作库", "device_install_log": "设备端安装记录", "task_step_log": "任务步骤明细", + "done_mark": "去重记录(已做过)", } _STAGE_TTL = 1800 # 导入暂存有效期(秒) diff --git a/core/task_draft.py b/core/task_draft.py index 1cdc408..7a25e50 100644 --- a/core/task_draft.py +++ b/core/task_draft.py @@ -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: 不能为空 —— " @@ -234,6 +236,17 @@ def validate_steps(steps, depth=1, path="steps", errors=None, warnings=None, 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) @@ -294,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} 字符)") diff --git a/doc/AI_CONSOLE.md b/doc/AI_CONSOLE.md index 5005238..b0bc4ca 100644 --- a/doc/AI_CONSOLE.md +++ b/doc/AI_CONSOLE.md @@ -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 diff --git a/doc/AI_TASK_GEN.md b/doc/AI_TASK_GEN.md index 424239e..7fd6e46 100644 --- a/doc/AI_TASK_GEN.md +++ b/doc/AI_TASK_GEN.md @@ -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。 diff --git a/doc/API.md b/doc/API.md index 21b989f..41773ea 100644 --- a/doc/API.md +++ b/doc/API.md @@ -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`) @@ -468,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. 设备池与自动发现 diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md index 87c3718..7ebe2f7 100644 --- a/doc/ARCHITECTURE.md +++ b/doc/ARCHITECTURE.md @@ -26,7 +26,7 @@ ┌───────────────────────────▼──────────────────────────────────────────┐ │ 领域层 task_manager(调度) device_worker(执行) device_pool(池) │ │ system_backup(备份) apk_manager(应用) device_discovery │ -│ device_battery(电量采集 + 低电量告警) │ +│ device_battery(电量采集 + 低电量告警) dedup(去重账本) │ └───────────────────────────┬──────────────────────────────────────────┘ │ ┌───────────────────────────▼──────────────────────────────────────────┐ @@ -80,13 +80,13 @@ |------|------|---------| | **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`(**刷一次 `serial→名称` 内存快照** + **起线程**:3s 后采集型号、另起 `device-names` 每 60s 刷名称);`device_discovery.init_app`(**起常驻扫描线程**);`device_battery.init_app`(**起常驻电量采集线程**);`TaskManager(app=app)`(APScheduler + 看门狗 + 从库加载分组/任务 + 重注册 cron);`ApkManager(app=app)`;`notifier.set_device_name_resolver(device_pool.name_of)`(通知里显示设备名而不是 IP,见 [NOTIFY.md](NOTIFY.md) §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`(**刷一次 `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 | +| **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`。 @@ -270,7 +270,7 @@ connecting ──获取设备──▶ u2 连接 ──▶ running ──▶ set ### 6.1 单页应用 -- 主页面 `templates/admin/monitor.html`:一个内联 `