feat(账号): 账号台账(web 页 + 任务取号 + 设备端身份页);fix(剪贴板): 注入通道改走设备端 Agent

一、账号台账(新表 device_account,schema v8)
- core/ledger.py:CRUD、Excel 粘贴解析(Tab 分隔 / 表头乱序缺列 / 是·否布尔 / 逐行留痕)、
  按范围取号(本机台账 / 全部 / 按设备分组)、设备端账号块、dry_run 预览
- 新「账号」顶级 Tab(权限 devices):列表 + 搜索 + 设备筛选 + 增删改 +
  粘贴导入(默认先预览再写,逐行显示 新增/覆盖/跳过/失败 + 留痕)
- 设备池加「账号 N」列(0 个显示"未登记"),点开看该设备账号明细
- 任务「条件判断」新增 cmp_source / cmp_group:候选值可直接来自台账,
  与手填值**合并(OR)**;取不到号时回落手填值,并把原因写进步骤明细与日志
  (否则"永远走 else 分支"而任务照样显示成功,最难查)
- 设备端契约:身份页多推 accounts_b64(base64 JSON;默认不含手机号;
  超预算按整条丢且绝不算字节切),见 doc/DEVICE_AGENT.md §5.2.1
- 备份/文档红线:TABLE_LABELS 加「账号台账」;DATA_MODEL/API/ARCHITECTURE/DEPLOY/
  TASK_DEV/DEVICE_AGENT/DEVELOPMENT/README 同步;顺手补上 DATA_MODEL 漏列的 done_mark

⚠ 台账里的抖音号是**纯号**,只当"比对用的候选值":绝不能拿去填「去重」的身份元素
  (身份是元素原文逐字算 key,格式不同会让去重静默失效)。代码与文档都写明了。

二、剪贴板注入通道(修:平台还在调早期的独立 APK)
- 改为按顺序尝试:设备端 Agent(com.example.deviceagent/.ClipActivity)→
  旧版独立 ClipInject 兜底;两个都没有时报"需要设备端 Agent"(不再只说 ClipInject)
- 读回验证改成**轮询到 3 秒**:透明 Activity 要等窗口拿到焦点才写,
  原来只睡 0.5s 会读到上一次的内容 → 误报"写入可能被拒"(实测内容已写入却回失败)
This commit is contained in:
2026-09-24 16:12:58 +08:00
parent e82fd64695
commit 7ce4ae9016
24 changed files with 1455 additions and 62 deletions
+37 -1
View File
@@ -137,6 +137,12 @@
| 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}`)→ 整批重跑 |
| GET | `/api/ledger?device=&q=&limit=` | D | 账号台账列表 + 统计(`{accounts, stats{total,devices,can_post_video,shown}, device_names}`) |
| GET | `/api/ledger/by_device` | D | 按设备分组(`{devices:{设备号:{count,accounts[]}}, counts:{设备号:N}}`) |
| POST | `/api/ledger` | D | 新增账号(抖音号重复 → 400) |
| PUT | `/api/ledger/<acc_id>` | D | 改一条账号 |
| DELETE | `/api/ledger/<acc_id>` | D | 删一条账号 |
| POST | `/api/ledger/import` | D | 从表格粘贴文本批量导入(`dry_run=true` 只预览不写库) |
### 2.4 admin(`web/admin_api.py`)
@@ -482,6 +488,36 @@
账本表与语义见 [DATA_MODEL.md](DATA_MODEL.md) §2.9;任务侧怎么用见
[TASK_DEV.md](TASK_DEV.md) §4.6。
### 账号台账(「账号」页)
数据在 `device_account` 表([DATA_MODEL.md](DATA_MODEL.md) §2.10),服务层 `core/ledger.py`,
权限 `devices`。
| 接口 | 请求 | 响应要点 |
|------|------|---------|
| `GET /api/ledger` | `?device=<设备号>&q=<搜索>&limit=500` | `{ok, accounts:[{id,device_name,serial,phone,nickname,douyin_id,registered_at,sim_in_device,can_post_video,bio,note,created_at,updated_at}], stats:{total,devices,can_post_video,shown}, device_names:["A01",…]}`。`q` 匹配抖音号/手机号/账号名 |
| `GET /api/ledger/by_device` | — | `{ok, devices:{A01:{count,accounts:[…]}}, counts:{A01:4}}`(设备池"账号 N"列 + 点开看明细都用它,省一次请求) |
| `POST /api/ledger` | 9 个字段(`device_name`/`douyin_id` 必填) | `{ok,msg,account}`;缺必填或抖音号重复 → **400** |
| `PUT /api/ledger/<id>` | 局部字段 | `{ok,msg,account}`;不存在 → 404;抖音号撞车 → 400 |
| `DELETE /api/ledger/<id>` | — | `{ok,msg}`;不存在 → 404 |
| `POST /api/ledger/import` | `{"text":"…","delimiter":"\\t","mode":"skip\|overwrite","dry_run":true}` | `{ok, counts:{total,added,updated,skipped,failed}, rows:[{line,action,device_name,douyin_id,nickname,error,warns}], errors:[{line,reason}], mode, dry_run}` |
**导入语义**(解析规则见 `core/ledger.parse_paste`):
- 分隔符默认 **Tab**(Excel 直接粘贴就是 Tab);也支持 `|` 与 `,`。**不做"多个空格当分隔"**——
账号名里本来就有双空格(实测 `有牛奶面包 你吃吗?`)。
- **带表头**时按列名映射(顺序随意、缺列留空、认不出的列忽略);**不带表头**则按
设备号/手机号/账号名称/抖音号/注册时间/卡在机内/可发视频/简介/备注 的固定顺序。
- 「卡在机内 / 可发视频」认 `是/有/1/true` → True,`否/空` → False;认不出的值按"否"处理
并在该行 `warns` 里留痕。
- 抖音号非纯数字也会 `warns` 留痕(不拦)——它会让任务比对匹配不上。
- `mode=skip`:库里有同号 → 跳过;`mode=overwrite`:**整行替换**(空单元格会覆盖掉原值)。
- `dry_run=true` 走同一条解析+判重路径但**一个字节都不写**(界面默认先预览再导入)。
- 缺设备号或抖音号的行会被跳过,并在 `errors` 里带行号说明。
> ⚠ 台账里的抖音号是**纯号**,只当"比对用的候选值";**不要**拿它填「去重」的身份元素
> (身份是元素原文逐字算 key,格式不同会让去重**静默失效**)。
---
## 6. 设备池与自动发现
@@ -719,7 +755,7 @@
| 接口 | 请求 | 说明 |
|------|------|------|
| `POST /api/tools/clipboard/set` | `{"serials":[…],"text":"…"}` | ClipInject 通道写入并读回校验;serials 空或非列表 → 400 |
| `POST /api/tools/clipboard/set` | `{"serials":[…],"text":"…"}` | 设备端 Agent 通道写入并读回校验(旧版独立 ClipInject 自动兜底);serials 空或非列表 → 400 |
| `POST /api/tools/appver` | `{"package":"com.xxx"}` | 并发查所有在线设备(≤10 并发);包名须匹配 `^[A-Za-z0-9_.]+$` |
### Tailscale(全部 Admin)
+3 -1
View File
@@ -270,7 +270,7 @@ connecting ──获取设备──▶ u2 连接 ──▶ running ──▶ set
### 6.1 单页应用
- 主页面 `templates/admin/monitor.html`:一个内联 `<style>` + 7 个 Tab 面板 + 10 个模态框容器 + 16 个 `<script src>`
- 主页面 `templates/admin/monitor.html`:一个内联 `<style>` + 8 个 Tab 面板 + 10 个模态框容器 + 17 个 `<script src>`
- 独立页面:`login.html`(登录)、`wall.html`(监控大屏,**完全自包含**,自带 CSS/JS,不加载 `static/admin/*.js`)
- 服务端内联页:`GET /locate`(设备端定位大字页,免登录)
- 响应头强制 `no-store`,避免后台改版后浏览器拿旧页面
@@ -280,6 +280,7 @@ connecting ──获取设备──▶ u2 连接 ──▶ running ──▶ set
| 顶级 Tab | `data-tab` | 权限 | 子分栏 |
|---------|-----------|------|--------|
| 监控 | `monitor` | 登录即可 | — |
| 账号 | `account` | `devices` | — (账号台账:列表 / 增删改 / 从表格粘贴导入,见 [DATA_MODEL.md](DATA_MODEL.md) §2.10) |
| 任务 | `tasks` | 登录即可(写操作需 `tasks`) | `plan` 任务计划 / `actions` 自定义动作 / `actioncfg` 动作配置 / `dedup` 去重记录 |
| 日志 | `logs` | `logs` | — |
| 用户 | `users` | `admin` | — |
@@ -296,6 +297,7 @@ connecting ──获取设备──▶ u2 连接 ──▶ running ──▶ set
| `base.js` | esc / CSRF / 权限 / API 封装 / Toast / Tab 与子分栏切换 / 模态框 / 常量表 | 所有模块的公共底座 |
| `markdown.js` | 轻量 Markdown 渲染(`renderMarkdown`) | 无 CDN 依赖;**先转义再套标记** |
| `list.js` | 统一列表组件:搜索 + 分页 + 排序 | 状态注册表 `_LIST_PAGERS`;`setListPager` **不重置**页码/搜索/排序(避免轮询刷新打断用户) |
| `ledger.js` | 账号台账页(列表 / 单条增删改 / 从表格粘贴导入 + 逐行结果 / 设备维度弹窗)+ `ledgerCell()` 供设备池那一列 | 服务层 `core/ledger.py`;导入默认**先预览再写**(`dry_run`) |
| `monitor.js` | 监控页:设备表(含**电量列**,按 `battery.tier` 上色)、批量操作、异常汇总、任务概况卡片(含覆盖设备 chip) | 5s 轮询 + 脏检查(签名不变不重渲染) |
| `editor.js` | 步骤编辑器(拖拽 / 参数表单 / 条件分支 / 元素抓取 / 测试此步骤)+ `saveTask` | 最大的前端文件;`_stepEditor` 单例;设备选择卡 `_devCard` 供「抓取元素」「测试此步骤」共用(**名字优先**,型号·地址作副标题) |
| `tasks.js` | 任务 Tab:任务 CRUD / 调度解析 / 运行窗口 + 自定义动作 | |
+38 -1
View File
@@ -27,7 +27,7 @@
启动时校验「`.env` 声明」与「库名」「库中登记的 `app_meta.deployment_env`」三方一致,不符**拒绝启动**。
**表清单(14 张,全部是 `core/models.py` 里的 ORM 模型)**
**表清单(16 张,全部是 `core/models.py` 里的 ORM 模型)**
| # | 表 | 用途 |
|---|---|------|
@@ -45,6 +45,8 @@
| 12 | `agent_action` | 动作库(命名动作) |
| 13 | `device_install_log` | 设备端应用商店的下载/安装记录(设备上报,见 [DEVICE_AGENT.md](DEVICE_AGENT.md)) |
| 14 | `task_step_log` | 任务步骤明细(每次步骤执行一条,见 §2.8;**唯一有无界增长风险的表**,靠保留期清理) |
| 15 | `done_mark` | 去重账本:跨设备"已做过"标记(见 §2.9;本清单此前漏列,2026-09-24 补上) |
| 16 | `device_account` | 账号台账:一台设备上登录着哪些账号(见 §2.10;任务「条件判断」的取号来源、设备端身份页显示用) |
> 2026-09-13 之前,`app_meta` 与 4 张 `agent_*` 表是各模块里的裸 `CREATE TABLE`
> (不进模型层)。迁 MySQL 时那批 SQL 的 `AUTOINCREMENT`/`TEXT DEFAULT ''`/`TEXT PRIMARY KEY`
@@ -197,6 +199,41 @@
⚠️ 这张表**自动进整库备份**(§6 派生规则);接任务步骤见 [TASK_DEV.md](TASK_DEV.md) §4.6。
### 2.10 `device_account` — 账号台账(一台设备上登录着哪些账号)
「账号」页维护;服务层 `core/ledger.py`,接口 `/api/ledger*`(见 [API.md](API.md) §2.15)。
| 列 | 类型 | 说明 |
|----|------|------|
| `id` | String(32) PK | uuid 前 8 位 |
| `device_name` | String(80) **index** | 设备号(= 设备池里的**设备名**,如 `A01`) |
| `serial` | String(120) | 录入时的**地址快照**(设备换 IP / 改名后台账仍能靠任一侧找回) |
| `phone` | String(32) index | 手机号 |
| `nickname` | String(80) | 账号名称 |
| `douyin_id` | String(64) index | **抖音号(纯号)**,如 `35377983067` |
| `registered_at` | String(20) | 注册时间(**原样存文本**,如 `2026/9/24`) |
| `sim_in_device` | Boolean | 卡在机内(**空 = 否**) |
| `can_post_video` | Boolean | 可发视频(**空 = 否**) |
| `bio` / `note` | Text | 简介 / 备注 |
| `created_at` / `updated_at` | String(20) | 字符串时间(仓库惯例) |
**三个使用方**:
1. **web「账号」页** —— 列表 / 增删改 / 从 Excel 粘贴导入(解析规则见 `core/ledger.parse_paste`)
2. **任务的「条件判断」取号** —— `if_el` 的 `cmp_source`(`device` 本机 / `all` 全部 / `group` 按设备分组),
见 [TASK_DEV.md](TASK_DEV.md) §4.2
3. **手机端 Agent 的身份大字页** —— 平台推 `accounts_b64`(见 [DEVICE_AGENT.md](DEVICE_AGENT.md) §5.2)
⚠️ **`douyin_id` 是纯号,绝不能当去重身份**:`done_mark.identity` 存的是**元素原文**
(`抖音号:35377983067`)、逐字算 key,格式不一致会让去重**静默失效**。
它只做"比对用的候选值"(运算符用「包含」时纯号是子串)。
**唯一性在应用层**(`core/ledger`):同一抖音号不允许两条。没做成 DB 唯一索引的理由:
抖音号可能为空,DB 级要写"部分唯一索引"(§4.2 那套 SQLite `WHERE` + MySQL 虚拟生成列),
而台账是人工维护的几十条 —— 三处方言适配不划算。
这张表**自动进整库备份**(§6 派生规则)。
---
## 3. 非模型表
+7 -2
View File
@@ -212,9 +212,10 @@ tail -20 logs/web.log # 无 ERROR/Traceback
SUMMARY_TABLES = tuple(sorted(t.name for t in db.metadata.tables.values()))
```
当前 15 张表:`app_meta` / `user` / `device_group` / `task_job` / `custom_action` /
当前 16 张表:`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` / `done_mark`。
`experience_audit` / `agent_action` / `device_install_log` / `task_step_log` / `done_mark` /
`device_account`(账号台账)。
完整说明见 [DATA_MODEL.md](DATA_MODEL.md) §6。
> ⚠️ `task_step_log`(任务步骤明细)是会持续增长的表:它按 `KEEP_DAYS`
@@ -228,6 +229,10 @@ SUMMARY_TABLES = tuple(sorted(t.name for t in db.metadata.tables.values()))
> 还需要手工做的只有:给新表补 `TABLE_LABELS` 的中文标签。
> 导出侧有**覆盖自检**(登记表缺失 → `manifest.coverage_missing` + 日志告警);
> 导入侧有**反向自检**(备份含未登记表 → 预览告警)。
>
> **回滚注意事项**:把"含新表的备份"导回**旧版本代码**时,旧代码的 `SUMMARY_TABLES`
> 里没有那张新表 → 导入预览会报 `extra_tables` 告警。**数据仍在快照里、不会丢**,
> 属于预期行为(升级回新版即可正常识别)。
### 5.3 目录与手工备份
+1 -1
View File
@@ -198,7 +198,7 @@ MCP_ALLOW_WRITE=1 MCP_PLATFORM_USER=admin MCP_PLATFORM_PASS=<密码> \
| 页面结构 / 样式 / 引入脚本 | `templates/admin/monitor.html` |
| 公共工具(API/权限/Toast/Tab) | `static/admin/base.js` |
| 列表分页排序 | `static/admin/list.js` |
| 各功能域逻辑 | `static/admin/{monitor,editor,tasks,tools,apps,admin,agent,system,markdown}.js` |
| 各功能域逻辑 | `static/admin/{monitor,editor,tasks,tools,apps,admin,agent,system,markdown,dedup,ledger}.js` |
新增 JS 模块:建文件 → 在 `monitor.html` 里按依赖顺序加 `<script src>` → 更新 [ARCHITECTURE.md](ARCHITECTURE.md) §6.3 与本文 §5.3。
+60 -2
View File
@@ -192,8 +192,18 @@ adb -s <serial> shell am start -n <pkg>/.ClipActivity --es text_b64 <base64(UTF-
- **必须 base64**:adb shell 直传中文/特殊字符会变形
- 必须用**透明 Activity**(有焦点才能写剪贴板),写完**立即退出**
- 平台判定失败的方式:`am start` 输出里有 `unable to resolve Intent` / `does not exist`
→ 视为"设备未安装 Agent"
- 平台判定失败的方式:`am start` 输出里有 `unable to resolve Intent` / `does not exist` /
`Error type 3` → 视为"这台设备没有这个通道"
**平台按顺序试两个包**(`core/clipboard_helper.py` 的 `_CLIP_TARGETS`):
| 顺序 | 包 | 说明 |
|---|---|---|
| 1 | `com.example.deviceagent`(**本 Agent**) | 现在的标准通道;装了就只调它 |
| 2 | `com.example.clipinject`(早期独立 APK) | 老设备上可能还残留 → 兜底;走这条会在日志里提示"建议装设备端 Agent" |
两个都没有时报"设备未安装剪贴板注入通道:需要设备端 Agent"(不再只说 ClipInject,
否则用户会去装那个已经不再分发的旧 APK)。**设备端无需为这条改动做任何事**。
### 5.2 设备身份显示(设备端已实现)
@@ -206,6 +216,51 @@ adb -s <serial> shell am start -n <pkg>/.InfoActivity \
要求:全屏大字、再次调用可覆盖内容、`close` 可关闭、不驻留前台。
#### 5.2.1 本机账号台账(`accounts_b64`,可选 extras — **需要设备端配合实现**)
平台在**打开**身份页时会顺带把这台设备在「账号」页登记的抖音账号推过去
(`--es close 1` 时**不带**)。**中文必须 base64**(同 §5.1:adb shell 直传中文会变形):
```bash
adb -s <serial> shell am start -n <pkg>/.InfoActivity \
--es name "A03" --es serial "192.168.20.63:5555" --es server "http://192.168.20.220:18050" \
--es ip "192.168.20.63" \
--es accounts_b64 "<base64(UTF-8 JSON)>"
```
解码:**标准 base64**(不是 URL-safe)+ UTF-8。JSON 结构(字段名的设备端解析按名取,多余字段忽略):
```json
{"v":1,"total":4,"shown":4,"truncated":false,
"note":"设备『A03』登记 4 个号",
"accounts":[
{"name":"AA建材王总","douyin_id":"66057500463","device_name":"A03","device_no":"A03",
"sim_in_device":true,"can_post_video":true}]}
```
| 字段 | 说明 |
|------|------|
| `v` | 本块格式版本(当前 **1**)。解不出来时用它区分"格式变了"与"平台没给" |
| `total` / `shown` / `truncated` | 该设备在台账里的总数 / 本次带了几条 / 是否被截断 |
| `accounts[].name` | 账号名称(台账的「账号名称」列) |
| `accounts[].douyin_id` | **抖音号(纯号)**,如 `35377983067` |
| `accounts[].sim_in_device` / `can_post_video` | 卡在机内 / 可发视频(布尔) |
| `accounts[].phone` | **默认不推**(大字页是机器旁的公开屏幕,手机号不宜默认上屏)。需要时平台请求体传 `{"include_phone":true}` |
**设备端要做的**(当前版本忽略该 extra,行为与旧版逐字一致):
1. 读 `accounts_b64` → base64 解码 → `JSONObject`
2. 在身份页上按 `name` / `douyin_id` 渲染一个列表(建议 1~3 行简短列表,最多显示 `shown` 条)
3. `truncated=true` 时提示"还有更多,见平台账号页"
**约定与边界**(平台侧实现见 `web/device_agent_api.py` 的 `_encode_accounts`):
- **没有这个 extra = 这台设备在台账里没有账号**(不是"平台忘了给")——设备端保持原样显示即可,
不要显示空列表
- 超预算时平台**按整条丢**(`shown` 变小 + `truncated=true`),**绝不按字节切**(切了就是坏 JSON);
台账过大时可能**整个 extra 不发**(平台响应体里 `warn` 会说明原因)
- 台账读取失败时同样**不发**这个 extra —— 台账故障不影响"显示身份"这个动作
### 5.3 扫码配置(设备端已实现)
平台「设备池」里点某台设备的「**二维码**」→ 生成一张二维码 → 手机上的 Agent 点
@@ -239,6 +294,9 @@ adb -s <serial> shell am start -n <pkg>/.ConfigActivity \
## 6. 版本与兼容
- **§5.2.1 的 `accounts_b64` 是"加字段"= 小版本**:`agent_api_version` **仍是 1**,
设备端忽略它时行为必须与旧版逐字一致(只是大字页少一块内容)。也**不**走
`bootstrap`:身份页由平台用 adb 推,加进 bootstrap 没人用还会让契约多一处要同步。
- `agent_api_version`(当前 **1**)在每次 `bootstrap` 响应里返回
- **设备端**:启动时对比自己实现的版本,不一致要在日志里明确记下来(不要静默)
- **平台端**:
+1 -1
View File
@@ -108,7 +108,7 @@ MCP_ALLOW_WRITE=1 MCP_PLATFORM_USER=admin MCP_PLATFORM_PASS=<平台密码> \
| 工具 | 参数 | 说明 |
|------|------|------|
| `de_type_text` | `serial`, `text` | 向当前输入框输入(支持中文,直设 EditText,不依赖剪贴板) |
| `de_set_clipboard` | `serial`, `text` | 写入设备剪贴板(ClipInject 通道 + 读回验证) |
| `de_set_clipboard` | `serial`, `text` | 写入设备剪贴板(设备端 Agent 通道 + 读回验证) |
### 4.5 App 管理(**写操作**,走 adb 直连不建 u2 会话)
+8 -8
View File
@@ -58,8 +58,8 @@
| 点击 | `POST /api/screen/tap {serial,x,y}` | 封装 tool `tap` |
| 滑动 | `POST /api/screen/swipe` | 封装 `swipe` |
| 按键 | `POST /api/screen/key` | 封装 `press_key` |
| 输入文字 | ClipInject 通道(core.clipboard_helper)+ u2 input | 封装 `type_text` |
| 剪贴板 | `POST /api/tools/clipboard/set`(ClipInject 通道) | 封装 `set_clipboard` |
| 输入文字 | 设备端 Agent 通道(core.clipboard_helper)+ u2 input | 封装 `type_text` |
| 剪贴板 | `POST /api/tools/clipboard/set`(设备端 Agent 通道) | 封装 `set_clipboard` |
| 元素树 | `GET /api/uiauto/...`(uiautodev) | 封装 `get_ui_tree` |
| OCR | core.ocr(RapidOCR) | 封装 `ocr_screen` |
| 打开 App | u2 `app_start`(经任务层/直连) | 封装 `open_app`(走 core device 直连) |
@@ -150,11 +150,11 @@
- key ∈ back/home/recent/menu/power/enter/delete…
#### `de_type_text(serial, text: str, clear_first: bool=True)`
- 实现:优先 ClipInject 通道(`core.clipboard_helper.inject_clipboard` 语义:写入剪贴板 + paste 到焦点输入框),失败回退 u2 input
- 中文/emoji 全支持(ClipInject 实测通过)
- 实现:优先设备端 Agent 通道(`core/clipboard_helper.inject_clipboard` 语义:写入剪贴板 + paste 到焦点输入框),失败回退 u2 input
- 中文/emoji 全支持(Agent 通道实测通过)
#### `de_set_clipboard(serial, text: str)`
- 写入设备剪贴板(ClipInject am start 通道),读回验证
- 写入设备剪贴板(设备端 Agent 的 am start 通道),读回验证
#### `de_open_app(serial, package: str)`
- 打开指定 App(u2 app_start);`package` 需在已知清单或完全限定名
@@ -247,7 +247,7 @@
3. **横竖屏/分辨率异构**:设备多样 → 坐标换算依赖截图 1:1;`de_find_and_tap`(语义层)不受分辨率影响
4. **图像 content block 兼容性**:Claude 系原生支持;其他多模态客户端需验证(M0 用 Claude Code 验证)
5. **是否接任务系统**:二期决策——`de_run_task` 语义与"单设备操作"不同(任务面向多设备调度),倾向二期用独立命名空间
6. **clipboard/input 通道差异**:个别 MIUI 需授权 ClipInject——M0 阶段在受控设备集验证,必要时 fallback 链已在 core 层
6. **clipboard/input 通道差异**:个别 MIUI 需授权写入剪贴板的透明 Activity——M0 阶段在受控设备集验证,必要时 fallback 链已在 core 层(设备端 Agent → 旧版 ClipInject)
## 12. 附录:平台 API 映射(MCP → 平台)
@@ -262,8 +262,8 @@
| de_press_key | POST /api/screen/key |
| de_tap_text | POST /api/screen/tap_text(UI 树子串匹配 → OCR 兜底) |
| de_tap_element | u2 元素直连(uiautomator2 `d(by=value).click()`,无平台端点) |
| de_type_text | u2 `EditText.set_text`(direct_ops.type_text,不走 ClipInject) |
| de_set_clipboard | core.clipboard_helper ClipInject 通道(inject_clipboard,读回验证) |
| de_type_text | u2 `EditText.set_text`(direct_ops.type_text,不走剪贴板通道) |
| de_set_clipboard | core.clipboard_helper 设备端 Agent 通道(inject_clipboard,读回验证) |
| de_read_clipboard | u2 `d.clipboard` |
| de_open_app | adb monkey 直启(direct_ops.open_app) |
| de_stop_app | adb `am force-stop`(direct_ops.stop_app) |
+28 -2
View File
@@ -128,11 +128,11 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
| 11 | `long_click` | 长按元素 | `selector_type`、`selector_value` | `duration`(1.0)、`wait_timeout`(2) | — | 先等元素出现再 `long_click` |
| 12 | `wait_el` | 等待元素 | `selector_type`、`selector_value` | `timeout`(10) | — | 等元素出现(条件等待,优于固定 `wait`) |
| 13 | `input_text` | 输入文字 | — | `mode`("random")、`texts`("你好\n有趣\n支持")、`fixed_text`("")、`clear_first`(True) | — | `mode="fixed"` 用 `fixed_text`,否则从 `texts` 按行随机选一条;**只负责输入,不负责定位输入框**(要先 click 输入框) |
| 14 | `clipboard` | 剪贴板注入 | `text` | `paste`(True) | — | 走 ClipInject 通道(绕开 Android 10+ 后台写剪贴板限制);`paste=true` 时再触发一次粘贴 |
| 14 | `clipboard` | 剪贴板注入 | `text` | `paste`(True) | — | 走**设备端 Agent** 通道(透明 Activity,绕开 Android 10+ 后台写剪贴板限制);老设备上只有独立 ClipInject 时自动兜底(见 `core/clipboard_helper.py`);`paste=true` 时再触发一次粘贴 |
| 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)、`cmp_op`("")、`cmp_value`("")、`ident_type`/`ident_value`(去重身份) | `then` / `else` | 见 §4.2;条件类型除元素/OCR 外还支持 **屏幕状态**、**前台App**、**去重**(见 §4.6);填 `cmp_op` 则改成**比元素的文本**(等于/不等于/包含/不包含,多值任一命中) |
| 18 | `if_el` | 条件判断 | `selector_type`、`selector_value`、`timeout`(3) | `ocr_click`(False)、`cmp_op`("")、`cmp_value`("")、`cmp_source`("")、`cmp_group`("")、`ident_type`/`ident_value`(去重身份) | `then` / `else` | 见 §4.2;条件类型除元素/OCR 外还支持 **屏幕状态**、**前台App**、**去重**(见 §4.6);填 `cmp_op` 则改成**比元素的文本**(等于/不等于/包含/不包含,多值任一命中),候选值还可以从**「账号」台账**取(`cmp_source` = device/all/group) |
| 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 |
@@ -283,6 +283,32 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
走了 `else`,不是选择器失效,不该攒出「连续未命中」告警。
- 读元素文本是**只读**操作(`get_text`,2 秒超时),不会点击或改动任何状态。
#### 候选值从「账号」台账取号(`cmp_source` / `cmp_group`,可选)
几十个号时手写 `cmp_value` 很累、而且换号要回来改。`cmp_source` 让候选值直接来自
「账号」页的台账:
| `cmp_source` | 取哪些号 |
|---|---|
| `""` / `manual`(默认) | 只用手填的 `cmp_value` —— **行为与以前完全一致** |
| `device` | **本机台账**:这台设备在台账里登记的抖音号(靠 `serial`→设备名 匹配) |
| `all` | 全部台账 |
| `group` | 某个**设备分组**内所有设备的号(分组名填在 `cmp_group`) |
- **与手填值是合并(OR)**,不是二选一:台账里的号 + 你补的一个号,任一命中即可。
更重要的是 —— **台账取不到号时手填值仍然生效**,台账没维护好不会把任务直接打哑。
- 取到 0 个号时**日志会写明原因**(如"设备名对不上"):
候选为空会让这一步**永远走 `else` 分支**,而任务本身照样"跑完了"。
编辑器保存时也会为此报警告,请到「账号」页核对设备号。
- 比对运算符仍要选:台账里是**纯号**(`35377983067`),元素原文是
`抖音号:35377983067` → 用 **`包含`**。(要整段一模一样才用 `等于`。)
> ⚠ **台账的纯号只能当"比对用的候选值",绝不能拿去填「去重」的身份元素**(`ident_value`):
> 去重身份存的是**元素原文**、逐字算 key(`core/dedup.py` 的 `build_key`),
> 格式不一致(`35377983067` vs `抖音号:35377983067`)会让**检查与记账算出两个不同的
> key → 去重静默失效**(重复评论,而且日志看不出问题)。
> 去重身份继续用「抓取元素」抓到的那个账号元素即可。
### 4.3 公共参数
| 参数 | 说明 |