92 Commits
Author SHA1 Message Date
butubb 65bab7e25c fix: schema 迁移幂等兜底——create_all 已建列而 schema_version 未记录时重复 ALTER 报 duplicate column,遇错回滚并照记版本自愈
典型场景:create_all 已按当前模型把列/表直接建好(如 perms、device.model),
而 schema_version 又因历史中断没记录,导致每次启动重复 ALTER 报错。
duplicate column name 说明列已存在=迁移目标已达成:回滚本次语句后仍记录
版本号,一次启动即自愈;其它异常才中止本批迁移。
2026-09-09 14:16:15 +08:00
butubb 4c0572f73c fix: 设备自动发现本机IP枚举——git-bash hostname -I 报错炸线程,改 bytes 接收 + getaddrinfo 兜底
Windows 上 git-bash 的 coreutils hostname 不支持 -I,会把 GBK 报错写进
stderr;text=True 在 subprocess 后台读线程里 utf-8 严格解码会直接炸线程
(主线程 try/except 接不住异步线程异常)。抽 _local_ips():优先 hostname -I
且用 bytes 接收 errors=ignore 解码,不支持/失败时 getaddrinfo 枚举兜底。
扫描网段展开时剔除本机自身 IP,避免探测到自己 5555。
2026-09-09 14:16:13 +08:00
butubb caa646e89c fix: 会话落库/加载缺 app context——后台线程 db 操作必须包 _flask_app.app_context()(历史加载与完成写回两处),否则静默失败(消息不落库、标题不生成) 2026-09-06 15:14:30 +08:00
butubb a77635c925 feat: AI 控制台历史会话(DeepSeek 式)——①agent_conversation 表:会话持久化(单表 JSON 消息),一轮 run 完成后 user/assistant 自动落库、新会话以首条消息作标题;②会话 API:列表/新建/详情/删除/重命名;③run 绑定 conversation_id:history 从会话加载(多轮上下文延续)、完成写回;④前端三栏布局:左侧会话栏(新建/列表/高亮/悬停删除/轮数时间)+ 聊天 + 实时画面,切换会话即切换上下文,刷新后自动恢复当前会话与消息 2026-09-06 15:12:56 +08:00
butubb 9b8ecd63f7 fix: AI 控制台切页/刷新不丢——①EventSource onerror 不再误 endRun(断网/后台节流/瞬时抖动都会触发 onerror 而后端任务仍在跑;EventSource 自动重连 + 服务端队列保留积压事件,重连后补发,连接彻底关闭才提示可刷新恢复);②页面刷新恢复:GET /api/agent/run 增加 run_id/answer/error/history,前端 restoreAgentView 渲染历史轮次、running 时自动重订阅事件流、done/error 展示结果——切走再回来任务与对话都在 2026-09-06 09:41:02 +08:00
butubb 0f75db289d feat: AI 目标设备必选 + 设备任务占用锁——①前端新增「🎯 目标设备」选择器(在线设备含型号,任务运行中的设备禁选),发送必须选定设备,AI 只操作你指定的设备;②/api/agent/run 校验:serial 不在池/离线/任务 running-connecting → 拒绝(409 提示任务名);③MCP 11 个写工具加 busy 锁(_ensure_device_free,5s 缓存):任务运行中的设备 AI 一律拒绝,AI 不与任务抢设备(外部 MCP 客户端同样受保护);④运行状态端点 GET /api/agent/run + 前端 8s 轮询:多人/多窗口能看到运行中任务(发起时间/任务/设备)并可停止(stop 加确认);未选设备直接 400 引导请选择设备,去掉模型自己乱挑设备的行为 2026-09-06 09:32:11 +08:00
butubb 850d2e2e66 feat: 最大执行步骤可配置——AI 控制台 ⚙ 配置弹窗新增「最大执行步骤」输入(1-200,默认 40),存 app_meta(agent_max_steps),Agent 线程运行时覆盖 agent.s.max_steps;此前仅环境变量 AGENT_MAX_STEPS 可改 2026-09-06 09:26:53 +08:00
butubb f7aa71cca4 fix: AI 控制台实时画面跟随 AI 所选设备——后端 step 事件 args 原为 str() 的 Python repr(单引号),前端 JSON.parse 失败导致 followSerialFromArgs 从未生效;改为保留对象序列化,前端兼容对象/字符串并修 step 卡片 args 展示(避免 [object Object]) 2026-09-06 09:21:03 +08:00
butubb edfd529c59 feat: 正式池断联设备自动重连——①扫描线程每轮顺带对断联的网络设备 adb_connect_light(幂等,连上即恢复,无需人工;此前只有启动时预连接一次,断联后不会自己回来);②断联设备不删不进待连接池(pending 语义=未授权新设备),GET discovery 返回 pool_offline(serial/型号/备注);③POST /api/devices/discovery/reconnect 手动立即重连;④发现面板加「设备池断联设备」区块(自动重连中 + 立即重连按钮) 2026-09-05 09:58:42 +08:00
butubb 4865ed1d31 fix: 依赖守卫补 fastmcp——漏检导致 fastmcp 缺失时跳过安装,MCP server 持续启动失败 2026-09-04 16:30:55 +08:00
butubb b4513e78eb fix: fastmcp 加入主 requirements——MCP server 已由 start.sh 常驻启动(AI 控制台依赖),compose 全新重建容器时主依赖缺失导致 MCP 启动失败 2026-09-04 16:26:17 +08:00
butubb 4863b9fbbe fix: cv2 空壳修复改 pip --force-reinstall——手动 wheel 解压兜底在部分环境修不好(解压后 import cv2 仍无 __version__,OCR 持续不可用);force-reinstall 已在 220 实测恢复 cv2 4.14.0 2026-09-04 16:25:24 +08:00
butubb 7fd77fa33c fix: 容器启动幂等——依赖安装段移入 start.sh 并加「就绪即跳过」守卫(flask/u2/rapidocr/cv2 全部可用则秒级启动);配合 220 compose 去掉 command 里的 pip 段,消除每次容器重启重装 opencv 5.0(73MB) 并破坏 cv2 的崩溃循环帮凶 2026-09-04 16:23:17 +08:00
butubb fbd01a056c fix: 经验巡检评审失败重试——模型偶发输出解释文字不带 JSON(实测 4/11 条评审失败自动保留),失败后重试一次并强调只输出 JSON;仍失败才保守保留 2026-09-04 16:19:23 +08:00
butubb 04d07b2c62 fix: uiautodev 残留进程清理防误杀——容器重启后 PID namespace 重建、残留 uiauto.pid 的 PID 值会被 MCP/其它进程复用,原逻辑直接 os.kill 可能 SIGTERM 杀掉同容器的 MCP server(观察到 web 容器 12s 崩溃循环);改为 kill 前校验 /proc/<pid>/cmdline 含 uiautodev 2026-09-04 16:15:54 +08:00
butubb 5681989194 feat: 经验库每日 AI 巡检 + 管理面板——①experience_audit 表:逐条评审(具体可执行/无过时坐标/非对话续语/配方与序列一致 checklist),keep 直接归档、delete 标 pending 待人工,绝不自动删;人工 kept 后不再重复建议;②web_server 注册每日 03:47 巡检 job(Asia/Shanghai);③API:经验列表(含巡检建议)/确认删除/保留/手动触发巡检;④AI 控制台右上角「🧠 经验库」面板:经验卡片 + 建议徽章与理由 + 删除(confirm)/保留按钮 2026-09-04 15:53:08 +08:00
butubb c078e282ed docs: 新增 MCP 使用手册(doc/MCP.md)——19 个 de_* 工具全清单(参数/用途/推荐用法)、坐标空间与自动吸附约定、配置环境变量表、推荐操作模式(文字语义点击优先→元素→坐标兜底)、安全与审计说明、fastmcp 客户端示例;与 MCP_DESIGN.md 设计稿互链 2026-09-04 15:46:14 +08:00
butubb 9c700292c3 fix: 经验入库质量门槛——①对话续语/质疑/纠错轮次不再存为经验(prompt 过短、继续/还有等续语开头、你确定/还没有/不是吧等质疑词、无任务动词的疑问短句→弃),被用户纠正的失败轮次入库会教坏后续任务;②配方含不存在的 de_* 工具弃存(蒸馏模型编造 de_input 之类);③检索命中 hits 回写 +1,高频有效经验浮前 2026-09-04 15:43:21 +08:00
butubb 729f40893e feat: 经验写入即时提示——提炼+保存挪到 done 事件之前(原在 done 后执行、SSE 已关流,用户看不到),成功后推 🧠 经验记忆 step 卡片「已写入记忆库,下次相似任务自动参考」;_save_experience 返回成功与否;蒸馏超时 60s→25s 防收尾拖沓 2026-09-04 15:12:55 +08:00
butubb bdaaea3e6c fix: MCP server 固化进 start.sh 容器启动链——之前手动 docker exec 拉起、容器重启即丢(AI 控制台 agent 依赖 8033);MCP_ENABLED=0 可关,账号密码可经环境变量覆盖 2026-09-04 14:38:01 +08:00
butubb 4fad0b1198 fix: tap_text UI 树命中路径——u2 3.x 的 el.bounds 是方法不是属性,取 bounds 抛 TypeError 被吞导致永远走 OCR 兜底;改兼容写法 2026-09-04 14:36:34 +08:00
butubb 4d4f0ae666 fix: OCR 文字点击落点精度——命中文本块较长时(一行含多段文字)按关键词在文本中的位置比例估算 x,避免点整块中心偏离关键词(tap_text 与任务 OCR 条件共用受益) 2026-09-04 14:33:28 +08:00
butubb 44a9dafcd8 fix: AI 点击精度优化——①坐标吸附:/api/screen/tap 加 snap=1,MCP de_tap 固定开启(dump UI 树找包含点击点的最小可点击元素点中心,模型坐标偏 20-50px 也点得准,大屏触控不带 snap 行为不变);②新 de_tap_text 语义点击:按屏幕可见文字一次完成找+点(UI 树 textContains/descriptionContains → OCR 中心兜底,WebView/图片文字也能点);③de_tap_element 支持 text_contains/desc_contains 模糊匹配;④de_ui_tree 可点击元素优先 + limit 参数防 token 膨胀;⑤agent 提示词重写:文字语义点击优先、坐标仅纯图形兜底且自动吸附、点击后无变化禁止重复同坐标 2026-09-04 14:29:17 +08:00
butubb e5018c4e45 fix: de_foreground_app fallback mFocusedApp(MIUI 过渡期 mCurrentFocus=null 时前台解析为空) 2026-09-04 13:52:34 +08:00
butubb fab7e2b11a feat: MCP 工具补全(9→18)——de_open_app(adb monkey 直启,省 token)/de_stop_app/de_foreground_app/de_type_text(中文直接输入)/de_set_clipboard/de_sleep/de_ocr(屏幕文字识别)/de_list_apps/de_list_tasks;direct_ops 直连封装(adb/u2/OCR/剪贴板轻量通道) 2026-09-04 13:48:55 +08:00
butubb 1560a13d86 fix: tool_calls 流中断自愈——400 配对错误时移除不完整段重试一次;加消息结构诊断日志 2026-09-04 13:43:20 +08:00
butubb c0c1be05f8 fix: 经验保存缺 app context(后台线程 db 报错被吞)——register_blueprints 注入 app,线程内 db 操作包 app_context 2026-09-04 13:37:33 +08:00
butubb 1ada51a7f6 feat: 自进化经验记忆——任务成功后用模型把工具序列提炼成「操作配方」存入 agent_experience 表;下次相似任务(bigram 相似度检索 top2)自动注入 system prompt 参考,AI 越用越聪明(同类操作不再摸索) 2026-09-04 13:33:30 +08:00
butubb 5145f83f06 feat: AI 控制台增强——①de_read_clipboard 剪贴板回读工具;②任务可中断(stop_event 循环检查点 + API stop + 前端■停止按钮);③智能滚动(回看历史不被打断,贴底才跟随);④右侧实时画面(MJPEG 流自动跟随操作设备,可手动选择) 2026-09-04 13:32:35 +08:00
butubb 44cab88546 fix: Agent 步骤上限优化——max_steps 20→40(多步任务如打开抖音搜索),system prompt 加收敛规范(无进展 6 步主动停止总结),超限时无工具收尾请求让模型给有意义总结(不再机械提示);web 超时 600→900s 2026-09-04 13:23:04 +08:00
butubb ce4e5464b9 feat: AI 控制台会话化 + 视觉优化——①多轮对话连续性(后端 history 持久化 12 轮,Agent 注入历史上下文,DeepSeek 式会话:新建会话才清空);②聊天区改浅色(DeepSeek 风格,图片文字清晰);③截图点击放大查看 2026-09-04 13:18:36 +08:00
butubb e24458a085 feat: AI 控制台升级为顶级 Tab——DeepSeek 风格聊天界面(气泡+流式渲染+思考折叠),实时 MCP 步骤卡片(工具/参数/截图缩略),SSE 流式输出(delta/step/done/error 事件),配置模态(模型/Key/设备前端可配);工具页旧子栏移除 2026-09-04 13:09:34 +08:00
butubb 25ef7e7746 fix: agent_api 同步 dev(事务/_execute 遮蔽/600s 超时) 2026-09-04 13:04:44 +08:00
butubb 19126d1a59 fix: Agent MCP 连接短超时(10s 快速失败)+ web 600s 兜底 2026-09-04 12:59:32 +08:00
butubb fea5a57fc3 feat(M2): Web AI 控制台——工具页新增子分栏:模型/Key/设备前端可配置(app_meta 存储,key 打码回显),指令执行 + 步骤流轮询(含截图缩略)+ 回答展示;Agent 加 on_step 回调、后台线程单实例运行 2026-09-04 12:54:17 +08:00
butubb 02776b12b8 feat(M2): 语义层工具 de_ui_tree/de_tap_element——UI 树元素定位点击(text/id/desc,多命中 index),无需坐标更可靠;坐标 tap 保留兜底(WebView/画布元素) 2026-09-04 12:52:55 +08:00
butubb 7017ff3315 feat(M1): 补 de_wake/de_press_key 工具(Agent 熄屏可自唤醒);修复 MCP SDK v2 input_schema 字段警告 2026-09-04 12:50:55 +08:00
butubb 937a87b36c feat(M1): Agent 编排层(OpenAI 兼容)——DeepSeek 等第三方模型经 MCP 工具控制手机:工具桥转 function schema、截图图像转 image_url 多模态流、工具循环、CLI 入口(mock 验证通过) 2026-09-04 12:47:09 +08:00
butubb d7b6d83cd8 feat(M1): MCP 截图坐标空间统一——de_screenshot 附 native_size,de_tap/de_swipe 接受截图坐标由 server 按比例换算原生(模型只感知截图坐标系) 2026-09-04 12:47:09 +08:00
butubb 084ed59ad5 feat: 平台新增只读端点 /api/screen/size——返回设备屏幕原生分辨率(u2 window_size),供 MCP 截图坐标换算(截图是缩放图,操作需原生坐标) 2026-09-04 12:47:09 +08:00
butubb 2ba9f0c239 feat(M0): MCP 手机控制 Server 骨架——FastMCP HTTP 传输(8033),平台登录会话+CSRF 封装,de_list_devices/de_screenshot(图像块)/de_tap/de_swipe,写门控/白名单/审计,实测全链路通过 2026-09-04 12:42:16 +08:00
butubb c304996c7b docs: MCP 手机控制设计文档——架构(复用平台 REST/core 能力薄封装)、L1感知/L2操作/L3语义三层 tools 规格、图像链路(screenshot→image block)、安全(写门控/白名单/占用互斥/审计)、220 部署与 M0-M3 里程碑 2026-09-04 10:29:03 +08:00
butubb 441ff7c7da feat: 设备分组移入工具页(原顶级 tab 删除)——工具页新增「设备分组」子分栏,分组管理入口与其他设备工具聚合 2026-09-04 10:15:44 +08:00
butubb 0b6b000b01 feat: 剪贴板注入面板只显示在线设备(pool/离线条目无注入意义);adb 终端面板保留全部供重连 2026-09-04 10:12:17 +08:00
butubb d3ccc4a7a8 chore: users.db 为运行时数据库(容器实时写入导致 git pull 一直冲突),移除 git 跟踪并加入 .gitignore 2026-09-04 00:11:14 +00:00
butubb 88f4d09d4c chore: 清理 DataEase 误解压残留;users.db 移除跟踪(运行时数据),.gitignore 忽略 data/ logs/ 2026-09-04 08:11:05 +08:00
butubb 7276cfe585 fix: 待连接池后端过滤离线设备——list_pending 只返回当前在线设备(离线候选不可确认),不依赖前端 JS 缓存版本,任何客户端都拿不到离线条目 2026-09-04 08:08:52 +08:00
butubb 1a74372ad9 fix: 任务调度限定设备池——preempt 抢占模式的 all 目标原用 list_online()(含待连接/未入池设备,自动发现验证连上的设备会被任务误跑);改为设备池内在线设备;group 模式同样过滤未入池设备(serial 模式保留显式指定) 2026-09-01 09:07:12 +08:00
butubb 22ba9272bd feat: 全站默认只看在线——监控页/大屏/待连接池同步过滤离线设备(可切换查看全部);18051 main 旧实例已停止(STF 已删,旧代码无保留意义) 2026-09-01 08:58:21 +08:00
butubb ac7997176d feat: 设备池默认只看在线——离线旧条目(IP 已变的幽灵设备)默认隐藏,在线优先排序;「只看在线」开关可切换查看全部(含离线/停用) 2026-09-01 08:54:06 +08:00
butubb b9fab1999d fix: 自动发现按钮引号转义语法错误——onclick 内嵌单引号拼接在生成时丢一层转义(Unexpected string 导致整个 tools.js 不执行,设备池/发现全不渲染);改为 dataset 传参彻底绕开嵌套引号 2026-08-31 15:38:33 +08:00
butubb 4efe17c1c9 feat: 设备自动发现——定时扫描局域网+Tailscale 网段开放 adb 5555 的设备进入待连接池,用户确认后才加入正式设备池(不自动连接);socket 并发探测+adb 短超时验证(state=device 过滤 unauthorized),配置存 app_meta 可前端调整;配套:adb_connect_light 验证短超时、DISABLE_SCHEDULER 回归开关 2026-08-30 10:57:07 +08:00
butubb 1e39f5490a fix: 大屏触控优化——①点击错位:ctrl-img object-fit:contain 时图片绘制区小于盒子(居中黑边),坐标换算改为按实际绘制区域,黑边点击忽略;②滑动卡顿:tap/swipe/key/text 每次 u2.connect 重建连接(1-3s 握手),加 60s 连接缓存,失败自动清缓存重建(实测点击 2.97s→1.13s) 2026-08-20 13:22:21 +08:00
butubb 31a2ae911e fix: uiautodev 自动启动失败 name sys is not defined——_ensure_uiauto_running 用 sys.platform 但 web_server.py 未 import sys;补 import,元素抓取/动作编辑器设备列表恢复 2026-08-20 11:09:25 +08:00
butubb 90ca2c2100 fix: 任务动作编辑器剪贴板注入改用 ClipInject 通道——u2 set_clipboard 在 Android 10+ 静默失效(粘贴模式必抛 UiAutomationError);提取 core/clipboard_helper 公共模块供工具页与任务执行端共用,粘贴模式 atx-agent pasteClipboard + set_text 兜底 2026-08-20 10:51:54 +08:00
butubb 2a3c748964 fix: 工具页剪贴板注入无反应——injectClipboard 函数在阶段3清理时被误删,点击按钮 JS 报错;补全函数(校验→ClipInject 通道 API→逐台结果表+toast 汇总),帮助文案同步更新 2026-08-20 10:40:49 +08:00
butubb 5d637bd4f5 fix: 剪贴板注入改用 ClipInject 通道——u2.set_clipboard 在 Android 10+ 被系统静默丢弃(atx-agent 后台身份写剪贴板受限),改为 am start 透明 Activity(shell 身份启动不受后台限制),注入后 u2 读回比对验证,未装 APK 明确报错 2026-08-20 10:34:53 +08:00
butubb 81a99e007b feat: 启动时后台并发预连接设备池——adb server 重启后设备全掉线,adb 命令排队卡几分钟;预连接(并发 connect IP:5555)把恢复时间降到秒级 2026-08-20 09:33:24 +08:00
butubb f78955f119 fix: start.sh 增加 cv2 空壳兜底——pip 24.0 解压 abi3 wheel 偶发丢根文件(cv2.abi3.so/__init__.py 缺失致 OCR 全挂),检测后 pip download + zipfile 手动解压(幂等) 2026-08-20 08:51:02 +08:00
butubb 27530ff5ac fix: 手动执行不再受运行窗口限制(窗口只约束定时触发)——立即执行按钮在窗口外也可触发任务 2026-08-20 08:36:29 +08:00
butubb 0fab6f4de8 fix: opencv-python-headless 锁 4.x——清华源 5.0.0.93 wheel 打包异常(site-packages 无 cv2 模块),OCR 仍不可用 2026-08-19 16:23:36 +08:00
butubb 5de87465bc fix: start.sh 修复顺序——rapidocr 依赖声明 opencv-python 会被 pip 强制装回 GUI 版,uninstall 必须在 pip install 之后最后执行 2026-08-19 16:17:28 +08:00
butubb b33ff17c26 feat: 容器启动脚本 scripts/start.sh——幂等卸载 opencv-python(GUI 版缺 X11 库致 OCR 崩)并装 headless,compose 指向本脚本后环境修复全走 git 2026-08-19 16:14:15 +08:00
butubb c0667c5959 fix: 容器环境 OCR 不可用——opencv-python 依赖 X11(libxcb)在 slim 容器 import 崩溃致条件判断 OCR 步骤任务退出,改为 opencv-python-headless(本地 Mac 不受影响) 2026-08-19 16:11:20 +08:00
butubb d0cb531c20 fix: 未登录访问 500——login_view 蓝图化后未更新为 auth.login,flask_login 重定向 url_for('login') BuildError 2026-08-19 16:02:47 +08:00
butubb 42c2820180 docs: requirements 注释清理(STF 时代描述 → 当前架构) 2026-08-19 15:51:57 +08:00
butubb a9720e8b81 fix: 抢占归还丢失——preempted_job 在重试循环内被重置为 None,关键字任务一旦重试(attempt≥2)被抢占的养号任务永不恢复;提升到循环外+已有值不覆盖+归还时被抢占任务停用/删除也打日志 2026-08-19 15:46:18 +08:00
butubb e0c3373dd1 feat: 全量路由回归测试脚本 scripts/regression_test.py(GET 全量 + 写操作空 body 探测抓 import 遗漏 + 关键业务 POST,500=缺陷 502/503=业务提示)+ fix: clear_all_worker_errors 缺导入(回归脚本首跑即抓到) 2026-08-19 15:24:11 +08:00
butubb 2adc54b512 fix: adb 相关功能全挂——tools_api 缺 ADB_PATH/_ADB_BLOCKED_PATTERNS 导入(模块拆分遗漏),应用版本/adb终端/剪贴板恢复,全量 28 个 GET 路由回归通过 2026-08-19 15:12:42 +08:00
butubb a0916ebda2 fix: 抓取元素回填垃圾选择器——无属性元素生成 //*[N](匹配任意节点点不到),改为 invalid 标记前端提示不可选;结构路径跳过无 class 层消除 //*[N] 前缀污染;模块拆分遗漏修复(get_all_worker_status/list_installed_apps 致 summary/设备已装应用 500) 2026-08-19 15:10:26 +08:00
butubb 265e47a1f4 fix: 测试此步骤弹窗全链路修复——closeTestModal 恢复(阶段3误删导致关不掉)、test-overlay 层级 1200(高于编辑器/元素抓取弹窗)、tasks_api 补 _log(后端500)、GenericStepsWorker 旧签名调用修复(测试执行失败) 2026-08-19 15:00:27 +08:00
butubb ce47a5b83d refactor: web_server 模块化拆分(web/ 蓝图包 auth/monitor/tasks/admin/tools/devices/apks/tailscale + context/common,web_server 1796→193 行仅留装配与启动)+ 大屏优化(网格 5s 轮询、弹窗独占缩略图、流支持 q/fps)+ 三修复(MIUI 屏幕状态检测改 dumpsys power、补 ADB_PATH 导入修复一键亮屏、renderGrid 增量渲染不重载缩略图) 2026-08-19 14:55:29 +08:00
butubb e07b339a51 feat: 大屏增强——一键亮屏按钮、卡片显示设备名+IP+熄屏状态(thumb 响应头 X-Screen-State)、点击卡片进入设备操作弹窗(实时流+触控+按键+文字),工具页远程看屏子分栏移除并入大屏 2026-08-19 14:17:33 +08:00
butubb c78f17d72f feat: 监控大屏(/wall 全屏深色控制室风格:设备卡片网格+缩略图轮询+统计+时钟,缩略图 360px/q55 每 2.5s 一帧 20 台仅 ~0.2 核 CPU)+ /api/screen/thumb 接口 + 导航栏大屏入口 2026-08-19 14:13:15 +08:00
butubb 5543bad0e6 docs: 全面更新——README/API/DEVELOPMENT 摘除 STF 章节(设备池/型号/看屏/新端点文档,技术红线改为共享 transport 表述) 2026-08-19 14:07:53 +08:00
butubb 83ba489f57 feat: 前端整体改版(仪表控制台主题:自托管 Bricolage Grotesque+IBM Plex Mono 字体、暖纸底+墨色+深青主色、卡片/表格/徽章/模态框/Toast 全面细化、Tab 切换动画)+ fix: showTab 硬编码 tab 列表移除维护后索引错位、tools 分支调用已删的 loadStfDevMgmt、导航移除失效维护Tab 2026-08-19 14:03:02 +08:00
butubb 2d1b10c7f3 fix: 全选勾选不显示(renderDevices 签名缺选中状态导致防抖跳过渲染)+ 亮屏/息屏支持勾选设备(未勾选则全部在线,离线自动过滤) 2026-08-19 13:57:07 +08:00
butubb 6f6093842c feat: 设备型号采集展示(devices 表 model 列+getprop 采集+管理页型号列/采集按钮+监控页兜底)+ fix: 监控页设备列错位(离线标签残留 td)、appver 端点误删恢复、appver 只查在线设备用轻量连接防挂起 2026-08-19 13:53:27 +08:00
butubb eda71c8e5f fix: USB 设备双路径——优先本机 adb 直连,其次 220 远程 adb server(原实现只查远程,本机 USB 会被误判不在 server) 2026-08-19 08:08:40 +08:00
butubb 1573e82653 阶段3: 文档更新(ARCHITECTURE/DEPLOY 摘除 STF 章节,STF_REMOVAL 标记代码完成+220 停容器待确认) 2026-08-18 08:11:00 +08:00
butubb a5ce57b6fd 阶段3: 摘除 STF——删除 stf_client/stf_device_mgmt 与全部引用(STF 容器重启/设备管理/强制释放占用/agent 按钮/占用列),调度与设备管理完全基于本地设备池+adb;错误类迁入 device_worker,create_worker 去 stf 参数 2026-08-18 08:08:32 +08:00
butubb 98244d8161 feat: 设备池管理面板(新增/停用/删除/一键重连,调度数据源规范化入口)+ STF 设备管理增删桥接同步设备池(迁移期) 2026-08-18 08:02:13 +08:00
butubb 645938b305 阶段2: USB 远程 adb server 打通(220 host 网络 5037 全接口监听,无需改 220)+ 自建远程看屏(MJPEG 流 + u2 触控 tap/swipe/key/text,ws-scrcpy 不在 npm 改自建) 2026-08-17 16:50:12 +08:00
butubb 5354fe6379 阶段1: 调度/生命周期切换 device_pool——task_manager 设备解析与状态查询、STFDevice 去 occupy/release、web_server 端点换数据源(USB 桥接保留到阶段 2) 2026-08-17 11:16:13 +08:00
butubb 52c4d3aab4 阶段0: 设备池(SQLite devices 表 + core/device_pool.py 清单/在线状态/CRUD,首次启动自动从 STF 导入)+ STF 摘除迁移计划文档 2026-08-17 11:11:08 +08:00
butubb c111cb9a5e fix: STF未就绪设备纳入维护终端列表(adb可达但agent启动失败,标记stf_not_ready)+ 卸载/检查agent自动先connect 2026-08-17 11:02:43 +08:00
butubb e48eeef49b feat: 任务抢占+归还(抢占任务结束后自动重启被抢占任务)+ 修复抢占死锁(stop_device 移出锁块)+ wait 步骤可中断 + 循环块新增一直循环模式(配合定时启动+停止实现全天候养号) 2026-08-16 08:12:05 +08:00
butubb a51857d143 feat: 步骤新增剪贴板注入(可注入+粘贴,批量发链接场景)+ adb终端快捷命令 tcpip 5555 转网络调试 2026-08-15 15:53:31 +08:00
butubb 17f849f723 feat: STF设备管理彻底删除(脚本+断开+STF API清记录)+ 一键重连(后台跑connect脚本)+ 跳过原因日志区分离线/未就绪 + SSH超时错误分类 2026-08-15 15:22:55 +08:00
butubb e12219c979 feat: 强制安装APK(推送+pm install 绕过厂商弹窗)+ 定位设备(亮屏+大字定位页,结束定位关闭浏览器含后台)+ 一键亮屏/息屏(并发全部设备) 2026-08-15 15:03:01 +08:00
butubb 21d3aa8268 feat: 任务离线设备自动跳过(serial/分组模式触发时过滤 STF 池离线设备,默认开启,任务编辑器可勾选关闭) 2026-08-14 09:16:54 +08:00
butubb 060157ea1a refactor: 前端JS拆分为8个模块(base/list/monitor/editor/tasks/tools/apps/admin,按依赖顺序加载)+ 删除废弃文件(start_web.bat/main.py/fix_web.bat/cleanup.py,web已全覆盖)+ 文档同步 + 修复/api/summary缺导入 2026-08-11 14:53:04 +08:00
79 changed files with 10933 additions and 5136 deletions
+6
View File
@@ -42,3 +42,9 @@ desktop.ini
bin/adb/* bin/adb/*
# 数据备份与损坏库(运维临时文件,不入库)
data/db_backup_*/
data/*.corrupt_*
data/users.db*
data/users.db-wal
data/users.db-shm
+76 -81
View File
@@ -1,8 +1,8 @@
# 设备自动化后台(platform-tools) # 设备自动化后台(platform-tools)
基于 **STF(Smartphone Test Farm)+ uiautomator2 + Flask** 的 Android 多设备自动化任务执行平台。 基于 **uiautomator2 + Flask + 自建设备池** 的 Android 多设备自动化任务执行平台(已摘除 OpenSTF 依赖)。
提供 Web 管理后台,支持多设备并发任务执行、定时调度、设备分组管理、APK 批量安装、UI 元素抓取等功能。内置抖音养号任务和通用步骤任务,可扩展任意 App 的自动化操作。 提供 Web 管理后台,支持多设备并发任务执行、定时调度、设备分组管理、设备池管理(新增/停用/删除/型号采集)、APK 批量安装、网页远程看屏(MJPEG 实时流 + 触控)、UI 元素抓取等功能。内置抖音养号任务和通用步骤任务,可扩展任意 App 的自动化操作。
--- ---
@@ -26,9 +26,9 @@
### 环境要求 ### 环境要求
- **Python 3.10+**(推荐 3.12) - **Python 3.10+**(推荐 3.12)
- **OpenSTF 服务**:已部署并运行,设备已接入 STF
- **Windows / Linux / macOS**均可(adb 二进制需放对应平台版本到 `bin/adb/`) - **Windows / Linux / macOS**均可(adb 二进制需放对应平台版本到 `bin/adb/`)
- 设备需开启 **USB 调试**或通过 **adb 网络连接**(IP:5555)接入 - 设备需开启 **USB 调试**(USB 连上后 `adb tcpip 5555` 转网络调试),并加入 Tailscale 获得 `100.100.10.x` IP
- 新增设备在后台「工具 → 设备池管理」添加 `IP:5555`(自动连接,任务运行时 u2 自动推送 atx-agent)
### 三步启动 ### 三步启动
@@ -36,8 +36,8 @@
# 1. 安装依赖 # 1. 安装依赖
pip install -r requirements.txt pip install -r requirements.txt
# 2. 修改配置(STF 地址、token、adb 路径等) # 2. 修改配置(可选):USB 设备在 220 上时配置 USB_ADB_HOST/PORT(默认 100.100.10.1:5037 已可用)
# 编辑 config.py 中的 STF_URL 和 STF_TOKEN # 密钥类配置放 .env(WEB_SECRET_KEY / TAILSCALE_API_KEY)
# 3. 启动 Web 后台 # 3. 启动 Web 后台
python web_server.py python web_server.py
@@ -45,8 +45,6 @@ python web_server.py
启动后访问 **http://localhost:18050/**,默认账号 `admin` / `admin123`。 启动后访问 **http://localhost:18050/**,默认账号 `admin` / `admin123`。
> Windows 用户也可双击 `start_web.bat` 启动(自动提权 + 添加防火墙规则,支持局域网访问)。
### 验证启动 ### 验证启动
控制台看到以下日志即表示启动成功: 控制台看到以下日志即表示启动成功:
@@ -61,11 +59,12 @@ python web_server.py
### 首次使用流程 ### 首次使用流程
1. **登录后台** → 用 `admin/admin123` 登录,建议立即修改密码 1. **登录后台** → 用 `admin/admin123` 登录,建议立即修改密码
2. **查看设备** → 首页"监控"Tab 自动展示 STF 设备池状态 2. **添加设备** → "工具 → 设备池管理"添加设备(serial 形如 `100.100.10.20:5555`,自动连接并采集型号)
3. **创建分组**(可选)→ "分组"Tab 按批次/项目给设备分组 3. **查看设备** → 首页"监控"Tab 展示设备池状态(在线/型号/任务)
4. **创建任务** → "任务"Tab 新建任务,选择任务类型、目标设备、参数、调度 4. **创建分组**(可选)→ "分组"Tab 按批次/项目给设备分组
5. **执行任务** → 任务列表点"立即执行",或在"监控"Tab 勾选设备批量操作 5. **创建任务** → "任务"Tab 新建任务,选择任务类型、目标设备、参数、调度
6. **查看日志** → "日志"Tab 实时查看运行日志 6. **执行任务** → 任务列表点"立即执行",或在"监控"Tab 勾选设备批量操作(亮屏/息屏/停止)
7. **查看日志** → "日志"Tab 实时查看运行日志
--- ---
@@ -73,64 +72,75 @@ python web_server.py
``` ```
platform-tools/ platform-tools/
├── config.py # 根配置(STF地址/token、adb路径、web端口) ├── config.py # 根配置(部署配置统一从 .env 读,模板见 .env.example)
├── web_server.py # Flask 入口(API + 登录 + 单页应用) ├── web_server.py # Flask 入口(app 装配 + 蓝图注册 + 启动,193 行)
├── main.py # 命令行入口(单设备跑一次,不走 web) ├── web/ # Web 层蓝图包(按功能域拆分,路由都在这里)
│ ├── auth.py # 登录/CSRF/权限装饰器/页面路由(/、/wall)
│ ├── monitor.py # 状态/运行控制/设备操作/远程看屏
│ ├── tasks_api.py # 任务计划/分组/自定义动作/元素抓取
│ ├── admin_api.py # 用户管理/日志
│ ├── tools_api.py # adb 终端/剪贴板注入/应用版本
│ ├── devices_api.py # 设备池管理
│ ├── apks_api.py # 应用管理
│ ├── tailscale_api.py # Tailscale 管理
│ ├── common.py # 跨模块共享工具(合并设备列表/屏幕状态)
│ └── context.py # 共享对象注入(mgr/apk_mgr/device_pool)
├── requirements.txt # Python 依赖清单 ├── requirements.txt # Python 依赖清单
├── start_web.bat # Windows 一键启动(提权 + 防火墙)
│ │
├── core/ # 核心基础设施层 ├── core/ # 核心基础设施层
│ ├── logger.py # 统一日志(分文件、10MB 滚动) │ ├── logger.py # 统一日志(分文件、10MB 滚动)
│ ├── stf_client.py # STF REST API 封装(占用/释放/远程连接) │ ├── device_pool.py # 设备池(SQLite 清单 + adb 在线状态 + 型号采集)
│ ├── adb_helper.py # adb 命令封装(全局锁,绝不 kill-server) │ ├── adb_helper.py # adb 命令封装(全局锁,绝不 kill-server)
│ ├── device_worker.py # BaseWorker 基类 + STFDevice + 心跳看门狗 │ ├── device_worker.py # BaseWorker 基类 + 设备生命周期 + 心跳看门狗
│ ├── task_manager.py # TaskManager 调度器 + 前台App扫描器 │ ├── task_manager.py # TaskManager 调度器 + 前台App扫描器
│ ├── u2_helper.py # uiautomator2 通用辅助函数 │ ├── u2_helper.py # uiautomator2 通用辅助函数
│ ├── uiauto_helper.py # uiautodev 元素抓取客户端 │ ├── uiauto_helper.py # uiautodev 元素抓取客户端
│ ├── apk_manager.py # APK 上传/解析/批量安装 │ ├── apk_manager.py # APK 上传/解析/批量安装
│ ├── ocr.py # 屏幕 OCR(RapidOCR,条件判断 OCR 选择器)
│ ├── ssh_client.py # SSH 统一执行(paramiko 纯密码 / 免密密钥,预留运维用)
│ ├── tailscale_client.py # Tailscale API v2 客户端
│ ├── models.py # SQLAlchemy 数据模型 + 数据库初始化 │ ├── models.py # SQLAlchemy 数据模型 + 数据库初始化
│ └── actions/ │ └── actions/ # 全局 Action 框架
│ ├── __init__.py # 全局 Action 框架导出
│ └── base.py # BaseAction 基类 + 注册机制
│ │
├── tasks/ # 任务定义层(每个 App 一个子包) ├── tasks/ # 任务定义层(每个 App 一个子包)
│ ├── __init__.py # 全局任务注册表 │ ├── __init__.py # 全局任务注册表
│ ├── base.py # BaseTask 基类 + @register_task 装饰器 │ ├── base.py # BaseTask 基类 + @register_task 装饰器
│ ├── douyin/ # 抖音养号任务 │ ├── douyin/ # 抖音养号任务
│ │ ├── __init__.py
│ │ ├── task.py # DEFAULT_PARAMS + Worker + Task
│ │ └── actions/ # 抖音专属操作(点赞等)
│ │ ├── __init__.py
│ │ ├── base.py # 抖音 ACTIONS 注册表
│ │ └── like.py # 点赞操作(找红心/双击回退)
│ └── generic/ # 通用步骤任务(可视化编辑器编排) │ └── generic/ # 通用步骤任务(可视化编辑器编排)
│ ├── __init__.py │ └── task.py # 步骤执行引擎(open_app/click/swipe/if_el...)
│ └── task.py # 步骤执行引擎(open_app/click/swipe/loop...)
│ │
├── templates/admin/ # 前端页面 ├── templates/admin/ # 前端页面
│ ├── monitor.html # 单页应用(监控/任务/分组/日志/用户 5 Tab) │ ├── monitor.html # 单页应用(监控/任务/分组/日志/用户/工具)
│ └── login.html # 登录页 │ └── login.html # 登录页
│ │
├── static/admin/ ├── static/admin/ # 前端 JS 模块(monitor.html 按依赖顺序加载)
│ ├── custom.css # 自定义样式 │ ├── base.js # 通用基础:API/CSRF/权限/Tab切换/子分栏/模态框
│ └── monitor.js # 前端 JS(已从 monitor.html 拆分) │ ├── list.js # 统一列表组件(搜索+分页+排序)
│ ├── monitor.js # 监控页(设备表/截图/异常汇总)
│ ├── editor.js # 步骤编辑器(拖拽/条件判断/元素抓取)
│ ├── tasks.js # 任务 Tab + 自定义动作
│ ├── tools.js # 工具 Tab(剪贴板/设备池管理/adb终端/Tailscale/远程看屏)
│ ├── apps.js # 工具 Tab-应用管理(APK/设备已装应用)
│ ├── admin.js # 管理 Tab(分组/日志/用户)+ 初始化
├── static/fonts/ # 自托管字体(Bricolage Grotesque + IBM Plex Mono)
│
├── data/
│ │
├── data/ # 运行时数据 ├── data/ # 运行时数据
│ ├── users.db # SQLite(用户/分组/任务/自定义动作/APK记录) │ ├── users.db # SQLite(用户/分组/任务/自定义动作/APK记录)
│ └── apks/ # 上传的 APK 文件存储 │ └── apks/ # 上传的 APK 文件存储
│ │
├── logs/ # 日志文件(自动生成,10MB 滚动保留 5 份) ├── logs/ # 日志文件(自动生成,10MB 滚动保留 5 份)
│ ├── core.log # 核心程序日志
│ ├── task.log # 任务执行日志
│ ├── web.log # Web 请求日志
│ └── action.log # 操作执行日志
│
├── bin/adb/ # adb 可执行文件(Windows: adb.exe + dll) ├── bin/adb/ # adb 可执行文件(Windows: adb.exe + dll)
├── scripts/ # 实用脚本 ├── scripts/ # 实用脚本
│ ├── cleanup.py # 清理 STF 残留设备占用 │ ├── pack.py # 打包项目为 zip(排除运行时产物)
│ └── pack.py # 打包项目为 zip(排除运行时产物) │ └── supervise.sh # 进程守护(崩溃自动重启)
└── doc/ # 项目文档 └── doc/ # 项目文档
├── TASK_DEV.md # 任务开发指南(新增 App 任务模板) ├── API.md # API 文档
├── ARCHITECTURE.md # 架构详解
├── DEPLOY.md # 部署指南
├── DEVELOPMENT.md # 开发指南
└── TASK_DEV.md # 任务开发指南(新增 App 任务模板)
├── ARCHITECTURE.md # 架构详解 ├── ARCHITECTURE.md # 架构详解
├── DEPLOY.md # 部署指南 ├── DEPLOY.md # 部署指南
└── API.md # API 接口文档 └── API.md # API 接口文档
@@ -143,26 +153,24 @@ platform-tools/
### 设备生命周期 ### 设备生命周期
``` ```
STF occupy(占用设备) 设备池选定设备(TaskManager._running 内存锁保证互斥)
↓ ↓
STF remoteConnect(建立远程 ADB 隧道) IP:5555 → adb connect 直连;USB(serial 无冒号)→ 本机 adb 或 220 远程 adb server
↓
adb connect(连接远程设备)
↓ ↓
u2.connect(连接 uiautomator2,自动推送 atx-agent) u2.connect(连接 uiautomator2,自动推送 atx-agent)
↓ ↓
Worker.run_task(执行业务逻辑) Worker.run_task(执行业务逻辑)
↓ ↓
adb disconnect + STF release(释放设备) 释放(不 disconnect,遵守共享 adb transport 红线)
``` ```
> 直连模式:当设备 serial 为 `IP:5555` 格式时,直接 adb connect,不走 STF 桥接,更稳定。 > 单实例部署下互斥由调度器内存锁保证;多实例场景可扩展 SQLite 行锁(见 doc/STF_REMOVAL.md 阶段 4)。
### Worker — 单设备执行线程 ### Worker — 单设备执行线程
每台设备对应一个 `Worker` 线程,继承 `BaseWorker`(`core/device_worker.py`)。基类已封装: 每台设备对应一个 `Worker` 线程,继承 `BaseWorker`(`core/device_worker.py`)。基类已封装:
- STF 设备占用/释放(try/finally 保证释放) - 设备获取(IP:5555 直连 / USB 远程 adb server,try/finally 保证释放)
- adb 连接 + u2.connect(带 30 秒超时保护) - adb 连接 + u2.connect(带 30 秒超时保护)
- 状态上报(实时推送到前端监控大屏) - 状态上报(实时推送到前端监控大屏)
- 异常捕获(设备离线不重试,其他异常按策略重试) - 异常捕获(设备离线不重试,其他异常按策略重试)
@@ -228,21 +236,17 @@ self.set_progress(done=5, total=80, unit="视频",
| 配置项 | 默认值 | 说明 | | 配置项 | 默认值 | 说明 |
|-------|--------|------| |-------|--------|------|
| `STF_URL` | `http://192.168.20.220:7100` | OpenSTF 服务地址 |
| `STF_TOKEN` | (.env 配置) | STF API Token,在 STF 个人设置里生成(密钥类配置统一放 `.env`) |
| `ADB_PATH` | 自动识别 | adb 二进制路径,自动区分 Windows/Linux | | `ADB_PATH` | 自动识别 | adb 二进制路径,自动区分 Windows/Linux |
| `WEB_HOST` | `0.0.0.0` | Web 监听地址(0.0.0.0 支持局域网访问) | | `WEB_HOST` | `0.0.0.0` | Web 监听地址(0.0.0.0 支持局域网访问) |
| `WEB_PORT` | `18050` | Web 端口(避开 Windows 动态端口范围) | | `WEB_PORT` | `18050` | Web 端口(避开 Windows 动态端口范围) |
| `DATA_DIR` | `data/` | 持久化数据目录 | | `DATA_DIR` | `data/` | 持久化数据目录 |
| `APK_DIR` | `data/apks/` | APK 文件存储目录 | | `APK_DIR` | `data/apks/` | APK 文件存储目录 |
| `STF_SSH_TARGET` | `[email protected]` | 维护页重启 STF / STF 设备管理的 SSH 目标(需免密) | | `USB_ADB_HOST` | `100.100.10.1` | USB 设备所在部署机(220)的 Tailscale IP(USB 设备远程 adb server) |
| `STF_DOCKER_CONTAINER` | `stf` | 220 上 STF Docker 容器名 | | `USB_ADB_PORT` | `5037` | 220 adb 容器监听端口(host 网络模式) |
| `STF_ADB_CONTAINER` | `adb` | 220 上 adb Docker 容器名(STF 设备管理执行 adb 用) |
| `STF_SCRIPT_PATH` | `/mnt/data/openstf/connect_devices.sh` | STF 设备池脚本路径(DEVICES 列表由此维护) |
| `TAILSCALE_API_KEY` | (.env 配置) | Tailscale 管理 API key(Settings → API Access Tokens) | | `TAILSCALE_API_KEY` | (.env 配置) | Tailscale 管理 API key(Settings → API Access Tokens) |
| `TAILSCALE_TAILNET` | 按邮箱前缀 | tailnet 名/ID | | `TAILSCALE_TAILNET` | 按邮箱前缀 | tailnet 名/ID |
**必须修改的配置**:`.env` 里的 `STF_TOKEN`(密钥类统一放 `.env`,不入 git);`STF_URL` 在 `config.py`。 **必须修改的配置**:`.env` 里的 `WEB_SECRET_KEY`(会话密钥,不入 git);其余均为可选(默认值开箱即用)。
--- ---
@@ -250,17 +254,16 @@ self.set_progress(done=5, total=80, unit="视频",
### 页面结构 ### 页面结构
单页应用(`templates/admin/monitor.html`),7 个 Tab;任务/工具 Tab 内部再有页内子分栏: 单页应用(`templates/admin/monitor.html`),6 个 Tab;任务/工具 Tab 内部再有页内子分栏:
| Tab | 功能 | 可见性 | | Tab | 功能 | 可见性 |
|-----|------|--------| |-----|------|--------|
| 监控 | 设备状态大屏:在线/离线、运行任务、当前动作、进度条、前台 App、截图、停止 | 所有登录用户(设备操作按钮需"设备控制"权限) | | 监控 | 设备状态大屏:在线/离线、型号、运行任务、当前动作、进度条、前台 App、截图、勾选批量操作(亮屏/息屏/停止);导航栏「📺 大屏」打开全屏监控墙(/wall,缩略图+统计+时钟,挂墙/电视用) | 所有登录用户(设备操作按钮需"设备控制"权限) |
| 任务 | 子分栏:任务计划(CRUD/启用停用/立即执行/下次运行时间)、自定义动作(打包复用) | 所有登录用户可看,写操作需"任务管理"权限 | | 任务 | 子分栏:任务计划(CRUD/启用停用/立即执行/下次运行时间/**离线设备自动跳过**)、自定义动作(打包复用) | 所有登录用户可看,写操作需"任务管理"权限 |
| 分组 | 设备分组管理:创建/编辑/删除分组 | 所有登录用户可看,写操作需"任务管理"权限 | | 分组 | 设备分组管理:创建/编辑/删除分组 | 所有登录用户可看,写操作需"任务管理"权限 |
| 日志 | 实时日志查看:按模块切换(core/task/web/action) | 需"日志查看"权限 | | 日志 | 实时日志查看:按模块切换(core/task/web/action) | 需"日志查看"权限 |
| 用户 | 用户管理:创建/删除/修改密码/分配权限 | 仅管理员 | | 用户 | 用户管理:创建/删除/修改密码/分配权限 | 仅管理员 |
| 维护 | STF 服务:一键重启容器 | 仅管理员 | | 工具 | 子分栏:剪贴板注入、adb 远程终端(快捷命令/自动 `-s`)、**设备池管理**(新增/停用/删除/一键重连/型号采集)、**远程看屏**(MJPEG 实时流 + 点击/滑动/按键/文字)、Tailscale 管理(改名/授权/密钥不过期/设置IP/auth key)、应用管理(APK 上传安装)、应用版本管理(按包名查所有设备版本)、设备已装应用 | 仅管理员 |
| 工具 | 子分栏:剪贴板注入、adb 远程终端(快捷命令/自动 `-s`/卸载 STF agent)、Tailscale 管理(改名/授权/密钥不过期/设置IP/auth key)、应用管理(APK 上传安装)、应用版本管理(按包名查所有设备版本)、设备已装应用、STF 设备管理(读写 220 脚本维护设备池) | 仅管理员 |
任务/工具 Tab 的子分栏会记住上次选中的位置;无权限的 tab 和按钮自动隐藏。 任务/工具 Tab 的子分栏会记住上次选中的位置;无权限的 tab 和按钮自动隐藏。
@@ -287,10 +290,9 @@ self.set_progress(done=5, total=80, unit="视频",
| 设备状态 | 处理方式 | | 设备状态 | 处理方式 |
|---------|---------| |---------|---------|
| worker 运行中 | 复用已有 ADB 连接查询 | | worker 运行中(IP:5555) | 复用已有 ADB 连接查询 |
| 自己占用 | 通过 STF remoteConnect 隧道查询(不 occupy/release) | | worker 运行中(USB) | 经 220 远程 adb server 查询 |
| 完全空闲 | 返回"空闲"(不主动 adb connect,避免 STF 误判离线) | | 完全空闲 | 返回"空闲"(不主动 connect,避免扰动共享 adb transport) |
| 他人占用 | 标记"(他人占用)" |
### 截图功能 ### 截图功能
@@ -360,7 +362,7 @@ self.set_progress(done=5, total=80, unit="视频",
| 文件 | 模块前缀 | 内容 | | 文件 | 模块前缀 | 内容 |
|------|---------|------| |------|---------|------|
| `logs/core.log` | `core.*` | STF/adb/worker/task_manager 核心程序 | | `logs/core.log` | `core.*` | adb/worker/task_manager/设备池 核心程序 |
| `logs/task.log` | `task.*` | 任务执行(worker 业务逻辑) | | `logs/task.log` | `task.*` | 任务执行(worker 业务逻辑) |
| `logs/web.log` | `web.*` | Web 请求/管理 | | `logs/web.log` | `web.*` | Web 请求/管理 |
| `logs/action.log` | `action.*` | 操作执行(点赞/评论等) | | `logs/action.log` | `action.*` | 操作执行(点赞/评论等) |
@@ -382,10 +384,8 @@ Web 后台"日志"Tab 可实时查看各模块日志。
| 脚本 | 用途 | | 脚本 | 用途 |
|------|------| |------|------|
| `python web_server.py` | 启动 Web 管理后台 | | `python web_server.py` | 启动 Web 管理后台 |
| `python main.py` | 命令行跑一次抖音养号(单设备,不走 web) |
| `python scripts/cleanup.py` | 清理 STF 残留设备占用(强杀进程后用) |
| `python scripts/pack.py` | 打包项目为 zip(排除日志/数据库/APK) | | `python scripts/pack.py` | 打包项目为 zip(排除日志/数据库/APK) |
| `start_web.bat` | Windows 一键启动(提权 + 防火墙规则) | | `bash scripts/supervise.sh` | 进程守护(web_server 崩溃自动重启) |
--- ---
@@ -401,19 +401,13 @@ netsh interface ipv4 show excludedportrange protocol=tcp
修改 `config.py` 中的 `WEB_PORT` 到一个不在排除范围内的端口。 修改 `config.py` 中的 `WEB_PORT` 到一个不在排除范围内的端口。
### STF 设备显示离线但实际在线 ### 设备显示离线
STF 状态有缓存,`present=True` 不代表设备真在线。用"扫描前台App"按钮复测,或直接尝试执行任务。 先确认本机 `adb devices` 能看到设备(工具 → 设备池管理 → 一键重连);看不到则检查 Tailscale 是否在线、设备是否加入了正确的 tailnet(平台与设备必须在同一 tailnet)。
### 设备一直被占用无法释放 ### 新增设备后任务不调度它
运行清理脚本: 新设备需在「工具 → 设备池管理」添加(serial 为 `IP:5555`),仅连上 adb 不会进入设备池。添加后自动连接并采集型号,下一轮任务即可调度。
```bash
python scripts/cleanup.py
```
会释放当前账户占用的所有设备。
### u2.connect 卡死 ### u2.connect 卡死
@@ -452,7 +446,8 @@ finally:
| [doc/DEVELOPMENT.md](doc/DEVELOPMENT.md) | **开发手册**:开发流程、git 工作流、技术红线、环境、本地开发 | | [doc/DEVELOPMENT.md](doc/DEVELOPMENT.md) | **开发手册**:开发流程、git 工作流、技术红线、环境、本地开发 |
| [doc/TASK_DEV.md](doc/TASK_DEV.md) | 任务开发指南(新增 App 任务的完整模板和规范) | | [doc/TASK_DEV.md](doc/TASK_DEV.md) | 任务开发指南(新增 App 任务的完整模板和规范) |
| [doc/ARCHITECTURE.md](doc/ARCHITECTURE.md) | 架构详解(分层设计、数据流、关键设计决策) | | [doc/ARCHITECTURE.md](doc/ARCHITECTURE.md) | 架构详解(分层设计、数据流、关键设计决策) |
| [doc/DEPLOY.md](doc/DEPLOY.md) | 部署指南(环境准备、STF 配置、生产部署) | | [doc/DEPLOY.md](doc/DEPLOY.md) | 部署指南(环境准备、设备池配置、生产部署) |
| [doc/STF_REMOVAL.md](doc/STF_REMOVAL.md) | STF 摘除迁移记录(阶段 0-3 已完成,含多实例锁方案) |
| [doc/API.md](doc/API.md) | API 接口文档(全部 HTTP 接口说明) | | [doc/API.md](doc/API.md) | API 接口文档(全部 HTTP 接口说明) |
> **注意**:所有 git 操作(含 push 到 dev)都需负责人确认后才能执行,详见开发手册。修改 `core/`、`tasks/`、`templates/` 后需重启服务/强刷浏览器才生效。 > **注意**:所有 git 操作(含 push 到 dev)都需负责人确认后才能执行,详见开发手册。修改 `core/`、`tasks/`、`templates/` 后需重启服务/强刷浏览器才生效。
@@ -469,4 +464,4 @@ finally:
| uiautomator2 | Android UI 自动化 | | uiautomator2 | Android UI 自动化 |
| uiautodev | UI 元素抓取(步骤编辑器"抓取元素") | | uiautodev | UI 元素抓取(步骤编辑器"抓取元素") |
| pyaxmlparser | APK 元信息解析 | | pyaxmlparser | APK 元信息解析 |
| OpenSTF | 设备农场管理(占用/释放/远程 ADB) | | 自建设备池 | 设备清单/在线状态/型号(SQLite + adb) |
+17 -15
View File
@@ -29,8 +29,7 @@ def _env(key, default):
return os.environ.get(key, default) return os.environ.get(key, default)
# ================== STF 配置 ================== # ================== STF 配置(已废弃,阶段 3 摘除 STF 后仅保留供历史参考) ==================
# 部署相关配置统一从 .env 读取(模板见 .env.example),config.py 不再内置具体值
STF_URL = _env("STF_URL", "") STF_URL = _env("STF_URL", "")
STF_TOKEN = _env("STF_TOKEN", "") # 写入 .env,不要提交到 git STF_TOKEN = _env("STF_TOKEN", "") # 写入 .env,不要提交到 git
@@ -61,25 +60,21 @@ DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data")
# APK 文件存储目录(应用管理功能) # APK 文件存储目录(应用管理功能)
APK_DIR = os.path.join(DATA_DIR, "apks") APK_DIR = os.path.join(DATA_DIR, "apks")
# ================== 悬空设备自愈 ================== # ================== SSH 到部署机(已废弃,STF 相关操作退役) ==================
# 启动时是否自动释放"仍被本账户占用"的设备(上次崩溃/强杀残留)。 # 阶段 3 后代码不再使用 SSH;保留配置仅供手动运维 220(如 docker stop stf)。
# 仅单实例部署时设为 True:多实例共用同一 STF 账户时不要开,会误释放另一实例的运行中任务。
AUTO_RELEASE_STALE_OCCUPY = _env("AUTO_RELEASE_STALE_OCCUPY", "false").lower() in ("1", "true", "yes")
# ================== SSH 到部署机(维护页重启 STF + 工具页 STF 设备管理) ==================
# 通过 SSH 到部署机执行 docker 命令/改脚本。配置从 .env 读取:
# STF_SSH_TARGET SSH 目标(user@host)
# STF_SSH_PASSWORD SSH 密码(配置后 paramiko 纯密码登录,跨平台;
# 留空则退回系统 ssh 免密密钥 BatchMode=yes)
# STF_DOCKER_CONTAINER STF Docker 容器名
# STF_ADB_CONTAINER 220 上 adb Docker 容器名(STF 设备管理执行 adb 用)
# STF_SCRIPT_PATH STF 设备池脚本路径(增删设备读写其 DEVICES 列表)
STF_SSH_TARGET = _env("STF_SSH_TARGET", "") STF_SSH_TARGET = _env("STF_SSH_TARGET", "")
STF_SSH_PASSWORD = _env("STF_SSH_PASSWORD", "") STF_SSH_PASSWORD = _env("STF_SSH_PASSWORD", "")
STF_DOCKER_CONTAINER = _env("STF_DOCKER_CONTAINER", "") STF_DOCKER_CONTAINER = _env("STF_DOCKER_CONTAINER", "")
STF_ADB_CONTAINER = _env("STF_ADB_CONTAINER", "") STF_ADB_CONTAINER = _env("STF_ADB_CONTAINER", "")
STF_SCRIPT_PATH = _env("STF_SCRIPT_PATH", "") STF_SCRIPT_PATH = _env("STF_SCRIPT_PATH", "")
# ================== USB 设备远程 adb server(阶段 2) ==================
# USB 设备插在 220(STF 主机)上,本机 adb 看不到;220 的 adb 容器是 host 网络
# 模式,5037 已监听所有网卡(含 Tailscale),平台用 adb -H/-P 直接驱动。
# 平台自身 USB(serial 无冒号且在本机 adb 里)不受影响,仍走本地。
USB_ADB_HOST = _env("USB_ADB_HOST", "100.100.10.1") # 220 的 Tailscale IP
USB_ADB_PORT = int(_env("USB_ADB_PORT", "5037"))
# ================== Tailscale 管理(工具页) ================== # ================== Tailscale 管理(工具页) ==================
# 调用官方 API v2 管理 tailnet 设备。配置从 .env 读取: # 调用官方 API v2 管理 tailnet 设备。配置从 .env 读取:
# TAILSCALE_API_KEY:后台 → Settings → API Access Tokens 生成(或 OAuth client_id:secret) # TAILSCALE_API_KEY:后台 → Settings → API Access Tokens 生成(或 OAuth client_id:secret)
@@ -87,3 +82,10 @@ STF_SCRIPT_PATH = _env("STF_SCRIPT_PATH", "")
# 安全:密钥用环境变量/.env 注入,不要写死在代码或提交到 git。 # 安全:密钥用环境变量/.env 注入,不要写死在代码或提交到 git。
TAILSCALE_API_KEY = _env("TAILSCALE_API_KEY", "") # 写入 .env,不要提交到 git TAILSCALE_API_KEY = _env("TAILSCALE_API_KEY", "") # 写入 .env,不要提交到 git
TAILSCALE_TAILNET = _env("TAILSCALE_TAILNET", "") TAILSCALE_TAILNET = _env("TAILSCALE_TAILNET", "")
# ================== 设备自动发现 ==================
# 定时扫描网段中开放 adb 5555 的设备 → 待连接池(用户确认后才加入正式池)。
# 网段默认:局域网 + Tailscale;可在工具页设备池面板修改(存 app_meta)。
DISCOVERY_PORT = int(_env("DISCOVERY_PORT", "5555"))
DISCOVERY_SUBNETS = ["192.168.20.0/24", "100.100.10.0/24"]
DISCOVERY_INTERVAL = int(_env("DISCOVERY_INTERVAL", "60"))
-1
View File
@@ -2,7 +2,6 @@
config — 全局配置常量(STF、adb 路径、养号参数) config — 全局配置常量(STF、adb 路径、养号参数)
adb_helper — adb 命令封装(并发安全) adb_helper — adb 命令封装(并发安全)
stf_client — OpenSTF REST API 客户端
device_worker — 设备生命周期 + 养号 worker + 全局状态注册表 device_worker — 设备生命周期 + 养号 worker + 全局状态注册表
task_manager — 任务管理框架(分组/计划/调度/重试/持久化) task_manager — 任务管理框架(分组/计划/调度/重试/持久化)
""" """
+64 -6
View File
@@ -19,18 +19,39 @@ _ADB_LOCK = threading.Lock()
_ADB_TIMEOUT = 30 _ADB_TIMEOUT = 30
def _adb(*args): def _adb(*args, timeout=_ADB_TIMEOUT):
"""执行 adb 命令,返回 (stdout+stderr) 文本。不用 shell=True,避免子进程环境问题。 """执行 adb 命令,返回 (stdout+stderr) 文本。不用 shell=True,避免子进程环境问题。
用 bytes 模式自己解码(Windows 下 subprocess text=True 会用 GBK 读 adb 输出, 用 bytes 模式自己解码(Windows 下 subprocess text=True 会用 GBK 读 adb 输出,
遇到非 GBK 字节会崩溃)。 遇到非 GBK 字节会崩溃)。
带 30s 超时:adb connect 到不可达地址可能挂很久,超时返回空串由调用方处理。 带 30s 超时:adb connect 到不可达地址可能挂很久,超时返回空串由调用方处理。
timeout 可覆盖(扫描验证等非关键路径用短超时快速失败)。
""" """
try: try:
r = subprocess.run([ADB_PATH, *args], capture_output=True, timeout=_ADB_TIMEOUT) r = subprocess.run([ADB_PATH, *args], capture_output=True, timeout=timeout)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
_log.warning(f"adb {' '.join(args)} 超时 {_ADB_TIMEOUT}s") _log.warning(f"adb {' '.join(args)} 超时 {timeout}s")
return "" return ""
return _decode(r)
def _adb_remote(host, port, *args):
"""在远程 adb server(如 220)上执行 adb 命令,返回文本。
USB 设备插在 220,本机 adb server 看不到,必须 -H/-P 指向 220 的 adb server
(host 网络模式,5037 全接口监听,Tailscale 可达)。超时语义同 _adb。
"""
try:
r = subprocess.run([ADB_PATH, "-H", host, "-P", str(port), *args],
capture_output=True, timeout=_ADB_TIMEOUT)
except subprocess.TimeoutExpired:
_log.warning(f"adb -H {host} -P {port} {' '.join(args)} 超时 {_ADB_TIMEOUT}s")
return ""
return _decode(r)
def _decode(r):
"""subprocess 结果解码(Windows GBK 兼容)。"""
out = (r.stdout or b"") + (r.stderr or b"") out = (r.stdout or b"") + (r.stderr or b"")
try: try:
return out.decode("utf-8", errors="replace").strip() return out.decode("utf-8", errors="replace").strip()
@@ -77,6 +98,41 @@ def get_foreground_app(remote_adb_url, timeout=3):
return None return None
def get_foreground_app_remote(host, port, serial, timeout=3):
"""远程 adb server(220)上设备的前台 App(USB 设备用)。
与 get_foreground_app 同逻辑,只是 adb 指向远程 server(-H/-P)。
"""
if not serial:
return None
try:
r = subprocess.run(
[ADB_PATH, "-H", host, "-P", str(port), "-s", serial, "shell",
"dumpsys window | grep mCurrentFocus"],
capture_output=True, timeout=timeout
)
out = ((r.stdout or b"") + (r.stderr or b"")).decode("utf-8", errors="replace").strip()
if not out:
return None
low = out.lower()
if "device offline" in low or "device not found" in low or "error:" in low:
return None
for line in out.split("\n"):
if "mCurrentFocus" not in line:
continue
m = re.search(r'\bu0\s+(\S+)', line)
if m:
token = m.group(1).rstrip("}")
if "/" in token:
return token.split("/")[0]
return token or None
return None
except subprocess.TimeoutExpired:
return None
except Exception:
return None
def adb_connect(remote_adb_url, retries=5): def adb_connect(remote_adb_url, retries=5):
"""adb connect(带重试)。返回 True/False。 """adb connect(带重试)。返回 True/False。
@@ -102,16 +158,18 @@ def _try_connect(remote_adb_url, retries):
return False return False
def adb_connect_light(remote_adb_url): def adb_connect_light(remote_adb_url, timeout=5):
"""轻量 adb connect(扫描专用):单次尝试,不重试,绝不 kill-server。 """轻量 adb connect(扫描/验证专用):单次尝试,不重试,绝不 kill-server。
与 adb_connect 的区别: 与 adb_connect 的区别:
- 不重试:失败立即返回 False - 不重试:失败立即返回 False
- 不 kill-server:避免断开其他 worker 正在使用的 ADB 连接 - 不 kill-server:避免断开其他 worker 正在使用的 ADB 连接
- 短超时(默认 5s):验证场景(设备发现)连不上的主机快速放弃,
避免每台卡 30s 拖慢整轮发现并长时间占用全局 adb 锁
用于前台 App 扫描等非关键路径,连不上就跳过,不影响正在运行的任务。 用于前台 App 扫描等非关键路径,连不上就跳过,不影响正在运行的任务。
""" """
with _ADB_LOCK: with _ADB_LOCK:
out = _adb("connect", remote_adb_url) out = _adb("connect", remote_adb_url, timeout=timeout)
if "connected" in out.lower() and "failed" not in out.lower(): if "connected" in out.lower() and "failed" not in out.lower():
return True return True
return False return False
+31 -10
View File
@@ -29,7 +29,6 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
from config import APK_DIR, ADB_PATH from config import APK_DIR, ADB_PATH
from core.logger import get_logger from core.logger import get_logger
from core.models import db, ApkFile as ApkRow from core.models import db, ApkFile as ApkRow
from .stf_client import STFClient
from .device_worker import get_all_worker_status from .device_worker import get_all_worker_status
_log = get_logger("core.apk") _log = get_logger("core.apk")
@@ -43,8 +42,7 @@ _INSTALL_CONCURRENCY = 5
class ApkManager: class ApkManager:
"""APK 文件管理 + 批量安装。""" """APK 文件管理 + 批量安装。"""
def __init__(self, stf_client=None, app=None): def __init__(self, app=None):
self.stf = stf_client or STFClient()
self.app = app # Flask app,用于 db context self.app = app # Flask app,用于 db context
os.makedirs(APK_DIR, exist_ok=True) os.makedirs(APK_DIR, exist_ok=True)
self._lock = threading.Lock() self._lock = threading.Lock()
@@ -194,10 +192,7 @@ class ApkManager:
if not serials: if not serials:
return False, "未选择设备" return False, "未选择设备"
# 获取设备信息(型号等) # 设备信息(型号从 worker 状态取,池内无型号信息)
try:
all_devices = {d["serial"]: d for d in self.stf.list_all_devices()}
except Exception:
all_devices = {} all_devices = {}
# 获取 worker 状态(判断设备是否运行中) # 获取 worker 状态(判断设备是否运行中)
@@ -305,12 +300,38 @@ class ApkManager:
"设备未在本地 adb 列表中,请检查 USB 连接/驱动") "设备未在本地 adb 列表中,请检查 USB 连接/驱动")
return return
# 安装 # 强制安装:绕过厂商安全防护(MIUI 等用 adb install 会弹"通过 USB 安装应用"
self._set_item(serial, "installing", "正在安装...") # 确认框需要手点;改为 推送 + pm install,shell 安装不弹窗)
# 1. 关闭 adb 安装校验(包校验器/安全守护拦截)
try:
_adb("-s", serial, "shell", "settings", "put", "global",
"verifier_verify_adb_installs", "0")
except Exception:
pass
# 2. 推送 APK 到设备(大 APK 经 WiFi 推送较慢,超时与安装共用)
self._set_item(serial, "installing", "正在推送 APK...")
r = subprocess.run( r = subprocess.run(
[ADB_PATH, "-s", serial, "install", "-r", apk_path], [ADB_PATH, "-s", serial, "push", apk_path, "/data/local/tmp/_install.apk"],
capture_output=True, timeout=_INSTALL_TIMEOUT capture_output=True, timeout=_INSTALL_TIMEOUT
) )
push_out = ((r.stdout or b"") + (r.stderr or b"")).decode("utf-8", errors="replace")
if r.returncode != 0 or "1 file pushed" not in push_out:
msg = push_out.strip().replace("\n", " ")[:150] or f"推送失败(returncode={r.returncode})"
self._set_item(serial, "failed", msg)
_log.warning(f"[{serial}] APK 推送失败: {msg}")
return
# 3. pm install 强制安装(-r 覆盖安装 / -t 允许测试包 / -d 允许降级版本)
self._set_item(serial, "installing", "正在安装...")
r = subprocess.run(
[ADB_PATH, "-s", serial, "shell", "pm", "install", "-r", "-t", "-d",
"/data/local/tmp/_install.apk"],
capture_output=True, timeout=_INSTALL_TIMEOUT
)
# 4. 清理设备临时文件(失败残留下次推送会覆盖,不影响)
try:
_adb("-s", serial, "shell", "rm", "-f", "/data/local/tmp/_install.apk")
except Exception:
pass
out = ((r.stdout or b"") + (r.stderr or b"")) out = ((r.stdout or b"") + (r.stderr or b""))
try: try:
out = out.decode("utf-8", errors="replace") out = out.decode("utf-8", errors="replace")
+69
View File
@@ -0,0 +1,69 @@
"""剪贴板注入公共模块(ClipInject APK 通道)。
背景:Android 10+ 禁止后台(atx-agent/shell)写剪贴板——u2.set_clipboard
调用"成功"但内容被系统静默丢弃(Android 12/13 实测)。ClipInject
(com.example.clipinject,用户自研 APK)通过 am start 启动透明 Activity,
窗口聚焦后 setPrimaryClip;shell 身份启动 Activity 不受后台启动限制
(广播方式在 MIUI 会被 Background activity start 拦截,必须用 am start)。
工具页(web/tools_api.py)与任务执行器(tasks/generic/task.py)共用本模块。
"""
import base64
import subprocess
import time
from concurrent.futures import ThreadPoolExecutor
from config import ADB_PATH
from core.adb_helper import adb_connect
from core.logger import get_logger
_log = get_logger("core.clip")
_CLIP_PACKAGE = "com.example.clipinject"
_CLIP_ACTIVITY = "com.example.clipinject/.ClipActivity"
def inject_clipboard(serial, text, d=None):
"""向设备剪贴板注入文字,返回 (ok, msg)。
- d:可选,任务执行器已持有 u2 Device 时传入复用(跳过重复连接),
读回验证直接用它;不传则临时 u2.connect(工具页场景,带超时保护)
- IP:port 设备先 adb connect(已连接自动跳过;绝不 disconnect,红线)
- 未装 ClipInject / 读回不一致都会明确报错(不再静默失败)
"""
import uiautomator2 as u2
if ":" in serial:
try:
adb_connect(serial)
except Exception as e:
return False, f"adb 连接失败: {e}"
b64 = base64.b64encode(text.encode("utf-8")).decode()
# 1. am start 透明 Activity 写剪贴板(shell 身份,不受后台启动限制)
try:
r = subprocess.run(
[ADB_PATH, "-s", serial, "shell", "am", "start",
"-n", _CLIP_ACTIVITY, "--es", "text_b64", b64],
capture_output=True, timeout=30)
out = ((r.stdout or b"") + (r.stderr or b"")).decode(errors="replace")
if "does not exist" in out or "unable to resolve Intent" in out:
return False, f"设备未安装 ClipInject({_CLIP_PACKAGE}),请先安装"
if "Error" in out or ("Starting" not in out and r.returncode != 0):
return False, f"am start 失败: {out.strip()[:120]}"
except subprocess.TimeoutExpired:
return False, "am start 超时(adb 无响应)"
except Exception as e:
return False, f"{type(e).__name__}: {str(e)[:120]}"
# 2. 读回验证(等透明 Activity 完成写入;u2 读剪贴板在 Android 12 实测可用)
time.sleep(0.5)
try:
if d is None:
with ThreadPoolExecutor(max_workers=1) as pool:
d = pool.submit(u2.connect, serial).result(timeout=30)
with ThreadPoolExecutor(max_workers=1) as pool:
got = pool.submit(lambda: d.clipboard).result(timeout=15)
if got == text:
return True, "已注入"
return False, f"剪贴板读回不一致(写入可能被拒): {str(got)[:60]!r}"
except Exception as e:
# 读回不可用(旧版 u2/连接失败)时按写入成功处理,避免误报,但带上具体原因
return True, f"已注入(读回验证不可用: {type(e).__name__}: {str(e)[:80]})"
+401
View File
@@ -0,0 +1,401 @@
"""设备自动发现:扫描网段中开放 adb 5555 的设备 → 待连接池(pending)。
流程:socket 并发探测 5555(标准库,不碰 adb)→ 剔除已在设备池的 serial →
串行 adb 验证(adb_connect_light,内部全局锁)→ 用 `adb devices` 的
state=="device" 过滤(排除 unauthorized/offline,connect 输出不可信)→
写入 pending_device 表。**扫描只验证、不自动连接入池**——用户在前端确认后
才调 device_pool.add_device + adb_connect(见 web/devices_api.py)。
安全红线:绝不 kill-server / 绝不 disconnect(与全项目一致)。
配置存 app_meta KV 表(discovery_enabled/subnets/interval/port),定时线程
每轮重读,开关/周期/网段改动即时生效无需重启。
"""
import ipaddress
import json
import socket
import threading
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from config import (USB_ADB_HOST,
DISCOVERY_PORT, DISCOVERY_SUBNETS, DISCOVERY_INTERVAL)
from core.adb_helper import _adb, adb_connect_light
from core.logger import get_logger
from core.models import db, Device, PendingDevice
_log = get_logger("core.disc")
# 扫描配置键(app_meta)
_K_ENABLED = "discovery_enabled"
_K_SUBNETS = "discovery_subnets"
_K_INTERVAL = "discovery_interval"
_K_PORT = "discovery_port"
# 单网段主机数上限(防误配 /8 之类),超过截断并告警
_MAX_HOSTS_PER_SUBNET = 1024
# socket 探测并发数与单 IP 超时
_PROBE_WORKERS = 64
_PROBE_TIMEOUT = 0.4
_app = None
_scan_lock = threading.Lock() # 定时/手动扫描互斥
_stop_event = threading.Event() # shutdown 用
_scanning = False # 状态快照(API/前端)
_last_scan = None # (时间串, 开放数, 可连数, 新增数)
_last_error = ""
def _ctx():
"""后台线程访问 db 时自行推 app context(仿 device_pool._ctx)。"""
return _app.app_context() if _app else None
def _fmt(ts=None):
return (ts or datetime.now()).strftime("%Y-%m-%d %H:%M")
# ================== 配置读写 ==================
def get_settings():
"""读发现配置(app_meta,缺键用 config 默认值补齐)。"""
with _ctx():
def _get(key, default):
v = db.session.execute(
db.text("SELECT value FROM app_meta WHERE key=:k"), {"k": key}).scalar()
return v if v is not None else default
try:
subnets = json.loads(_get(_K_SUBNETS, "[]")) or DISCOVERY_SUBNETS
except Exception:
subnets = DISCOVERY_SUBNETS
return {
"enabled": _get(_K_ENABLED, "1") == "1", # 默认开启
"subnets": subnets,
"interval": int(_get(_K_INTERVAL, str(DISCOVERY_INTERVAL)) or DISCOVERY_INTERVAL),
"port": int(_get(_K_PORT, str(DISCOVERY_PORT)) or DISCOVERY_PORT),
}
def save_settings(enabled=None, subnets=None, interval=None, port=None):
"""写发现配置(部分字段更新)。subnets 逐项校验 CIDR,非法返回 (False, 原因)。"""
if subnets is not None:
clean = []
for s in subnets:
s = (s or "").strip()
if not s:
continue
try:
ipaddress.ip_network(s, strict=False)
except ValueError:
return False, f"网段格式错误: {s}"
clean.append(s)
subnets = clean
try:
if interval is not None and (int(interval) < 10 or int(interval) > 3600):
return False, "扫描周期需在 10-3600 秒之间"
if port is not None and (int(port) < 1 or int(port) > 65535):
return False, "端口不合法"
except (TypeError, ValueError):
return False, "参数不合法"
with _ctx():
def _put(key, value):
db.session.execute(
db.text("INSERT OR REPLACE INTO app_meta(key,value) VALUES(:k,:v)"),
{"k": key, "v": str(value)})
if enabled is not None:
_put(_K_ENABLED, "1" if enabled else "0")
if subnets is not None:
_put(_K_SUBNETS, json.dumps(subnets))
if interval is not None:
_put(_K_INTERVAL, str(int(interval)))
if port is not None:
_put(_K_PORT, str(int(port)))
db.session.commit()
return True, "已保存"
# ================== 网段展开与端口探测 ==================
def _local_ips():
"""本机自身 IP 集合(尽力而为):扫描时排除,避免探测到自己的 5555。
优先 `hostname -I`(Linux/macOS 支持,一行空格分隔多 IP);不支持/失败的
平台退回 socket.getaddrinfo 枚举。关键:外部命令必须用 bytes 收——Windows
上 git-bash 的 coreutils hostname 不支持 -I,会把 GBK 报错写进 stderr,
text=True 在 subprocess 后台读线程里 utf-8 严格解码会直接炸线程(主线程
try/except 接不住异步线程异常)。
"""
import subprocess
ips = set()
try:
r = subprocess.run(["hostname", "-I"], capture_output=True, timeout=3)
if r.returncode == 0:
ips.update(p for p in
r.stdout.decode("utf-8", errors="ignore").split() if p)
except Exception:
pass
if not ips: # 兜底:主机名解析出的接口 IPv4
try:
for info in socket.getaddrinfo(socket.gethostname(), None):
ip = info[4][0]
if ":" not in ip:
ips.add(ip)
except Exception:
pass
return ips
def _expand_subnets(subnets, max_hosts=_MAX_HOSTS_PER_SUBNET):
"""CIDR 列表 → IP 列表。排除 220 自身(USB_ADB_HOST)与本机 IP;
非法网段跳过记日志;单网段超过 max_hosts 截断并告警。"""
self_ips = {USB_ADB_HOST} | _local_ips()
ips = []
for cidr in subnets:
try:
net = ipaddress.ip_network((cidr or "").strip(), strict=False)
except ValueError:
_log.warning(f"发现: 跳过非法网段 {cidr}")
continue
hosts = [str(h) for h in net.hosts()
if str(h) not in self_ips]
if len(hosts) > max_hosts:
_log.warning(f"发现: 网段 {cidr} 主机数 {len(hosts)} 超过上限,截断前 {max_hosts}")
hosts = hosts[:max_hosts]
ips.extend(hosts)
return ips
def _probe_port(ip, port, timeout=_PROBE_TIMEOUT):
"""单 IP 端口探测(标准库 socket,超时/拒绝/不可达一律 False)。"""
try:
with socket.create_connection((ip, port), timeout=timeout):
return True
except Exception:
return False
def _probe_open(ips, port, workers=_PROBE_WORKERS):
"""并发探测,返回开放端口的主机列表。"""
if not ips:
return []
with ThreadPoolExecutor(max_workers=workers) as ex:
results = list(ex.map(lambda ip: (ip, _probe_port(ip, port)), ips))
return [ip for ip, ok in results if ok]
# ================== adb devices 解析 ==================
def _parse_adb_devices(out):
"""解析 `adb devices` 输出 → state=="device" 的 serial 集合。
只看 connect 输出不可靠(未授权设备也返回 connected),必须用 state 过滤。
"""
devices = set()
for line in (out or "").splitlines()[1:]:
parts = line.split()
if len(parts) >= 2 and parts[0] and not parts[0].startswith("*"):
if parts[1] == "device":
devices.add(parts[0])
return devices
# ================== 扫描 ==================
def scan_once(manual=False):
"""执行一轮扫描。返回 (ok, result_dict);后台线程/API 调用。"""
global _scanning, _last_scan, _last_error
if not _scan_lock.acquire(blocking=False):
return False, {"error": "扫描进行中"}
_scanning = True
try:
settings = get_settings()
port = settings["port"]
subnets = settings["subnets"]
if not subnets:
_last_error = "未配置扫描网段"
return True, {"found": 0, "verified": 0, "new": 0, "error": _last_error}
# 1. socket 并发探测(不碰 adb,把几百台缩小到开放端口量级)
open_ips = _probe_open(_expand_subnets(subnets), port)
candidates = {f"{ip}:{port}" for ip in open_ips}
with _ctx():
# 2. 剔除已在正式设备池的 serial
configured = set(device_pool_list_configured())
candidates -= configured
# 3. 串行 adb 验证:已连接的直接跳过(零成本),其余 adb_connect_light
already = _parse_adb_devices(_adb("devices"))
for serial in candidates - already:
adb_connect_light(serial) # 内部全局锁串行;失败无妨,下面按 state 过滤
# 4. state==device 过滤(排除 unauthorized/offline)
verified = _parse_adb_devices(_adb("devices")) & candidates
# 5. 写 pending:新 → 插入;已有 → 更新 last_seen
now = _fmt()
existing = {p.serial for p in PendingDevice.query.all()}
added = 0
for serial in verified:
source = "tailscale" if serial.split(":")[0].startswith("100.") else "lan"
if serial in existing:
PendingDevice.query.filter_by(serial=serial).update(
{"last_seen": now})
else:
db.session.add(PendingDevice(serial=serial, source=source,
first_seen=now, last_seen=now))
added += 1
db.session.commit()
# 6. 正式池断联设备自动重连:adb connect 会因 WiFi 波动/设备重启/
# adb 服务重启而断开——扫描线程每轮顺带重试(幂等轻量,内部
# 全局锁串行),连上即恢复在线,无需人工干预。pending 池是给
# 「未授权新设备」的,正式池设备断联不进 pending,而是自动重连。
back = _reconnect_offline(configured)
_log.info(f"发现: 探测开放 {len(open_ips)} 台,可连 {len(verified)} 台,"
f"新增待连接 {added} 台"
+ (f",自动重连恢复 {len(back)} 台 {back}" if back else ""))
_last_scan = (now, len(open_ips), len(verified), added)
_last_error = ""
return True, {"found": len(open_ips), "verified": len(verified), "new": added}
except Exception as e:
_log.warning(f"发现扫描异常: {e}")
_last_error = str(e)[:200]
return False, {"error": _last_error}
finally:
_scanning = False
_scan_lock.release()
def device_pool_list_configured():
"""设备池已配置 serial(延迟 import 避免循环依赖)。"""
from core import device_pool
return device_pool.list_configured()
# ================== 正式池断联设备:自动重连 ==================
def _reconnect_offline(configured):
"""对正式池中断联的网络设备逐个 adb 重连,返回恢复的 serial 列表。
只重连网络设备(IP:5555;USB 设备插着就在,无需 connect)。
幂等轻量:内部 adb 全局锁串行,失败静默(下轮扫描再试)。
"""
online_now = _parse_adb_devices(_adb("devices"))
targets = [s for s in (configured or [])
if ":" in s and s not in online_now]
if not targets:
return []
for serial in targets:
adb_connect_light(serial)
online_after = _parse_adb_devices(_adb("devices"))
return [s for s in targets if s in online_after]
def list_pool_offline():
"""正式设备池中断联的设备(serial + 型号 + 备注名),面板展示用。
断联设备仍是正式池成员(不删除、不进 pending)——pending 是给未授权
新设备的;它们由扫描线程每轮自动重连,也可前端手动立即重连。
"""
online = _parse_adb_devices(_adb("devices"))
with _ctx():
rows = [d.to_dict() for d in Device.query.filter_by(enabled=True)
.order_by(Device.serial).all()]
return [{"serial": r["serial"], "model": r.get("model") or "",
"name": r.get("name") or ""}
for r in rows if r["serial"] not in online]
# ================== 定时扫描线程 ==================
def _discovery_loop():
"""定时扫描 daemon 线程。每轮重读配置(开关/周期即时生效)。
启动先 sleep 15s 避让 web_server 预连接线程(两者都抢 adb 全局锁)。
等待用 30s 切片(_stop_event.wait),关停/改周期 ≤30s 生效。
"""
try:
_stop_event.wait(15)
while not _stop_event.is_set():
try:
settings = get_settings()
if settings["enabled"]:
scan_once()
except Exception as e:
_log.warning(f"发现定时扫描异常: {e}")
wait_s = max(10, settings.get("interval", 60))
waited = 0
while waited < wait_s and not _stop_event.is_set():
_stop_event.wait(min(30, wait_s - waited))
waited += min(30, wait_s - waited)
except Exception:
pass
def init_app(app):
"""web_server 启动时调用:绑 app + 起定时扫描 daemon 线程。"""
global _app
_app = app
_stop_event.clear()
t = threading.Thread(target=_discovery_loop, name="device-discovery", daemon=True)
t.start()
_log.info("设备自动发现线程已启动(默认 60s 扫描一次)")
def shutdown():
"""优雅退出:置 stop_event,等待中的循环在切片边界退出。"""
_stop_event.set()
# ================== 对外查询与确认 ==================
def get_status():
"""API 状态快照:配置 + 扫描状态 + 最近一轮结果 + 待连接数量。"""
settings = get_settings()
with _ctx():
pending = PendingDevice.query.count()
return {
"enabled": settings["enabled"],
"subnets": settings["subnets"],
"interval": settings["interval"],
"port": settings["port"],
"scanning": _scanning,
"last_scan": _last_scan[0] if _last_scan else "",
"last_result": ({"found": _last_scan[1], "verified": _last_scan[2],
"new": _last_scan[3]} if _last_scan else None),
"last_error": _last_error,
"pending_count": pending,
}
def list_pending():
"""待连接列表:只返回当前在线的设备(离线候选不可确认,不展示)。
后端直接过滤(2026-09-04):不依赖前端 JS 版本,任何客户端都拿不到
离线条目;设备恢复在线后扫描自动更新 last_seen 并重新出现在列表。
"""
online = _parse_adb_devices(_adb("devices"))
with _ctx():
rows = [p.to_dict() for p in PendingDevice.query.order_by(
PendingDevice.first_seen.desc()).all()]
return [dict(r, online=True) for r in rows if r["serial"] in online]
def confirm_pending(serial, name=""):
"""确认连接:pending 行 → 正式设备池(add_device upsert)→ 删 pending。
返回 (ok, msg, is_new)。adb_connect + 采型号由 API 层后台线程做。
"""
with _ctx():
row = PendingDevice.query.get(serial)
if not row:
return False, "设备不在待连接列表", False
from core import device_pool
is_new = device_pool.add_device(serial, name=name or "")
PendingDevice.query.filter_by(serial=serial).delete()
db.session.commit()
return True, "已加入设备池" + ("" if is_new else "(已存在,信息已更新)"), is_new
def ignore_pending(serial):
"""忽略:删除 pending 行(下轮扫描可能再次发现)。"""
with _ctx():
row = PendingDevice.query.get(serial)
if not row:
return False, "设备不在待连接列表"
PendingDevice.query.filter_by(serial=serial).delete()
db.session.commit()
return True, "已忽略"
+213
View File
@@ -0,0 +1,213 @@
"""设备池:SQLite devices 表 = 设备清单,本地 adb = 在线状态(STF 替代数据源)。
设计:
- list_configured() — devices 表里 enabled 的设备(清单,管理页维护)
- list_online() — 本机 `adb devices` 里 state=device 的设备(实时;
池内有 USB 设备时合并 220 远程 adb server 状态)
- list_ready() — configured ∩ online(任务调度用)
- CRUD — add/update/remove/set_enabled(设备池管理页用)
红线:全模块不 connect / 不 kill-server / 不 disconnect,遵守既有技术约束。
后台线程(task_manager 等)调用时由本模块自行推 app context,调用方无需关心。
"""
import time
import threading
import subprocess
from config import ADB_PATH, USB_ADB_HOST, USB_ADB_PORT
from core.adb_helper import _adb, _adb_remote
from core.logger import get_logger
from core.models import db, Device
_log = get_logger("core.device_pool")
_app = None
def init_app(app):
"""web_server 启动时调用:绑定 Flask app(供后台线程推 db context),
并后台刷新一次设备型号(首次启动/设备更换后型号可能变化)。"""
global _app
_app = app
try:
t = threading.Thread(target=_refresh_models_bg, daemon=True)
t.start()
except Exception:
pass
def _refresh_models_bg():
"""后台批量采集在线设备型号(启动时/手动触发)。失败静默,不影响启动。"""
time.sleep(3) # 等服务起来再查
try:
refresh_all_models()
except Exception as e:
_log.warning(f"启动型号采集失败(可稍后手动采集): {e}")
def _ctx():
"""后台线程访问 db 需自行推 app context(与 task_manager._db 同思路)。"""
if _app is None:
raise RuntimeError("device_pool 未关联 Flask app(web_server 启动时调用 init_app)")
return _app.app_context()
# ================== 清单 ==================
def list_configured(enabled_only=True):
"""devices 表里的设备清单(serial 列表)。"""
with _ctx():
q = Device.query
if enabled_only:
q = q.filter_by(enabled=True)
return [d.serial for d in q.order_by(Device.serial).all()]
def list_devices():
"""全量设备行(设备池管理页用)。"""
with _ctx():
return [d.to_dict() for d in Device.query.order_by(Device.serial).all()]
# ================== 在线状态 ==================
def list_online():
"""本机 adb 中 state=device 的设备 + 池内 USB 设备(经 220 远程 adb server)。
池内无 USB 设备(serial 无冒号)时不查远程 server,零额外开销。
"""
out = _adb("devices") or ""
serials = []
for line in out.splitlines()[1:]:
parts = line.split()
if len(parts) >= 2 and parts[0] and parts[1] == "device":
serials.append(parts[0])
# 池内有 USB 设备时,补查远程 adb server(220,host 网络 5037 全接口监听)
try:
usb = [s for s in list_configured() if ":" not in s]
except Exception:
usb = []
if usb:
try:
out = _adb_remote(USB_ADB_HOST, USB_ADB_PORT, "devices") or ""
for line in out.splitlines()[1:]:
parts = line.split()
if len(parts) >= 2 and parts[0] and parts[1] == "device":
if parts[0] not in serials:
serials.append(parts[0])
except Exception:
pass
return serials
def is_online(serial):
"""设备当前是否在线(本机 adb 可达且状态正常)。"""
return serial in list_online()
def list_ready():
"""可调度设备:清单 ∩ 在线(取代 STF list_free_devices)。"""
online = set(list_online())
return [s for s in list_configured() if s in online]
# ================== 管理(CRUD) ==================
def add_device(serial, name="", note="", enabled=True):
"""添加/更新设备(upsert)。返回 True 新增 / False 已存在并更新。"""
serial = (serial or "").strip()
if not serial:
return False
with _ctx():
d = Device.query.get(serial)
if d:
d.name, d.note, d.enabled = name or "", note or "", enabled
db.session.commit()
return False
db.session.add(Device(serial=serial, name=name or "", note=note or "",
enabled=enabled,
created_at=time.strftime("%Y-%m-%d %H:%M")))
db.session.commit()
_log.info(f"设备池新增设备: {serial}")
return True
def remove_device(serial):
"""删除设备。返回是否删除成功。"""
with _ctx():
d = Device.query.get(serial)
if not d:
return False
db.session.delete(d)
db.session.commit()
_log.info(f"设备池删除设备: {serial}")
return True
def set_enabled(serial, enabled):
"""启用/停用设备(停用后不参与调度)。返回是否成功。"""
with _ctx():
d = Device.query.get(serial)
if not d:
return False
d.enabled = enabled
db.session.commit()
return True
# ================== 型号采集 ==================
def refresh_model(serial, timeout=8):
"""采集单台设备型号(getprop ro.product.model)并写回 DB。
IP:5555 设备走本机 adb;USB 设备先查本机 adb,查不到再查 220 远程 adb server。
采集失败返回空串(静默,不影响主流程)。返回采集到的型号。
"""
if not serial:
return ""
model = ""
if ":" in serial:
try:
r = subprocess.run([ADB_PATH, "-s", serial, "shell",
"getprop", "ro.product.model"],
capture_output=True, timeout=timeout)
model = (r.stdout or b"").decode("utf-8", errors="replace").strip()
except Exception:
model = ""
else:
# USB:先本机,再远程
try:
r = subprocess.run([ADB_PATH, "-s", serial, "shell",
"getprop", "ro.product.model"],
capture_output=True, timeout=timeout)
model = (r.stdout or b"").decode("utf-8", errors="replace").strip()
except Exception:
model = ""
if not model:
model = _adb_remote(USB_ADB_HOST, USB_ADB_PORT, "-s", serial,
"shell", "getprop", "ro.product.model")
model = model.strip() or ""
if not model:
return ""
with _ctx():
d = Device.query.get(serial)
if d and d.model != model:
d.model = model
db.session.commit()
_log.info(f"设备池采集型号: {serial} -> {model}")
return model
def refresh_all_models():
"""批量采集池内在线设备的型号(并发 10,后台线程调用)。返回成功数。"""
serials = list_online()
if not serials:
return 0
from concurrent.futures import ThreadPoolExecutor, as_completed
ok = 0
with ThreadPoolExecutor(max_workers=min(10, len(serials))) as pool:
futures = {pool.submit(refresh_model, s): s for s in serials}
for fut in as_completed(futures):
try:
if fut.result():
ok += 1
except Exception:
pass
_log.info(f"设备池批量采集型号完成: {ok}/{len(serials)} 台")
return ok
+86 -53
View File
@@ -1,9 +1,9 @@
"""设备生命周期 + 通用 Worker 基类 + 全局状态注册表 + 心跳看门狗。 """设备生命周期 + 通用 Worker 基类 + 全局状态注册表 + 心跳看门狗。
STFDevice — 单设备生命周期:acquire(占用+adb连接)→ release(断开+释放) STFDevice — 单设备生命周期:acquire(直连/远程 server)→ release
BaseWorker — 通用 worker 基类:封装 STF 生命周期 + u2 连接 + 状态上报 + stop BaseWorker — 通用 worker 基类:封装设备生命周期 + u2 连接 + 状态上报 + stop
+ 生命周期钩子(setup/teardown/on_error) + 心跳上报 + 生命周期钩子(setup/teardown/on_error) + 心跳上报
子类只需实现 run_task(d),可选实现钩子,不用管 STF/adb/状态/异常 子类只需实现 run_task(d),可选实现钩子,不用管 adb/状态/异常
_WORKERS — 全局状态注册表,供 web_server 读取实时状态 _WORKERS — 全局状态注册表,供 web_server 读取实时状态
_watchdog — 心跳看门狗线程,检测 worker 卡死 _watchdog — 心跳看门狗线程,检测 worker 卡死
@@ -16,12 +16,25 @@ from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeou
import uiautomator2 as u2 import uiautomator2 as u2
from config import USB_ADB_HOST, USB_ADB_PORT
from core.logger import get_logger from core.logger import get_logger
from core.stf_client import DeviceOfflineError, STFError from core import device_pool
from .adb_helper import adb_connect, adb_disconnect from .adb_helper import _adb, adb_connect, _adb_remote
_log = get_logger("core.worker") _log = get_logger("core.worker")
class STFError(Exception):
"""设备操作错误(历史名称保留兼容)。code: offline/conflict/..."""
def __init__(self, message, code=""):
super().__init__(message)
self.code = code
class DeviceOfflineError(STFError):
"""设备离线/不可用:任务不重试(换设备也没用)。"""
# 心跳超时阈值(秒)。worker 超过这个时间没更新心跳,判定为卡死 # 心跳超时阈值(秒)。worker 超过这个时间没更新心跳,判定为卡死
_HEARTBEAT_TIMEOUT = 120 _HEARTBEAT_TIMEOUT = 120
# 看门狗检查间隔 # 看门狗检查间隔
@@ -32,65 +45,58 @@ _U2_INFO_TIMEOUT = 10
class STFDevice: class STFDevice:
"""单设备生命周期:acquire(占用+连接)→ release(断开+释放)。 """单设备生命周期:acquire(直连)→ release(无操作)。
用 try/finally 保证设备一定被释放,即使养号中途报错。 阶段 2:已摘除 STF occupy/release 与 remoteConnect 桥接——
单实例互斥由 TaskManager._running 保证;非 IP:port(USB 序列号)设备
通过 220 的远程 adb server(-H/-P)驱动,不再依赖 STF。
""" """
def __init__(self, stf_client, serial=None): def __init__(self, serial=None):
self.stf = stf_client
self.serial = serial self.serial = serial
self.remote_adb_url = None self.remote_adb_url = None # IP:5555 设备:设备地址
self.usb_server = None # USB 设备:远程 adb server 地址(host:port)
def acquire(self, serial=None): def acquire(self, serial=None):
self.serial = serial or self.serial or self._pick_free() self.serial = serial or self.serial or self._pick_free()
_log.info(f"[{self.serial}] 选定设备") _log.info(f"[{self.serial}] 选定设备")
ok, msg = self.stf.occupy(self.serial)
if not ok:
raise STFError(f"占用设备失败: {msg}", "conflict")
_log.info(f"[{self.serial}] STF 占用成功")
# 直连优先:serial 本身是 IP:5555(如 Tailscale 网络设备)时直接 adb connect。 # 直连优先:serial 本身是 IP:5555(如 Tailscale 网络设备)时直接 adb connect。
# 实测(本机 100.100.10.2,设备 100.100.10.x): # 只 connect、绝不 disconnect/kill-server(红线),不影响共享 adb transport。
# - 本机在 tailnet 内时直连可靠且快(<1s),且只 connect、绝不 disconnect/kill-server,
# 不会影响 STF provider 共享的 adb 连接(STF 端设备保持 present/ready)。
# - STF remoteConnect 桥接在此环境反而有 adb key 认证问题(隧道显示 unauthorized,
# 即使设备已授权本机 key),直连才是可靠路径。
# 仅当 serial 不是 IP:port(如 USB 序列号)时才走 STF 桥接。
if ":" in self.serial: if ":" in self.serial:
self.remote_adb_url = self.serial self.remote_adb_url = self.serial
_log.info(f"[{self.serial}] 直连设备: {self.remote_adb_url}") _log.info(f"[{self.serial}] 直连设备: {self.remote_adb_url}")
else:
self.remote_adb_url = self.stf.remote_connect(self.serial)
_log.info(f"[{self.serial}] STF 桥接: {self.remote_adb_url}")
if not adb_connect(self.remote_adb_url): if not adb_connect(self.remote_adb_url):
raise STFError(f"adb connect 失败: {self.remote_adb_url}", "offline") raise STFError(f"adb connect 失败: {self.remote_adb_url}", "offline")
else:
# USB 设备(serial 无冒号):优先本机 adb(插在平台本机),
# 其次 220 远程 adb server(插在 220 上,经 Tailscale 驱动)。
# 两者都不需要 adb connect——设备已连接在对应 server 上,只做可见性校验。
local = {line.split()[0] for line in (_adb("devices") or "").splitlines()[1:]
if len(line.split()) >= 2}
if self.serial in local:
self.remote_adb_url = self.serial # u2 走本地 adb(无需 connect)
_log.info(f"[{self.serial}] USB 设备,本机 adb 直连")
else:
self.usb_server = f"{USB_ADB_HOST}:{USB_ADB_PORT}"
_log.info(f"[{self.serial}] USB 设备,远程 adb server: {self.usb_server}")
out = _adb_remote(USB_ADB_HOST, USB_ADB_PORT, "devices") or ""
seen = {line.split()[0] for line in out.splitlines()[1:]
if len(line.split()) >= 2}
if self.serial not in seen:
raise STFError(
f"USB 设备 {self.serial} 不在本机 adb 也不在远程 adb server", "offline")
time.sleep(2) time.sleep(2)
def _pick_free(self): def _pick_free(self):
free = self.stf.list_free_devices() free = device_pool.list_ready()
if not free: if not free:
raise STFError("没有可用的在线空闲设备", "offline") raise STFError("没有可用的在线空闲设备", "offline")
return free[0]["serial"] return free[0]
def release(self): def release(self):
if not self.serial: # 直连模式:不 disconnect(红线,STF 共享 transport);USB:无连接可断
return
# 直连模式(remote_adb_url == serial):不 disconnect
# STF provider 共享该 IP:5555 的 adb transport,disconnect 会让 STF 误判离线并重连
if self.remote_adb_url and self.remote_adb_url != self.serial:
adb_disconnect(self.remote_adb_url)
try:
self.stf.remote_disconnect(self.serial)
except Exception:
pass pass
ok, msg = self.stf.release(self.serial)
if ok:
_log.info(f"[{self.serial}] 已释放")
else:
_log.warning(f"[{self.serial}] STF 释放失败: {msg}")
# ================== 全局 worker 状态注册表(供 web_server 读取) ================== # ================== 全局 worker 状态注册表(供 web_server 读取) ==================
@@ -214,11 +220,11 @@ def stop_watchdog():
class BaseWorker(threading.Thread): class BaseWorker(threading.Thread):
"""通用 worker 基类。 """通用 worker 基类。
自动处理:STF 占用/释放、u2 连接、状态上报、异常捕获、stop 信号、心跳。 自动处理:设备获取、u2 连接、状态上报、异常捕获、stop 信号、心跳。
子类只需实现 run_task(d) 方法,专注业务逻辑。 子类只需实现 run_task(d) 方法,专注业务逻辑。
生命周期(基类 run() 已封装,不要重写): 生命周期(基类 run() 已封装,不要重写):
1. acquire 设备(STF 占用 + adb 连接) 1. acquire 设备(直连 IP:5555 / USB 走远程 adb server)
2. u2.connect 拿到 d 2. u2.connect 拿到 d
3. 调用 setup(d) ← 子类可选钩子,做初始化 3. 调用 setup(d) ← 子类可选钩子,做初始化
4. 调用子类 run_task(d) ← 业务逻辑 4. 调用子类 run_task(d) ← 业务逻辑
@@ -259,9 +265,8 @@ class BaseWorker(threading.Thread):
其他异常 — 可重试 其他异常 — 可重试
""" """
def __init__(self, stf_client, serial, params=None, daemon=True): def __init__(self, serial, params=None, daemon=True):
super().__init__(daemon=daemon) super().__init__(daemon=daemon)
self.stf = stf_client
self.serial = serial self.serial = serial
self.params = params or {} self.params = params or {}
self._stop_flag = threading.Event() self._stop_flag = threading.Event()
@@ -359,14 +364,19 @@ class BaseWorker(threading.Thread):
# ================== 基类主循环(不要重写) ================== # ================== 基类主循环(不要重写) ==================
def run(self): def run(self):
"""基类主循环:不要重写。子类实现 run_task + 可选钩子。""" """基类主循环:不要重写。子类实现 run_task + 可选钩子。"""
device = STFDevice(self.stf, serial=self.serial) device = STFDevice(serial=self.serial)
_update_status(self.serial, status="connecting", stf_occupied=False, _update_status(self.serial, status="connecting",
last_error="", remote_adb_url="", model="", last_error="", remote_adb_url="", usb_server="", model="",
current_action="") current_action="")
try: try:
device.acquire() device.acquire()
remote = device.remote_adb_url remote = device.remote_adb_url
# u2.connect 可能 hang 住(atx-agent 无响应),用线程池+超时保护 usb_server = device.usb_server
# u2.connect 可能 hang 住(atx-agent 无响应),用线程池+超时保护;
# USB 设备(无 IP:5555)经远程 adb server 建 u2 连接
if usb_server:
self.d = self._u2_connect_usb_with_timeout(usb_server)
else:
self.d = self._u2_connect_with_timeout(remote) self.d = self._u2_connect_with_timeout(remote)
if self.d is None: if self.d is None:
raise STFError(f"u2.connect 超时 {_U2_CONNECT_TIMEOUT}s,atx-agent 无响应", "offline") raise STFError(f"u2.connect 超时 {_U2_CONNECT_TIMEOUT}s,atx-agent 无响应", "offline")
@@ -378,8 +388,9 @@ class BaseWorker(threading.Thread):
model = info.get("productName") or "" model = info.get("productName") or ""
except Exception: except Exception:
pass pass
_update_status(self.serial, status="running", stf_occupied=True, _update_status(self.serial, status="running",
remote_adb_url=remote, model=model) remote_adb_url=remote or "", usb_server=usb_server or "",
model=model)
# setup 钩子 # setup 钩子
try: try:
@@ -432,9 +443,9 @@ class BaseWorker(threading.Thread):
with _WORKERS_LOCK: with _WORKERS_LOCK:
cur = _WORKERS.get(self.serial, {}).get("status") cur = _WORKERS.get(self.serial, {}).get("status")
if cur in ("running", "connecting"): if cur in ("running", "connecting"):
_update_status(self.serial, stf_occupied=False, status="released") _update_status(self.serial, status="released")
else: else:
_update_status(self.serial, stf_occupied=False) pass
def _u2_connect_with_timeout(self, remote): def _u2_connect_with_timeout(self, remote):
"""u2.connect 带超时保护,避免 atx-agent 无响应时永久 hang。""" """u2.connect 带超时保护,避免 atx-agent 无响应时永久 hang。"""
@@ -445,6 +456,28 @@ class BaseWorker(threading.Thread):
except FuturesTimeout: except FuturesTimeout:
_log.error(f"[{self.serial}] u2.connect 超时 {_U2_CONNECT_TIMEOUT}s") _log.error(f"[{self.serial}] u2.connect 超时 {_U2_CONNECT_TIMEOUT}s")
return None return None
def _u2_connect_usb_with_timeout(self, server):
"""USB 设备:经远程 adb server(220)建 u2 连接,带超时保护。
u2 3.7 底层 adbutils 支持远程 adb server:AdbClient(host, port).device(serial),
再交给 u2.connect(AdbDevice)(与本地连接同语义,无需 adb connect)。
"""
host, _, port = server.partition(":")
with ThreadPoolExecutor(max_workers=1) as pool:
def _connect():
from adbutils import AdbClient
dev = AdbClient(host=host, port=int(port)).device(self.serial)
return u2.connect(dev)
fut = pool.submit(_connect)
try:
return fut.result(timeout=_U2_CONNECT_TIMEOUT)
except FuturesTimeout:
_log.error(f"[{self.serial}] u2 远程连接超时 {_U2_CONNECT_TIMEOUT}s")
return None
except Exception as e:
_log.error(f"[{self.serial}] u2 远程连接失败: {e}")
return None
except Exception as e: except Exception as e:
_log.error(f"[{self.serial}] u2.connect 异常: {e}") _log.error(f"[{self.serial}] u2.connect 异常: {e}")
return None return None
+63
View File
@@ -216,11 +216,63 @@ class ApkFile(db.Model):
return f"<ApkFile {self.display_name}>" return f"<ApkFile {self.display_name}>"
class Device(db.Model):
"""设备池(本地设备清单,替代 STF 池作为调度数据源)。
serial 即 adb 序列号(IP:5555 或 USB 序列号);enabled=False 不参与调度。
model 为在线时自动采集的型号(如 Redmi 12C),供管理页/监控页区分设备。
"""
serial = db.Column(db.String(120), primary_key=True)
name = db.Column(db.String(80), default="") # 备注名(可选)
model = db.Column(db.String(120), default="") # 型号(自动采集)
enabled = db.Column(db.Boolean, default=True) # 是否参与调度
note = db.Column(db.Text, default="") # 备注
created_at = db.Column(db.String(20), default="") # 添加时间
def to_dict(self):
return {"serial": self.serial, "name": self.name or "",
"model": self.model or "", "enabled": bool(self.enabled),
"note": self.note or "", "created_at": self.created_at or ""}
def __repr__(self):
return f"<Device {self.serial}>"
class PendingDevice(db.Model):
"""自动发现待连接池:扫描验证通过的设备,等待用户确认后才入正式池。
与 Device 表的区别:只代表"被扫描到、可连接",不参与任务调度。
"""
serial = db.Column(db.String(120), primary_key=True) # 如 192.168.20.5:5555
source = db.Column(db.String(20), default="") # lan / tailscale
first_seen = db.Column(db.String(20), default="") # 首次发现时间
last_seen = db.Column(db.String(20), default="") # 最近一次扫描仍可见的时间
def to_dict(self):
return {"serial": self.serial, "source": self.source or "",
"first_seen": self.first_seen or "", "last_seen": self.last_seen or ""}
# 版本化 schema 迁移:新增结构变更时在此追加 (版本号, 说明, SQL) # 版本化 schema 迁移:新增结构变更时在此追加 (版本号, 说明, SQL)
# 版本号单调递增,只执行比当前 schema_version 新的迁移。 # 版本号单调递增,只执行比当前 schema_version 新的迁移。
SCHEMA_MIGRATIONS = [ SCHEMA_MIGRATIONS = [
(1, "用户权限位:user 表新增 perms 列(JSON 数组,默认空=无业务权限,管理员不受限)", (1, "用户权限位:user 表新增 perms 列(JSON 数组,默认空=无业务权限,管理员不受限)",
"ALTER TABLE user ADD COLUMN perms TEXT DEFAULT '[]'"), "ALTER TABLE user ADD COLUMN perms TEXT DEFAULT '[]'"),
(2, "设备池:device 表(本地设备清单,替代 STF 池)",
"CREATE TABLE IF NOT EXISTS device ("
"serial VARCHAR(120) PRIMARY KEY,"
"name VARCHAR(80) DEFAULT '',"
"enabled BOOLEAN DEFAULT 1,"
"note TEXT DEFAULT '',"
"created_at VARCHAR(20) DEFAULT '')"),
(3, "设备池:device 表新增 model 列(型号,在线时自动采集)",
"ALTER TABLE device ADD COLUMN model TEXT DEFAULT ''"),
(4, "自动发现:pending_device 待连接池表(扫描发现的设备,用户确认后才入正式池)",
"CREATE TABLE IF NOT EXISTS pending_device ("
"serial VARCHAR(120) PRIMARY KEY,"
"source VARCHAR(20) DEFAULT '',"
"first_seen VARCHAR(20) DEFAULT '',"
"last_seen VARCHAR(20) DEFAULT '')"),
] ]
@@ -253,7 +305,18 @@ def _migrate_schema():
if version <= current: if version <= current:
continue continue
if sql: if sql:
try:
db.session.execute(text(sql)) db.session.execute(text(sql))
except Exception as e:
# 幂等兜底:目标结构已存在也视为该迁移生效。典型场景——
# create_all 已按当前模型把列/表直接建好(如 perms、device.model),
# 而 schema_version 又因历史中断没记录,导致每次启动重复 ALTER 报错。
# duplicate column name 说明列已存在=迁移目标已达成:回滚本次语句后
# 仍记录版本号,一次启动即自愈;其它异常才中止本批迁移。
if "duplicate column name" not in str(e).lower():
raise
db.session.rollback()
_log.info(f"schema 迁移 {version} 目标已存在,跳过: {desc}")
db.session.execute( db.session.execute(
text("INSERT OR REPLACE INTO app_meta(key,value) VALUES('schema_version',:v)"), text("INSERT OR REPLACE INTO app_meta(key,value) VALUES('schema_version',:v)"),
{"v": str(version)}) {"v": str(version)})
+15 -4
View File
@@ -69,11 +69,22 @@ def recognize(image):
def find_on_screen(image, keyword): def find_on_screen(image, keyword):
"""在截图上查找包含 keyword 的文字。 """在截图上查找包含 keyword 的文字。
返回 (found, center_xy, matched_text);center_xy 为文字中心像素坐标(可点击), 返回 (found, center_xy, matched_text);center_xy 为**关键词**中心像素坐标
未命中返回 (False, None, "")。 (可点击),未命中返回 (False, None, "")。
OCR 一行常含多段文字(如 "医值得推荐#苏州济世璞真…展开"),若直接点整块
中心会偏离关键词很远。命中块文本较长时,按关键词在文本中的位置比例估算 x
(块内文字近似等宽),y 取块中心——中文场景估算偏差小,点击能落准。
""" """
for r in recognize(image): for r in recognize(image):
if keyword in r["text"]: text = r["text"]
if keyword in text:
b = r["box"] b = r["box"]
return True, ((b[0] + b[2]) // 2, (b[1] + b[3]) // 2), r["text"] if len(text) <= 12: # 短文本:整块中心即关键词中心
return True, ((b[0] + b[2]) // 2, (b[1] + b[3]) // 2), text
idx = text.find(keyword)
ratio = (idx + len(keyword) / 2) / len(text)
cx = b[0] + int((b[2] - b[0]) * ratio)
cy = (b[1] + b[3]) // 2
return True, (cx, cy), text
return False, None, "" return False, None, ""
+3
View File
@@ -10,6 +10,7 @@
失败抛 SSHError(连接失败/超时),由调用方转成友好错误返回。 失败抛 SSHError(连接失败/超时),由调用方转成友好错误返回。
""" """
import re import re
import socket
import subprocess import subprocess
import paramiko import paramiko
@@ -91,6 +92,8 @@ def _run_paramiko(cmd, timeout):
client.close() client.close()
except paramiko.AuthenticationException: except paramiko.AuthenticationException:
raise SSHError(f"SSH 密码认证失败(请检查 .env 的 STF_SSH_PASSWORD)") raise SSHError(f"SSH 密码认证失败(请检查 .env 的 STF_SSH_PASSWORD)")
except socket.timeout:
raise SSHError(f"SSH 执行超时({timeout}s 内未完成,命令可能耗时较长)")
except paramiko.SSHException as e: except paramiko.SSHException as e:
raise SSHError(f"SSH 连接失败: {e}") raise SSHError(f"SSH 连接失败: {e}")
except Exception as e: except Exception as e:
-228
View File
@@ -1,228 +0,0 @@
"""OpenSTF REST API 客户端封装。
负责:设备列表查询、占用、远程 ADB 隧道建立、释放。
不负责实际 UI 操作(那是 DeviceWorker 的事)。
健壮性设计:
- 所有请求带 timeout,避免 STF 网关 504 时长时间卡死
- remoteConnect 带重试,STF provider 偶尔慢启动
- 友好错误分类:设备离线 / 网络不通 / STF 内部错误,便于上层决策
- 占用冲突自动重试(STF 偶发 400 "device already in use")
"""
import time
import requests
from config import STF_URL, STF_TOKEN
from core.logger import get_logger
_log = get_logger("core.stf")
# 请求超时(秒)。connect 超时 + read 超时
_TIMEOUT = (5, 15)
# remoteConnect 重试次数(STF provider 慢启动时需要重试)
_RC_RETRIES = 3
# remoteConnect 重试间隔
_RC_DELAY = 2
class STFError(Exception):
"""STF 相关错误基类。"""
def __init__(self, message, code=""):
super().__init__(message)
self.code = code # 错误码:offline / network / conflict / server / unknown
class DeviceOfflineError(STFError):
"""设备离线/不可用。"""
class STFNetworkError(STFError):
"""STF 服务不可达。"""
class DeviceConflictError(STFError):
"""设备已被占用。"""
def _headers():
return {"Authorization": f"Bearer {STF_TOKEN}"}
class STFClient:
"""OpenSTF REST API 封装。"""
def __init__(self, base_url=STF_URL, token=STF_TOKEN):
self.base_url = base_url
self.headers = _headers()
# ================== 设备查询 ==================
def list_all_devices(self):
"""返回 STF 上所有设备(含状态)。"""
try:
resp = requests.get(f"{self.base_url}/api/v1/devices",
headers=self.headers, timeout=_TIMEOUT)
resp.raise_for_status()
return resp.json().get("devices", [])
except requests.exceptions.ConnectionError as e:
raise STFNetworkError(f"STF 服务不可达: {e}", "network") from e
except requests.exceptions.Timeout as e:
raise STFNetworkError(f"STF 请求超时: {e}", "network") from e
except requests.exceptions.RequestException as e:
_log.error(f"获取设备列表失败: {e}")
raise STFError(f"获取设备列表失败: {e}", "server") from e
def list_free_devices(self):
"""返回可占用的空闲设备。"""
return [d for d in self.list_all_devices()
if d.get("present") and d.get("ready")
and not d.get("using") and d.get("owner") is None]
def list_my_devices(self):
"""返回当前账户已占用的设备。"""
try:
resp = requests.get(f"{self.base_url}/api/v1/user/devices",
headers=self.headers, timeout=_TIMEOUT)
resp.raise_for_status()
return resp.json().get("devices", [])
except requests.exceptions.RequestException as e:
_log.error(f"获取已占用设备失败: {e}")
return []
# ================== 占用/释放 ==================
def occupy(self, serial, retries=2):
"""占用设备。返回 (ok, msg)。
冲突(已被占用)时自动重试,偶发 400 "already in use" 可能是脏状态。
"""
for attempt in range(1, retries + 1):
try:
r = requests.post(f"{self.base_url}/api/v1/user/devices/{serial}",
headers=self.headers, timeout=_TIMEOUT)
if r.status_code in (200, 201):
return True, r.text[:200]
if r.status_code == 400 and "already" in r.text.lower():
# 已被占用(可能是自己之前占用没释放干净),尝试先释放再占
_log.warning(f"{serial} 占用冲突,尝试清理后重试 ({attempt}/{retries})")
try:
requests.delete(f"{self.base_url}/api/v1/user/devices/{serial}",
headers=self.headers, timeout=_TIMEOUT)
except Exception:
pass
time.sleep(1)
continue
return False, f"HTTP {r.status_code}: {r.text[:200]}"
except requests.exceptions.RequestException as e:
_log.warning(f"{serial} 占用请求异常 ({attempt}/{retries}): {e}")
if attempt < retries:
time.sleep(1)
return False, str(e)
return False, "占用冲突重试耗尽"
def release(self, serial, retries=3):
"""释放设备占用。返回 (ok, msg)。
STF 释放可能因设备响应超时返回 504(如设备过载/卡死),此时设备实际未释放。
必须检查响应状态并重试,不能静默吞掉——否则占用会一直悬着。
"""
self.remote_disconnect(serial)
last_status = None
last_body = ""
for attempt in range(1, retries + 1):
try:
r = requests.delete(f"{self.base_url}/api/v1/user/devices/{serial}",
headers=self.headers, timeout=_TIMEOUT)
if r.status_code in (200, 201, 202, 204):
return True, "released"
last_status = r.status_code
last_body = r.text[:150]
_log.warning(f"释放 {serial} 失败 HTTP {r.status_code}: {last_body} ({attempt}/{retries})")
except requests.exceptions.RequestException as e:
_log.warning(f"释放 {serial} 请求异常 ({attempt}/{retries}): {e}")
last_status = -1
last_body = str(e)
if attempt < retries:
time.sleep(2)
return False, f"STF 释放失败(HTTP {last_status}): {last_body}"
def release_all_mine(self):
"""释放当前账户占用的所有设备(清理用)。返回 (released, failed)。"""
released, failed = [], []
for d in self.list_my_devices():
serial = d["serial"]
ok, msg = self.release(serial)
if ok:
released.append(serial)
_log.info(f"已释放 {serial}")
else:
failed.append(serial)
_log.error(f"释放 {serial} 失败: {msg}")
return released, failed
# ================== 远程 ADB 隧道 ==================
def remote_connect(self, serial):
"""建立远程 ADB 隧道,返回 remoteConnectUrl。
STF provider 慢启动或设备掉线时会 504,这里带重试。
设备真离线时快速失败,不长时间卡住 worker。
"""
last_err = None
for attempt in range(1, _RC_RETRIES + 1):
try:
conn = requests.post(
f"{self.base_url}/api/v1/user/devices/{serial}/remoteConnect",
headers=self.headers, timeout=_TIMEOUT,
)
# 504 = STF 网关等 provider 响应超时,通常是设备掉线或 provider 卡死
if conn.status_code == 504:
last_err = f"STF 网关超时(504),设备可能掉线"
_log.warning(f"{serial} remoteConnect 504 ({attempt}/{_RC_RETRIES})")
if attempt < _RC_RETRIES:
time.sleep(_RC_DELAY)
continue
raise DeviceOfflineError(
f"{serial} remoteConnect 超时,设备可能掉线或 provider 卡死",
"offline",
)
conn.raise_for_status()
data = conn.json()
if not data.get("success"):
raise STFError(f"远程连接失败: {data.get('description')}", "server")
url = (data.get("remoteConnectUrl") or data.get("remoteAdbUrl")
or data.get("remote_adb_url") or data.get("adbUrl")
or data.get("url"))
if not url:
raise STFError("STF 返回成功但无 remoteConnectUrl", "server")
return url
except DeviceOfflineError:
raise
except requests.exceptions.ConnectionError as e:
last_err = str(e)
_log.warning(f"{serial} remoteConnect 网络异常 ({attempt}/{_RC_RETRIES}): {e}")
if attempt < _RC_RETRIES:
time.sleep(_RC_DELAY)
except requests.exceptions.Timeout as e:
last_err = str(e)
_log.warning(f"{serial} remoteConnect 超时 ({attempt}/{_RC_RETRIES}): {e}")
if attempt < _RC_RETRIES:
time.sleep(_RC_DELAY)
except requests.exceptions.RequestException as e:
last_err = str(e)
_log.error(f"{serial} remoteConnect 请求异常: {e}")
raise STFError(f"远程连接请求失败: {e}", "server") from e
# 重试耗尽
raise DeviceOfflineError(
f"{serial} remoteConnect 重试 {_RC_RETRIES} 次失败: {last_err}",
"offline",
)
def remote_disconnect(self, serial):
"""断开远程 ADB 隧道。"""
try:
requests.post(
f"{self.base_url}/api/v1/user/devices/{serial}/remoteDisconnect",
headers=self.headers, timeout=_TIMEOUT,
)
except requests.exceptions.RequestException:
pass # 断开失败不影响主流程
-133
View File
@@ -1,133 +0,0 @@
"""OpenSTF 设备池管理(工具页"STF 设备管理"用)。
背景:220 上 STF 的 adb 跑在 Docker 容器里,设备池由
/mnt/data/openstf/connect_devices.sh 的 DEVICES 数组维护(cron 每 5 分钟补连)。
本模块把"改脚本 + 实时 adb connect/disconnect"封装成 API:
- status 已配置 IP 列表 + 220 adb 实际连接状态
- add_device 写入脚本 DEVICES + 立即 docker exec adb adb connect
- remove_device 从脚本 DEVICES 移除 + adb disconnect
脚本编辑用 220 上的 python3 做精确的行级增删(sed 处理多行数组容易误伤)。
注意:这里的 adb 是 220 上的(STF provider 的),与本机 Mac 直连的 adb 相互独立;
移除设备会断开 STF 池连接(设备从 OpenSTF 下线),不影响本机已有的任务 adb 连接。
"""
import re
import shlex
from config import STF_ADB_CONTAINER, STF_SCRIPT_PATH
from core.logger import get_logger
from core.ssh_client import run as _ssh_run, SSHError
_log = get_logger("core.stfdev")
class StfDevError(Exception):
"""STF 设备管理错误(SSH/脚本/命令失败)。"""
def _ssh(cmd, timeout=25):
"""在 220 上执行一条 shell 命令(认证见 core.ssh_client)。返回 (code, stdout, stderr)。"""
try:
return _ssh_run(cmd, timeout=timeout)
except SSHError as e:
raise StfDevError(str(e))
def _adb(args, remote_timeout=15):
"""在 220 的 adb 容器里执行 adb 命令。返回 (code, stdout, stderr)。
remote_timeout 是 220 侧的 timeout 秒数:adb connect 到不可达 IP 会长时间
挂起重试(实测 ≥40s),必须用 host 的 timeout 兜底,否则 API 请求会卡死。
"""
return _ssh(f"timeout {remote_timeout} docker exec {shlex.quote(STF_ADB_CONTAINER)} adb "
+ " ".join(shlex.quote(a) for a in args))
def _norm_ip(ip):
"""规范化 IP:去空白、去 :5555 后缀。"""
ip = str(ip or "").strip()
if ip.endswith(":5555"):
ip = ip[:-5]
return ip
# ================== 查询 ==================
def configured_ips():
"""解析脚本 DEVICES 数组里的 IP 列表。"""
code, out, err = _ssh(f"cat {shlex.quote(STF_SCRIPT_PATH)}")
if code != 0:
raise StfDevError(f"读取脚本失败: {err.strip()}")
m = re.search(r"DEVICES=\((.*?)\)", out, re.S)
if not m:
raise StfDevError("脚本中未找到 DEVICES 列表")
return re.findall(r'"([^"]+)"', m.group(1))
def connected_devices():
"""220 adb 实际连接状态 [{serial, state}]。"""
code, out, err = _adb(["devices"])
if code != 0:
raise StfDevError(f"adb devices 失败: {err.strip()}")
devs = []
for line in out.splitlines()[1:]:
parts = line.split()
if len(parts) >= 2 and parts[0] and not parts[0].startswith("*"):
devs.append({"serial": parts[0], "state": parts[1]})
return devs
def status():
"""综合状态:脚本配置的 IP + 实际连接。返回 {configured, connected}。"""
return {"configured": configured_ips(), "connected": connected_devices()}
# ================== 增删设备 ==================
def _edit_script(python_code):
"""用 220 的 python3 执行脚本编辑代码(代码内请用双引号)。"""
cmd = f"python3 -c {shlex.quote(python_code)}"
code, out, err = _ssh(cmd)
if code != 0:
raise StfDevError(f"脚本编辑失败: {err.strip() or out.strip()}")
def add_device(ip):
"""添加设备:写入脚本 DEVICES + 立即 connect。返回操作消息列表。"""
ip = _norm_ip(ip)
if not re.match(r"^\d{1,3}(\.\d{1,3}){3}$", ip):
raise StfDevError(f"IP 格式不正确: {ip}")
msgs = []
if ip in configured_ips():
msgs.append(f"{ip} 已在脚本配置中")
else:
# 在 DEVICES=( 行后插入一行 IP(python 精确行级插入)
_edit_script(
"p='" + STF_SCRIPT_PATH + "';s=open(p).read();"
"s=s.replace('DEVICES=(\\n','DEVICES=(\\n \\\"" + ip + "\\\"\\n',1);open(p,'w').write(s)")
msgs.append(f"已写入脚本 {STF_SCRIPT_PATH}")
code, out, err = _adb(["connect", f"{ip}:5555"])
if code == 0:
msgs.append(out.strip() or "adb connect 成功")
elif code == 124:
msgs.append("连接超时(设备当前不可达),已保留在脚本中,cron 会每 5 分钟自动重试")
else:
msgs.append(f"连接失败: {out.strip() or err.strip() or '未知错误'}")
_log.info(f"STF 添加设备 {ip}: {msgs}")
return msgs
def remove_device(ip):
"""移除设备:从脚本 DEVICES 删除 + adb disconnect。返回操作消息列表。"""
ip = _norm_ip(ip)
msgs = []
if ip in configured_ips():
# 删除 DEVICES 块内该 IP 的整行
_edit_script(
"p='" + STF_SCRIPT_PATH + "';s=open(p).read();"
"s=s.replace(' \\\"" + ip + "\\\"\\n','');open(p,'w').write(s)")
msgs.append("已从脚本移除")
else:
msgs.append(f"{ip} 不在脚本配置中")
code, out, err = _adb(["disconnect", f"{ip}:5555"])
msgs.append(out.strip() or (f"adb disconnect 执行完成(exit {code})" if code == 0 else "adb disconnect 无输出"))
_log.info(f"STF 移除设备 {ip}: {msgs}")
return msgs
+179 -138
View File
@@ -30,9 +30,10 @@ from apscheduler.triggers.cron import CronTrigger
from config import DATA_DIR from config import DATA_DIR
from core.logger import get_logger from core.logger import get_logger
from core.models import db, DeviceGroup as GroupRow, TaskJob as JobRow from core.models import db, DeviceGroup as GroupRow, TaskJob as JobRow
from .stf_client import STFClient, DeviceOfflineError from core import device_pool
from .adb_helper import get_foreground_app, adb_connect_light, adb_disconnect from .adb_helper import get_foreground_app, get_foreground_app_remote
from .device_worker import ( from .device_worker import (
DeviceOfflineError,
get_all_worker_status, _update_status, _remove_worker, get_all_worker_status, _update_status, _remove_worker,
_WORKERS, _WORKERS_LOCK, _WORKERS, _WORKERS_LOCK,
start_watchdog, stop_watchdog, start_watchdog, stop_watchdog,
@@ -41,8 +42,8 @@ from tasks import list_task_types, get_task_class
_log = get_logger("core.tm") _log = get_logger("core.tm")
# 错峰启动间隔(秒):批量触发时设备逐个开始占用/连接,避免 adb 全局锁串行堆积 # 错峰启动间隔(秒):批量触发时设备逐个开始连接,避免 adb 全局锁串行堆积。
# 和 STF occupy 并发风暴。100 台 × 0.2s = 20s 全部开始启动。 # 100 台 × 0.2s = 20s 全部开始启动。
_START_STAGGER_SEC = 0.2 _START_STAGGER_SEC = 0.2
@@ -143,18 +144,59 @@ class TaskJob:
d.get("params"), d.get("schedule"), d.get("retry"), d.get("enabled", True)) d.get("params"), d.get("schedule"), d.get("retry"), d.get("enabled", True))
def resolve_serials(self, manager): def resolve_serials(self, manager):
"""根据 target 解析出实际要跑的 serial 列表。""" """根据 target 解析出实际要跑的 serial 列表。
数据源:core.device_pool(本地清单 + adb 在线状态),不再查 STF。
"""
mode = self.target.get("mode", "all") mode = self.target.get("mode", "all")
if mode == "serial": if mode == "serial":
return [self.target["serial"]] serials = [self.target["serial"]]
if mode == "group": elif mode == "group":
g = manager.groups.get(self.target.get("group_name")) g = manager.groups.get(self.target.get("group_name"))
return list(g.serials) if g else [] serials = list(g.serials) if g else []
# all:返回所有空闲设备 # 分组应引用设备池内设备:不在池内的(如手动填的旧 IP)不参与调度,
# 与 all/preempt 模式口径一致(2026-09-01)
try: try:
return [d["serial"] for d in manager.stf.list_free_devices()] configured = set(device_pool.list_configured())
serials = [s for s in serials if s in configured]
except Exception:
pass
else:
# all:默认返回池内在线设备(单实例互斥由 _run_with_retry 的 _running 保证);
# 抢占模式返回全部在线设备(含运行中,执行时抢占)
if self.params.get("preempt"):
try:
# 抢占模式:只抢设备池内的在线设备——
# list_online() 会包含待连接/未入池设备(如自动发现验证时连上的),
# 它们不在设备池就不该被任务调度(2026-09-01 修复)
online = set(device_pool.list_online())
configured = set(device_pool.list_configured())
return [s for s in device_pool.list_online() if s in configured]
except Exception: except Exception:
return [] return []
try:
return device_pool.list_ready()
except Exception:
return []
# 离线自动跳过(serial/group 模式):跳过本机 adb 不可达的设备,
# 避免反复尝试连接后报"设备离线"。默认开启,可在任务编辑器取消勾选。
if serials and self.params.get("skip_offline", True):
try:
online = set(device_pool.list_online())
except Exception:
online = None # 查询失败时不过滤,维持原行为
if online is not None:
kept, skipped = [], []
for s in serials:
if s in online:
kept.append(s)
else:
skipped.append((s, "离线(未连接)"))
if skipped:
detail = ", ".join(f"{s}({why})" for s, why in skipped)
_log.info(f"任务 {self.name} 跳过 {len(skipped)} 台设备: {detail}")
serials = kept
return serials
# ================== 前台 App 扫描器 ================== # ================== 前台 App 扫描器 ==================
@@ -165,17 +207,16 @@ class _ForegroundScanner:
策略(按设备状态区分获取逻辑): 策略(按设备状态区分获取逻辑):
- worker 运行中设备:用已有 remote_adb_url 直接查询(已有 adb 连接,无额外开销) - worker 运行中设备:用已有 remote_adb_url 直接查询(已有 adb 连接,无额外开销)
- 空闲设备:直接 adb connect <serial> → 查询 → adb disconnect(绕过 STF,不打扰设备) - USB 运行中设备:经远程 adb server(220)查询
- 被别人占用的设备:标记 "他人占用" - 空闲设备:不主动连接(返回"空闲")——IP:5555 的 adb transport 与 220
- STF occupy/release 会打扰设备(可能退回桌面),绝不使用 共享,外部 connect/disconnect 会扰动共享连接
serial 格式为 IP:5555(设备本身的 adb 网络地址),可直接 adb connect。 serial 格式为 IP:5555(设备本身的 adb 网络地址),可直接 adb connect。
adb connect/disconnect 只建立/断开调试连接,不影响设备 UI。 adb connect/disconnect 只建立/断开调试连接,不影响设备 UI。
dumpsys window 只读取窗口状态,不执行任何操作。 dumpsys window 只读取窗口状态,不执行任何操作。
""" """
def __init__(self, stf): def __init__(self):
self.stf = stf
self._cache = {} # serial -> app_name self._cache = {} # serial -> app_name
self._cache_lock = threading.Lock() self._cache_lock = threading.Lock()
self._scanning = threading.Event() # 标记是否正在扫描 self._scanning = threading.Event() # 标记是否正在扫描
@@ -213,61 +254,44 @@ class _ForegroundScanner:
def _scan_all(self): def _scan_all(self):
"""扫描所有在线设备(后台线程执行)。 """扫描所有在线设备(后台线程执行)。
按设备归属分四类处理: 按设备归属分两类处理:
1. worker 运行中:用已有 remote_adb_url 直接查询(无额外开销) 1. worker 运行中:用已有 remote_adb_url 直接查询(无额外开销)
2. 自己账户占用但无 worker:调用 STF remoteConnect 获取隧道查询 2. 空闲设备:返回"空闲"——不主动连接。IP:5555 的 adb transport
(不 occupy/release,不打扰设备 UI) 与 STF provider 共享,外部 connect/disconnect 会让 STF 误判
3. 完全空闲设备(using=False):尝试轻量 adb connect serial 设备离线并触发重连(迁移期仍保留此约束,摘除 STF 后可放开)
(单次尝试,不 kill-server,不影响其他 worker)
4. 被他人占用:标记 "(他人占用)"
""" """
self._scanning.set() self._scanning.set()
_log.info("前台 App 扫描已启动") _log.info("前台 App 扫描已启动")
try: try:
all_devices = self.stf.list_all_devices() online = set(device_pool.list_online())
except Exception as e: except Exception as e:
_log.error("前台 App 扫描: 获取设备列表失败: %s", e) _log.error("前台 App 扫描: 获取在线设备失败: %s", e)
self._scanning.clear() self._scanning.clear()
return return
# 自己账户已占用的设备列表(用于区分"自己占用"vs"他人占用")
try:
my_serials = {d["serial"] for d in self.stf.list_my_devices()}
except Exception:
my_serials = set()
worker_status = {w["serial"]: w for w in get_all_worker_status()} worker_status = {w["serial"]: w for w in get_all_worker_status()}
# 分类设备 # 分类设备
have_conn = {} # serial -> remote_adb_url(worker 运行中,已有 adb 连接) have_conn = {} # serial -> remote_adb_url(IP:5555 运行中设备)
my_owned = [] # 自己账户占用但无 running worker,需 remoteConnect 获取隧道 usb_conn = {} # serial -> usb_server(USB 运行中设备,经远程 adb server)
free_serials = [] # 完全空闲设备,尝试轻量 adb connect free_serials = [] # 空闲设备
skip_serials = [] # 被他人占用
for dev in all_devices: for serial in online:
if not dev.get("present"):
continue
serial = dev.get("serial", "")
if not serial:
continue
w = worker_status.get(serial, {}) w = worker_status.get(serial, {})
url = w.get("remote_adb_url") if w.get("status") not in ("running", "connecting"):
if url and w.get("status") in ("running", "connecting"): free_serials.append(serial)
# 1. 设备正在执行任务,已有 adb 连接,直接查询 elif w.get("remote_adb_url"):
have_conn[serial] = url # 1. IP:5555 设备正在执行任务,已有 adb 连接,直接查询
elif serial in my_serials: have_conn[serial] = w["remote_adb_url"]
# 2. 自己账户占用但无 worker,可安全 remoteConnect(不打扰设备) elif w.get("usb_server"):
my_owned.append(serial) # 1b. USB 设备运行中:经远程 adb server 查询
elif dev.get("using"): usb_conn[serial] = w["usb_server"]
# 4. 被他人占用
skip_serials.append(serial)
else: else:
# 3. 完全空闲,尝试轻量 adb connect(不 kill-server)
free_serials.append(serial) free_serials.append(serial)
results = {} results = {}
_log.info("前台 App 扫描分类: 运行中=%d, 自己占用=%d, 空闲=%d, 他人占用=%d", _log.info("前台 App 扫描分类: 运行中=%d, USB运行中=%d, 空闲=%d",
len(have_conn), len(my_owned), len(free_serials), len(skip_serials)) len(have_conn), len(usb_conn), len(free_serials))
# 1. worker 运行中设备:用已有 remote_adb_url 查询(并发 10) # 1. worker 运行中设备:用已有 remote_adb_url 查询(并发 10)
if have_conn: if have_conn:
@@ -281,33 +305,21 @@ class _ForegroundScanner:
except Exception: except Exception:
results[s] = None results[s] = None
# 2. 自己占用设备:remoteConnect 获取隧道 → 查询 → 断开(并发 5,不打扰设备) # 1b. USB 运行中设备:经远程 adb server 查询(并发 5)
if my_owned: if usb_conn:
with ThreadPoolExecutor(max_workers=min(5, len(my_owned))) as pool: with ThreadPoolExecutor(max_workers=min(5, len(usb_conn))) as pool:
futures = {pool.submit(self._scan_my_owned, s): s futures = {pool.submit(self._scan_usb_conn, s): s
for s in my_owned} for s, server in usb_conn.items()}
for fut in as_completed(futures, timeout=30): for fut in as_completed(futures, timeout=15):
s = futures[fut] s = futures[fut]
try: try:
results[s] = fut.result() results[s] = fut.result()
except Exception: except Exception:
results[s] = None results[s] = None
# 3. 空闲设备:轻量 adb connect serial → 查询 → disconnect(并发 10) # 2. 空闲设备:不打扰,直接返回"空闲"
if free_serials: for s in free_serials:
with ThreadPoolExecutor(max_workers=min(10, len(free_serials))) as pool: results[s] = "空闲"
futures = {pool.submit(self._scan_free, s): s
for s in free_serials}
for fut in as_completed(futures, timeout=20):
s = futures[fut]
try:
results[s] = fut.result()
except Exception:
results[s] = None
# 4. 被别人占用的设备
for s in skip_serials:
results[s] = "(他人占用)"
# 更新缓存 # 更新缓存
with self._cache_lock: with self._cache_lock:
@@ -319,44 +331,29 @@ class _ForegroundScanner:
self._scanning.clear() self._scanning.clear()
_log.info("前台 App 扫描完成: %d 台设备", len(results)) _log.info("前台 App 扫描完成: %d 台设备", len(results))
def _scan_my_owned(self, serial): def _scan_usb_conn(self, serial):
"""自己账户占用的设备:通过 STF remoteConnect 获取隧道查询。 """USB 运行中设备:经远程 adb server(220)查询前台 App。"""
不调用 occupy/release,只建立/断开 ADB 隧道,不打扰设备 UI。
"""
try: try:
url = self.stf.remote_connect(serial) with _WORKERS_LOCK:
if not url: server = _WORKERS.get(serial, {}).get("usb_server", "")
if not server:
return None return None
try: host, _, port = server.partition(":")
if not adb_connect_light(url): return get_foreground_app_remote(host, int(port), serial)
return None
return get_foreground_app(url)
finally:
adb_disconnect(url)
self.stf.remote_disconnect(serial)
except Exception: except Exception:
return None return None
def _scan_free(self, serial):
"""空闲设备:不扫描前台 App,直接返回"空闲"。
原因:STF provider 内部通过 IP:5555 维持 adb 连接监控设备。
外部 adb connect/disconnect IP:5555 会让 adb server 断开该地址的
transport,连带 STF provider 的连接一起断,STF 误判设备 offline
并触发重连——表现就是"一扫描前台 App 设备就离线、需要重连"。
因此空闲设备绝不主动 adb connect/disconnect,只对运行中设备
(走 STF 隧道端口,不动 5555)和自占设备(remoteConnect 隧道)扫描。
"""
return "空闲"
# ================== 任务管理器 ================== # ================== 任务管理器 ==================
class TaskManager: class TaskManager:
def __init__(self, stf_client=None, app=None): def __init__(self, app=None):
self.stf = stf_client or STFClient()
self.app = app # Flask app,用于 db context self.app = app # Flask app,用于 db context
self.scheduler = BackgroundScheduler(timezone="Asia/Shanghai") self.scheduler = BackgroundScheduler(timezone="Asia/Shanghai")
if os.environ.get("DISABLE_SCHEDULER"):
# 测试/回归模式:不启动 cron 调度器,避免 test_client 加载任务后
# 按 cron 真的跑任务(干扰回归、占用设备)。add_job 仍可调用不报错。
_log.info("DISABLE_SCHEDULER=1:跳过 cron 调度器(测试/回归模式)")
else:
self.scheduler.start() self.scheduler.start()
start_watchdog() # 启动心跳看门狗 start_watchdog() # 启动心跳看门狗
self.groups = {} # name -> DeviceGroup(内存业务对象) self.groups = {} # name -> DeviceGroup(内存业务对象)
@@ -364,7 +361,7 @@ class TaskManager:
self._running = {} # serial -> {"worker", "job_id", "started_at", "attempt"} self._running = {} # serial -> {"worker", "job_id", "started_at", "attempt"}
self._stop_requested = set() # serial 集合:用户请求停止,阻止后续重试 self._stop_requested = set() # serial 集合:用户请求停止,阻止后续重试
self._lock = threading.Lock() self._lock = threading.Lock()
self._fg_scanner = _ForegroundScanner(self.stf) self._fg_scanner = _ForegroundScanner()
self._load() self._load()
def _db(self): def _db(self):
@@ -607,14 +604,10 @@ class TaskManager:
return _next_run_time(job.schedule) return _next_run_time(job.schedule)
def run_job_now(self, job_id): def run_job_now(self, job_id):
"""立即执行任务(手动触发)。运行窗口外拒绝启动。""" """立即执行任务(手动触发)。运行窗口只约束定时触发,手动执行不受限制。"""
job = self.jobs.get(job_id) job = self.jobs.get(job_id)
if not job: if not job:
return {"ok": False, "error": "任务不存在"} return {"ok": False, "error": "任务不存在"}
if not _in_run_window(job.schedule):
win = job.schedule.get("window") or {}
return {"ok": False,
"error": f"当前不在运行窗口内({win.get('start', '')}-{win.get('end', '')}),任务未启动"}
# 在独立线程跑,不阻塞调用方 # 在独立线程跑,不阻塞调用方
t = threading.Thread(target=self._run_job, args=(job,), daemon=True) t = threading.Thread(target=self._run_job, args=(job,), daemon=True)
t.start() t.start()
@@ -656,6 +649,9 @@ class TaskManager:
# 错峰启动:在各自线程内等待,分摊批量启动的连接/占用压力 # 错峰启动:在各自线程内等待,分摊批量启动的连接/占用压力
if start_delay > 0: if start_delay > 0:
time.sleep(start_delay) time.sleep(start_delay)
# 被抢占的原任务 id(抢占结束后归还)——必须在循环外:
# 重试时若重置为 None,finally 归还逻辑会丢失信息,被抢占任务永不恢复
preempted_job = None
try: try:
for attempt in range(1, max_attempts + 1): for attempt in range(1, max_attempts + 1):
# 用户已请求停止 → 不再启动新 attempt # 用户已请求停止 → 不再启动新 attempt
@@ -663,19 +659,46 @@ class TaskManager:
if serial in self._stop_requested: if serial in self._stop_requested:
_log.info(f"{serial} 用户已请求停止,取消重试 (job={job.name})") _log.info(f"{serial} 用户已请求停止,取消重试 (job={job.name})")
return return
# 同一 serial 同时只能一个 worker # 同一 serial 同时只能一个 worker;不同任务可配置抢占
preempt = False
with self._lock: with self._lock:
if serial in self._running: if serial in self._running:
cur = self._running[serial]
if cur.get("job_id") == job.id:
# 同一任务重复触发:跳过(原行为)
_log.warning(f"{serial} 已有任务在跑,跳过 (job={job.name})") _log.warning(f"{serial} 已有任务在跑,跳过 (job={job.name})")
return return
if not job.params.get("preempt"):
# 不同任务且本任务未开启抢占:跳过
_log.warning(f"{serial} 已有任务在跑,跳过 (job={job.name})")
return
preempt = True
if not preempted_job:
preempted_job = cur.get("job_id")
if preempt:
# 抢占:锁外停止该设备上的其他任务(stop_device 内部拿同一把锁,
# 在锁内调用会死锁!),等其释放后接管
_log.warning(f"{serial} 抢占:停止任务 {preempted_job} 后执行 {job.name}")
self.stop_device(serial)
deadline = time.time() + 30
while time.time() < deadline:
with self._lock:
if serial not in self._running:
break
time.sleep(0.5)
else:
_log.warning(f"{serial} 抢占超时(旧任务 30s 未退出),跳过 (job={job.name})")
return
with self._lock:
self._running[serial] = {"job_id": job.id, "started_at": time.time(), self._running[serial] = {"job_id": job.id, "started_at": time.time(),
"attempt": attempt, "task_type": job.task_type} "attempt": attempt, "task_type": job.task_type,
"preempted_job": preempted_job}
_update_status(serial, task_job=job.name, attempt=attempt, _update_status(serial, task_job=job.name, attempt=attempt,
max_attempts=max_attempts) max_attempts=max_attempts)
worker = None worker = None
try: try:
worker = task.create_worker(self.stf, serial, job.params) worker = task.create_worker(serial, job.params)
with self._lock: with self._lock:
self._running[serial]["worker"] = worker self._running[serial]["worker"] = worker
_log.info(f"{serial} 开始任务 {job.name} (第{attempt}/{max_attempts}次)") _log.info(f"{serial} 开始任务 {job.name} (第{attempt}/{max_attempts}次)")
@@ -736,6 +759,16 @@ class TaskManager:
# 清除停止标志:整个重试循环结束(成功/失败/停止)后允许下次任务 # 清除停止标志:整个重试循环结束(成功/失败/停止)后允许下次任务
with self._lock: with self._lock:
self._stop_requested.discard(serial) self._stop_requested.discard(serial)
# 归还:本任务是抢占任务,结束后自动重新启动被抢占的原任务
if preempted_job:
j = self.jobs.get(preempted_job)
if j and j.enabled:
_log.info(f"{serial} 抢占任务 {job.name} 结束,归还设备给任务 {j.name}")
threading.Thread(target=self._run_job, args=(j,), daemon=True).start()
else:
# 打日志便于排查:被抢占任务已删除/停用时不会归还,但要知道原因
_log.info(f"{serial} 抢占任务 {job.name} 结束,"
f"被抢占任务 {preempted_job} {'已停用,不归还' if j else '已不存在,不归还'}")
# ---- 运行控制 ---- # ---- 运行控制 ----
def stop_device(self, serial): def stop_device(self, serial):
@@ -772,15 +805,15 @@ class TaskManager:
with self._lock: with self._lock:
return {s: dict(v) for s, v in self._running.items()} return {s: dict(v) for s, v in self._running.items()}
# ---- 状态查询(带缓存,避免 STF 请求阻塞前端)---- # ---- 状态查询(带缓存,避免设备列表查询阻塞前端)----
_status_cache = None # (timestamp, data, error) _status_cache = None # (timestamp, data, error)
_status_cache_lock = threading.Lock() _status_cache_lock = threading.Lock()
_STATUS_CACHE_TTL = 5.0 # 缓存 5 秒,前端 5 秒刷新刚好命中 _STATUS_CACHE_TTL = 5.0 # 缓存 5 秒,前端 5 秒刷新刚好命中
def get_status(self): def get_status(self):
"""综合状态:STF 设备池 + 本地 worker + 运行中的任务。 """综合状态:设备池(清单+在线)+ 本地 worker + 运行中的任务。
带 5 秒缓存:STF 请求慢时避免每次 /api/status 都打 STF 阻塞 Flask。 带 5 秒缓存:避免每次 /api/status 都查库/adb 阻塞 Flask。
worker 状态实时读(内存,无 IO),不受缓存影响。 worker 状态实时读(内存,无 IO),不受缓存影响。
""" """
with self._status_cache_lock: with self._status_cache_lock:
@@ -791,46 +824,54 @@ class TaskManager:
return None, err return None, err
# 用缓存的设备列表 + 实时 worker 状态重新组装 # 用缓存的设备列表 + 实时 worker 状态重新组装
return self._merge_status(cached), None return self._merge_status(cached), None
# 缓存过期或不存在,重新拉 STF # 缓存过期或不存在,重新拉设备池
try: try:
all_devices = self.stf.list_all_devices() configured = device_pool.list_configured()
except Exception as e: except Exception as e:
with self._status_cache_lock: with self._status_cache_lock:
self._status_cache = (time.time(), None, str(e)) self._status_cache = (time.time(), None, str(e))
return None, f"获取设备列表失败: {e}" return None, f"获取设备列表失败: {e}"
with self._status_cache_lock: with self._status_cache_lock:
self._status_cache = (time.time(), all_devices, None) self._status_cache = (time.time(), configured, None)
return self._merge_status(all_devices), None return self._merge_status(configured), None
def _merge_status(self, all_devices): def _merge_status(self, configured_serials):
"""用 STF 设备列表 + 实时 worker 状态 + 前台 App 组装返回结果。""" """用设备池清单 + 实时 worker 状态 + 前台 App 组装返回结果。"""
worker_status = {w["serial"]: w for w in get_all_worker_status()} worker_status = {w["serial"]: w for w in get_all_worker_status()}
running = self.get_running() running = self.get_running()
try:
online = set(device_pool.list_online())
except Exception:
online = set()
try:
pool_rows = device_pool.list_devices()
names = {d["serial"]: d["name"] for d in pool_rows}
models = {d["serial"]: d["model"] for d in pool_rows}
except Exception:
names, models = {}, {}
# 清理陈旧状态:serial 已不在 STF 设备池、且没有在跑 worker 的条目, # 清理陈旧状态:serial 已不在设备池、且没有在跑 worker 的条目,
# 避免设备被删除后其失败记录仍残留在"异常汇总"里 # 避免设备被删除后其失败记录仍残留在"异常汇总"里
present = {dev.get("serial", "") for dev in all_devices} configured = set(configured_serials)
for serial, w in list(worker_status.items()): for serial, w in list(worker_status.items()):
if serial in present or serial in running: if serial in configured or serial in running:
continue continue
if w.get("status") in ("running", "connecting"): if w.get("status") in ("running", "connecting"):
continue continue
_remove_worker(serial) _remove_worker(serial)
result = [] result = []
for dev in all_devices: for serial in configured_serials:
serial = dev.get("serial", "") is_online = serial in online
owner = dev.get("owner")
w = worker_status.get(serial, {}) w = worker_status.get(serial, {})
r = running.get(serial, {}) r = running.get(serial, {})
result.append({ result.append({
"serial": serial, "serial": serial,
"model": dev.get("model") or w.get("model", "") or dev.get("product", ""), "model": w.get("model") or models.get(serial, ""),
"device_name": dev.get("name") or "", "device_name": names.get(serial, ""),
"present": dev.get("present", False), "present": is_online,
"ready": dev.get("ready", False), "ready": is_online, # 阶段 1:ready 概念并入在线状态
"stf_occupied": dev.get("using", False), "owner": "",
"owner": owner.get("name", "") if owner else "",
"worker_status": w.get("status", "idle"), "worker_status": w.get("status", "idle"),
"foreground_app": self._fg_scanner.get(serial), "foreground_app": self._fg_scanner.get(serial),
# 通用进度字段(任意 app 通用,前端统一解析展示) # 通用进度字段(任意 app 通用,前端统一解析展示)
@@ -847,17 +888,17 @@ class TaskManager:
return result return result
def list_all_serials(self): def list_all_serials(self):
"""返回 STF 上所有在线设备的 serial 列表(供分组表单勾选用)。 """返回设备池在线设备的 serial 列表(供分组表单勾选用)。"""
try:
复用 get_status 缓存,避免开页面时阻塞。 return device_pool.list_ready()
""" except Exception:
devices, err = self.get_status()
if err or not devices:
return [] return []
return [d["serial"] for d in devices if d.get("present")]
def shutdown(self): def shutdown(self):
self.stop_all() self.stop_all()
self._fg_scanner.stop() self._fg_scanner.stop()
stop_watchdog() stop_watchdog()
try:
self.scheduler.shutdown(wait=False) self.scheduler.shutdown(wait=False)
except Exception:
pass # DISABLE_SCHEDULER 模式下 scheduler 未 start,shutdown 会抛异常
+13 -1
View File
@@ -190,9 +190,14 @@ def _extract(root, out):
desc = props.get("content-desc", "") desc = props.get("content-desc", "")
cls = props.get("class", "") cls = props.get("class", "")
tag = cls.split(".")[-1] if cls else "*" tag = cls.split(".")[-1] if cls else "*"
# 始终带同 class 兄弟序号(含 [1]),结构路径才精确无歧义 # 始终带同 class 兄弟序号(含 [1]),结构路径才精确无歧义;
# class 为空的层(tag=*)不参与结构路径,避免 //*[N] 前缀污染导致选择器定位到任意节点
if tag != "*":
seg = f"{tag}[{tag_index}]" seg = f"{tag}[{tag_index}]"
own_tag_path = f"{tag_path}/{seg}" if tag_path else seg own_tag_path = f"{tag_path}/{seg}" if tag_path else seg
else:
seg = ""
own_tag_path = tag_path
# ---- 推荐选择器:唯一属性 > 锚点祖先限定 > 全结构路径 ---- # ---- 推荐选择器:唯一属性 > 锚点祖先限定 > 全结构路径 ----
if rid: if rid:
@@ -205,6 +210,13 @@ def _extract(root, out):
val = f"{ctx}/{seg}" val = f"{ctx}/{seg}"
suggested = {"type": "xpath", "value": val} suggested = {"type": "xpath", "value": val}
child_ctx = val child_ctx = val
else:
if tag == "*":
# class 也为空的元素:结构路径 //*[N] 无意义(匹配到文档任意节点),
# 标记 invalid,前端提示不可选,避免回填垃圾选择器导致"点击不到"
suggested = {"type": "xpath", "value": "",
"invalid": True,
"reason": "该元素无可用属性(id/文本/class),无法生成可靠选择器"}
else: else:
# 无唯一属性且无锚点祖先:用从根开始的结构路径(脆弱,标记 broad 让前端提示) # 无唯一属性且无锚点祖先:用从根开始的结构路径(脆弱,标记 broad 让前端提示)
suggested = {"type": "xpath", "value": f"//{own_tag_path}", "broad": True} suggested = {"type": "xpath", "value": f"//{own_tag_path}", "broad": True}
BIN
View File
Binary file not shown.
+86 -48
View File
@@ -343,7 +343,7 @@
(权限:设备控制) (权限:设备控制)
释放当前账户占用的所有 STF 设备(清理用)。 (已随 STF 摘除移除,此接口不再存在;设备互斥由调度器内存锁保证)
**响应**: **响应**:
```json ```json
@@ -557,7 +557,7 @@
### GET /api/apks/install/devices ### GET /api/apks/install/devices
可安装设备列表:STF 在线设备池 + 本机 adb 设备(**含 USB 有线连接**,serial 无冒号标记 `usb`)。 可安装设备列表:设备池在线设备(标记 `pool`)+ 本机 adb 设备(**含 USB 有线连接**,serial 无冒号标记 `usb`)。
安装弹窗用此列表,USB 设备安装时跳过 adb connect 直接安装。 安装弹窗用此列表,USB 设备安装时跳过 adb connect 直接安装。
**响应**: **响应**:
@@ -598,26 +598,94 @@
## 13. 维护 / 工具(仅管理员) ## 13. 维护 / 工具(仅管理员)
以下接口都**仅管理员可用**(普通用户即使有业务权限也访问不了,返回 403)。 以下接口都**仅管理员可用**(普通用户即使有业务权限也访问不了,返回 403)。
维护页现仅含 STF 服务管理;adb 终端、Tailscale 管理、应用管理、STF 设备管理等 设备池管理、远程看屏、adb 终端等集中在"工具"页(页内子分栏)。
已集中到"工具"页(页内子分栏),API 不变。
### POST /api/stf/restart ### GET /api/devices/pool
一键重启 STF Docker 容器。通过 SSH 到部署机执行 `docker restart`,随后轮询 STF API 确认恢复。 设备池清单(SQLite devices 表),含实时在线状态。**权限**:`devices`。
- 目标与容器名:`STF_SSH_TARGET`(默认 `[email protected]`)、`STF_DOCKER_CONTAINER`(默认 `stf`)环境变量配置
- 前置条件:本机可免密 SSH 到部署机
- **有任务在运行时返回 409 拒绝执行**(STF 重启会重置设备占用状态)
- 失败返回 502(SSH 不可达/容器名错误);执行超时返回 408
**响应**: **响应**:
```json ```json
{"ok": true, "msg": "STF 容器已重启", "detail": "stf: Up 3 seconds", "api_alive": true} {"ok": true, "devices": [
{"serial": "100.100.10.20:5555", "name": "", "model": "22120RN86C",
"enabled": true, "online": true, "note": "", "created_at": "2026-08-17 11:10"}
]}
``` ```
### POST /api/devices/pool/add
添加/更新设备(upsert)。**权限**:`devices`。
**请求**(JSON):`{"serial": "100.100.10.20:5555", "name": "备注", "note": ""}`
IP:5555 设备添加后立即尝试 adb connect,并后台采集型号(getprop ro.product.model)。
### POST /api/devices/pool/toggle
启用/停用设备(停用后不参与任务调度)。**权限**:`devices`。
**请求**(JSON):`{"serial": "...", "enabled": false}`
### POST /api/devices/pool/remove
从设备池删除(不再参与调度,不影响设备本身)。**权限**:`devices`。
**请求**(JSON):`{"serial": "..."}`
### POST /api/devices/pool/reconnect
一键重连:并发 adb connect 池内全部 IP:5555 设备(后台执行),完成后自动批量刷新型号。**权限**:`devices`。
### POST /api/devices/pool/refresh_models
批量采集池内在线设备的型号(后台执行)。**权限**:`devices`。
### GET /api/screen/stream
远程看屏:MJPEG 实时画面流(`multipart/x-mixed-replace`)。**权限**:`devices`。
`?serial=xxx` 指定设备;浏览器 `<img src>` 直接渲染,客户端断开自动停止。数据源 u2(atx-agent minicap,约 3-6 帧/秒)。
### GET /api/screen/thumb
大屏缩略图:单张 JPEG(360px 宽,质量 55)。**权限**:`devices`。
`?serial=xxx` 指定设备。监控大屏按设备每 ~2.5s 轮询一帧(页面不可见时暂停);
一次性请求(非流),与任务并发安全(与任务截图同走 u2 minicap)。
### GET /wall
监控大屏页面(全屏深色控制室风格,登录后可访问):设备卡片网格(缩略图/型号/
状态/当前动作/进度)、顶部统计与时钟;状态每 5s 刷新、缩略图每 2.5s 轮询。
20 台设备整体开销约 0.2 核 CPU + 100KB/s 带宽,普通电脑无压力。
### POST /api/screen/tap
点击设备屏幕。**权限**:`devices`。
**请求**(JSON):`{"serial": "...", "x": 360, "y": 800}`(设备原生分辨率坐标)
### POST /api/screen/swipe
滑动。**请求**(JSON):`{"serial": "...", "x1": 360, "y1": 1200, "x2": 360, "y2": 600, "duration": 0.2}`
### POST /api/screen/key
按键。**请求**(JSON):`{"serial": "...", "key": "back"}`。
支持:back/home/recent/menu/power/volume_up/volume_down/enter/delete/search/camera
### POST /api/screen/text
输入文字(需焦点在输入框)。**请求**(JSON):`{"serial": "...", "text": "你好"}`
### POST /api/device/screen_all
批量亮屏/息屏(并发)。**权限**:`devices`。
**请求**(JSON):
```json
{"mode": "on", "serials": ["100.100.10.11:5555"]}
```
`serials` 可选:指定设备(离线自动过滤);不带则作用于全部在线设备。息屏会中断运行中的任务,前端有确认提示。
### GET /api/adb/devices ### GET /api/adb/devices
维护终端设备列表:本地 adb 已连接(含 offline)+ STF 在线设备池(标记 `stf`)。 维护终端设备列表:本地 adb 已连接(含 offline)+ 设备池已配置设备(标记 `pool`)。
供终端设备选择器使用——选中后前端自动附加 `-s <serial>`。 供终端设备选择器使用——选中后前端自动附加 `-s <serial>`。
**响应**: **响应**:
@@ -639,7 +707,7 @@ adb 远程终端:用平台 adb 二进制执行任意 adb 命令(20s 超时
``` ```
(开头的 `adb` 前缀可省略) (开头的 `adb` 前缀可省略)
**安全红线**:包含 `kill-server` / `disconnect` 的命令直接拒绝(400)——会断开 STF provider 共享的 adb transport,导致全部设备被 STF 误判离线。 **安全红线**:包含 `kill-server` / `disconnect` 的命令直接拒绝(400)——会断开共享的 adb transport,导致全部设备连接重建。
**响应**: **响应**:
```json ```json
@@ -653,7 +721,7 @@ adb 远程终端:用平台 adb 二进制执行任意 adb 命令(20s 超时
### POST /api/steps/test ### POST /api/steps/test
在指定设备上单步试执行(步骤编辑器"测试此步骤"按钮),验证选择器是否命中。 在指定设备上单步试执行(步骤编辑器"测试此步骤"按钮),验证选择器是否命中。
只读连接(adb connect + u2),不占用/释放 STF,与运行中任务互不干扰。 只读连接(adb connect + u2),与运行中任务互不干扰。
**请求**(JSON): **请求**(JSON):
```json ```json
@@ -721,7 +789,7 @@ adb 远程终端:用平台 adb 二进制执行任意 adb 命令(20s 超时
{"ipv4": "100.100.10.16"} {"ipv4": "100.100.10.16"}
``` ```
⚠ 改 IP 会断开设备当前 tailscale 会话;平台设备池 serial 随之变化,需同步更新 STF 设备池/分组/任务目标。 ⚠ 改 IP 会断开设备当前 tailscale 会话;平台设备池 serial 随之变化,需同步更新设备池/分组/任务目标。
### DELETE /api/tailscale/devices/:device_id ### DELETE /api/tailscale/devices/:device_id
@@ -746,7 +814,7 @@ adb 远程终端:用平台 adb 二进制执行任意 adb 命令(20s 超时
## 16. 工具(仅管理员) ## 16. 工具(仅管理员)
工具页(剪贴板注入 / 应用版本管理 / STF 设备管理)接口,均仅管理员可用。 工具页(剪贴板注入 / 应用版本管理 / 设备池管理)接口,均仅管理员可用。
### POST /api/tools/clipboard/set ### POST /api/tools/clipboard/set
@@ -756,7 +824,7 @@ adb 远程终端:用平台 adb 二进制执行任意 adb 命令(20s 超时
```json ```json
{"serials": ["100.100.10.11:5555", "0123456789ABCDEF"], "text": "要注入的文字"} {"serials": ["100.100.10.11:5555", "0123456789ABCDEF"], "text": "要注入的文字"}
``` ```
设备来源与维护终端一致(本地 adb 含 USB + STF 池)。 设备来源与维护终端一致(本地 adb 含 USB + 设备池)。
实现:u2 `jsonrpc.setClipboard`(实测 `cmd clipboard` 在 MIUI 上不存在),支持中文/引号/换行; 实现:u2 `jsonrpc.setClipboard`(实测 `cmd clipboard` 在 MIUI 上不存在),支持中文/引号/换行;
IP 设备先 adb connect(已连接跳过,绝不 disconnect),USB 设备首次自动推送 atx-agent。 IP 设备先 adb connect(已连接跳过,绝不 disconnect),USB 设备首次自动推送 atx-agent。
@@ -781,33 +849,3 @@ IP 设备先 adb connect(已连接跳过,绝不 disconnect),USB 设备
"results": {"100.100.10.11:5555": {"installed": true, "version_name": "28.5.0", "version_code": "280500"}}} "results": {"100.100.10.11:5555": {"installed": true, "version_name": "28.5.0", "version_code": "280500"}}}
``` ```
未安装返回 `installed: false`;查询失败的设备带 `error` 字段。 未安装返回 `installed: false`;查询失败的设备带 `error` 字段。
### GET /api/stf/devmgmt/status
STF 设备管理状态:connect_devices.sh 脚本配置的 IP 列表 + 220 上 adb 实际连接状态。
**响应**:
```json
{"ok": true,
"configured": ["100.100.10.10", "100.100.10.11"],
"connected": [{"serial": "100.100.10.10:5555", "state": "device"}]}
```
### POST /api/stf/devmgmt/add
添加设备:写入 220 上 `connect_devices.sh` 的 DEVICES 列表 + 立即 `docker exec adb adb connect`。
设备不可达时 connect 会 15 秒超时兜底(220 侧 `timeout`),但仍保留在脚本中,cron 每 5 分钟自动重试。
**请求**(JSON):`{"ip": "100.100.10.20"}`(可带 `:5555`,自动归一化)
**响应**:
```json
{"ok": true, "msgs": ["已写入脚本 /mnt/data/openstf/connect_devices.sh", "连接超时(设备当前不可达),已保留在脚本中,cron 会每 5 分钟自动重试"]}
```
### POST /api/stf/devmgmt/remove
移除设备:从脚本 DEVICES 删除 + `adb disconnect`(设备从 STF 池下线,任务不再分配)。
注意:断开的是 220(STF provider 侧)的 adb 连接,与本机任务直连的 adb 相互独立。
**请求**(JSON):`{"ip": "100.100.10.20"}`
+46 -55
View File
@@ -10,8 +10,8 @@
| 层 | 路径 | 职责 | | 层 | 路径 | 职责 |
|----|------|------| |----|------|------|
| 配置层 | `config.py` | 项目根配置:STF 地址、adb 路径、web 端口等基础设施。**不放任务参数** | | 配置层 | `config.py` | 项目根配置:adb 路径、web 端口、USB 远程 adb server 等基础设施。**不放任务参数** |
| 核心层 | `core/` | 框架运行时:日志、STF 客户端、adb 操作、Worker 基类、任务管理器、u2 辅助、Action 基类 | | 核心层 | `core/` | 框架运行时:日志、设备池、adb 操作、Worker 基类、任务管理器、u2 辅助、Action 基类 |
| 任务层 | `tasks/` | 每个 App 一个子包,自包含 `task.py` + `actions/`,互不依赖 | | 任务层 | `tasks/` | 每个 App 一个子包,自包含 `task.py` + `actions/`,互不依赖 |
| 前端层 | `templates/admin/` | 单页应用(纯 HTML+CSS+JS,无框架) | | 前端层 | `templates/admin/` | 单页应用(纯 HTML+CSS+JS,无框架) |
| 数据层 | `data/` | SQLite 持久化 | | 数据层 | `data/` | SQLite 持久化 |
@@ -37,15 +37,15 @@
└──────────────┘ │ 状态/心跳 │ u2 操作 └──────────────┘ │ 状态/心跳 │ u2 操作
│ │ ▼ │ │ ▼
│ JSON API │ ┌─────────────────┐ │ JSON API │ ┌─────────────────┐
▼ ┌──────────────┐ │ STF Device / adb │ ▼ ┌──────────────┐ │ 设备 adb │
┌──────────────┐ │ 看门狗监控 │ └─────────────────┘ ┌──────────────┐ │ 看门狗监控 │ │ (IP:5555 直连 │
│ web_server │ └──────────────┘ │ web/ 蓝图包 │ └──────────────┘ │ / USB 远程) │
│ (Flask API) │ │ (Flask API) │ └─────────────────┘
└──────────────┘ └──────────────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ data/users.db│ SQLite 持久化(用户/分组/任务/自定义动作/APK记录) │ data/users.db│ SQLite 持久化(用户/分组/任务/设备池/自定义动作/APK记录)
└──────────────┘ └──────────────┘
``` ```
@@ -64,34 +64,25 @@
## 3. 核心模块详解 ## 3. 核心模块详解
### 3.1 STFClient(`core/stf_client.py`) ### 3.1 DevicePool(`core/device_pool.py`)
OpenSTF REST API 封装,负责设备池管理。 设备池(已摘除 OpenSTF):SQLite `devices` 表 = 设备清单,本机 adb = 在线状态。
**关键设计**: **关键设计**:
- 所有请求带 `timeout=(5, 15)`,避免 STF 网关 504 时卡死 - `list_configured()` — 清单(管理页维护,enabled=False 不参与调度)
- `remote_connect` 带重试(3 次),STF provider 慢启动时需要 - `list_online()` — 本机 adb 在线设备;池内有 USB 设备(serial 无冒号)时合并 220
- 错误分类:`DeviceOfflineError`(不重试)/ `STFNetworkError` / `DeviceConflictError`(自动重试) 远程 adb server(`USB_ADB_HOST:PORT`,host 网络模式 5037)状态
- 占用冲突自动清理重试(偶发 "already in use" 脏状态) - `list_ready()` — 清单 ∩ 在线(任务调度用)
- CRUD — `add_device`(upsert)/ `remove_device` / `set_enabled`
**核心方法**: - 单实例互斥由 `TaskManager._running[serial]` 内存锁保证(无跨实例占用概念)
- 全模块不 connect/kill-server/disconnect(遵守共享 adb transport 红线)
| 方法 | 说明 |
|------|------|
| `list_all_devices()` | 返回 STF 上所有设备(含状态) |
| `list_free_devices()` | 返回可占用的空闲设备 |
| `list_my_devices()` | 返回当前账户已占用的设备 |
| `occupy(serial)` | 占用设备(冲突自动重试) |
| `release(serial)` | 释放设备(含远程断开) |
| `remote_connect(serial)` | 建立远程 ADB 隧道,返回 remoteConnectUrl |
| `remote_disconnect(serial)` | 断开远程 ADB 隧道 |
### 3.2 STFDevice + BaseWorker(`core/device_worker.py`) ### 3.2 STFDevice + BaseWorker(`core/device_worker.py`)
**STFDevice** — 单设备生命周期管理: **STFDevice** — 单设备生命周期管理:
- `acquire()`:占用 → remoteConnect → adb connect - `acquire()`:IP:5555 直连 adb connect;USB(serial 无冒号)校验 220 远程 adb server 可见性
- `release()`:adb disconnect → remoteDisconnect → STF release - `release()`:无操作(不 disconnect,红线)
- 直连模式:serial 为 `IP:5555` 时直接 adb connect,不走 STF 桥接 - 互斥由 TaskManager `_running` 保证,设备池负责在线判断
**BaseWorker** — 通用 Worker 基类(继承 threading.Thread): **BaseWorker** — 通用 Worker 基类(继承 threading.Thread):
@@ -146,7 +137,7 @@ run() 主循环(不要重写):
**并发控制**:同一 serial 同时只允许一个 worker,避免冲突。 **并发控制**:同一 serial 同时只允许一个 worker,避免冲突。
**状态缓存**:`get_status()` 带 5 秒缓存,STF 请求慢时不阻塞前端。 **状态缓存**:`get_status()` 带 5 秒缓存,避免每次 /api/status 都查库/adb 阻塞前端。
### 3.4 前台 App 扫描器(`_ForegroundScanner`) ### 3.4 前台 App 扫描器(`_ForegroundScanner`)
@@ -154,12 +145,11 @@ run() 主循环(不要重写):
| 设备状态 | 处理方式 | 是否打扰 | | 设备状态 | 处理方式 | 是否打扰 |
|---------|---------|---------| |---------|---------|---------|
| worker 运行中 | 复用已有 ADB 连接查询 | 否 | | worker 运行中(IP:5555) | 复用已有 ADB 连接查询 | 否 |
| 自己占用无 worker | STF remoteConnect 隧道查询 | 否 | | worker 运行中(USB) | 经 220 远程 adb server 查询 | 否 |
| 完全空闲 | 返回"空闲"(不 adb connect,避免 STF 误判离线) | 否 | | 完全空闲 | 返回"空闲"(不主动 connect) | 否 |
| 他人占用 | 标记"(他人占用)" | 否 |
> **为什么不扫描空闲设备的前台 App**:STF provider 内部通过 IP:5555 维持 adb 连接。外部 adb connect/disconnect 会让 adb server 断开该 transport,连带 STF provider 的连接断开,STF 误判设备 offline 并触发重连。 > **为什么不扫描空闲设备的前台 App**:IP:5555 的 adb transport 是共享的(历史与 STF provider 共用),外部 connect/disconnect 会扰动共享连接,遵守既有红线。
### 3.5 ADB 操作(`core/adb_helper.py`) ### 3.5 ADB 操作(`core/adb_helper.py`)
@@ -167,8 +157,8 @@ run() 主循环(不要重写):
**铁律:绝不 kill-server**: **铁律:绝不 kill-server**:
- `adb kill-server` 会断开所有设备的 adb transport - `adb kill-server` 会断开所有设备的 adb transport
- 导致 STF provider 对全部设备误判离线并触发重连 - 全部设备连接被重建,影响所有运行中的任务
- 影响所有运行中的任务 - 同理绝不 disconnect IP:5555(共享 transport 红线,见 DEVELOPMENT.md)
| 函数 | 说明 | | 函数 | 说明 |
|------|------| |------|------|
@@ -200,9 +190,9 @@ SQLAlchemy 模型,存于 `data/users.db`:
### 权限模型 ### 权限模型
- `User.perms` 存业务权限位 JSON 数组(`tasks`/`devices`/`apks`/`logs`),`is_admin=true` 拥有全部权限(`has_perm` 短路) - `User.perms` 存业务权限位 JSON 数组(`tasks`/`devices`/`apks`/`logs`),`is_admin=true` 拥有全部权限(`has_perm` 短路)
- 后端统一用 `@perm_required(PERM_X)` / `@admin_required` 装饰器拦截(web_server.py),无权限返回 403; - 后端统一用 `@perm_required(PERM_X)` / `@admin_required` 装饰器拦截(web/auth.py),无权限返回 403;
查看类 GET 接口只要求登录;用户管理、维护接口(STF 重启 `/api/stf/restart`、adb 终端 `/api/adb/cmd`)强制 `admin_required` 查看类 GET 接口只要求登录;用户管理、adb 终端(`/api/adb/cmd`)强制 `admin_required`
- adb 终端安全红线:拒绝 `kill-server` / `disconnect`(STF provider 共享 adb transport,断开会误判全设备离线) - adb 终端安全红线:拒绝 `kill-server` / `disconnect`(共享 adb transport)
- 前端 `loadMe()` 拉取 `/api/me`,用 `data-perm` 属性隐藏无权限的 tab/按钮,行内按钮用 `_can(perm)` 判断 - 前端 `loadMe()` 拉取 `/api/me`,用 `data-perm` 属性隐藏无权限的 tab/按钮,行内按钮用 `_can(perm)` 判断
- 安全兜底:**前端隐藏只是 UX,权限强制在后端**;新增路由时按"写操作必须带权限装饰器"的约定 - 安全兜底:**前端隐藏只是 UX,权限强制在后端**;新增路由时按"写操作必须带权限装饰器"的约定
@@ -210,10 +200,10 @@ SQLAlchemy 模型,存于 `data/users.db`:
APK 上传/解析/批量安装。 APK 上传/解析/批量安装。
**设备连接策略(直连,绕过 STF)**: **设备连接策略(直连)**:
- 直接 `adb connect serial`(serial 是 IP:5555) - 直接 `adb connect serial`(serial 是 IP:5555)
- 不经过 STF occupy/release,避免 STF release 触发 agent 清理卸载 app - 不经过占用/释放(单实例互斥由调度器内存锁保证)
- 安装后不主动 disconnect(STF provider 共享该 adb transport) - 安装后不主动 disconnect(共享 adb transport 红线)
**安装流程**: **安装流程**:
1. 上传 APK → 保存到 `data/apks/` → pyaxmlparser 解析包名/版本 → 入库 1. 上传 APK → 保存到 `data/apks/` → pyaxmlparser 解析包名/版本 → 入库
@@ -241,23 +231,24 @@ APK 上传/解析/批量安装。
- 引擎懒加载单例 + 并发加锁(识别约 0.2-0.5s/次) - 引擎懒加载单例 + 并发加锁(识别约 0.2-0.5s/次)
- 返回坐标约定:像素、原点左上 - 返回坐标约定:像素、原点左上
### 3.10 STF 设备管理(`core/stf_device_mgmt.py`) ### 3.10 Web 层蓝图包(`web/`)
工具页"STF 设备管理":维护 220 上 OpenSTF 设备池。 路由按功能域拆分(模块化开发底线,便于定位问题):
背景:220 的 adb 跑在 Docker 容器(`STF_ADB_CONTAINER`,默认 `adb`)里,设备池由 | 模块 | 职责 |
`/mnt/data/openstf/connect_devices.sh`(`STF_SCRIPT_PATH`)的 `DEVICES` 数组维护,cron 每 5 分钟补连。
| 函数 | 说明 |
|------|------| |------|------|
| `status()` | 脚本配置 IP + 220 adb 实际连接状态 | | `auth.py` | 登录/登出/CSRF/权限装饰器/页面路由(/、/wall) |
| `add_device(ip)` | 用 220 的 python3 精确改脚本 DEVICES 块 + `docker exec adb adb connect` | | `monitor.py` | 状态/运行控制/设备操作/远程看屏(流+缩略图+触控) |
| `remove_device(ip)` | 脚本删条目 + `adb disconnect` | | `tasks_api.py` | 任务计划/分组/自定义动作/元素抓取/步骤测试 |
| `admin_api.py` | 用户管理/日志 |
| `tools_api.py` | adb 终端/剪贴板注入/应用版本 |
| `devices_api.py` | 设备池管理(增删停用/重连/型号采集) |
| `apks_api.py` | 应用管理 |
| `tailscale_api.py` | Tailscale 管理 |
| `common.py` | 跨模块共享(合并设备列表/屏幕状态) |
| `context.py` | 共享对象注入(mgr/apk_mgr/device_pool) |
关键点: `web_server.py` 只保留:app 创建、数据库初始化、蓝图注册、uiautodev 生命周期、启动(193 行)。
- 脚本编辑用 python3 行级增删(sed 处理多行数组易误伤)
- `adb connect` 到不可达 IP 会挂 40s+,220 侧用 `timeout 15` 兜底,超时保留在脚本由 cron 重试
- 断开的是 220(STF provider 侧)的 adb 连接,与本机任务直连的 adb 相互独立
--- ---
+17 -37
View File
@@ -17,15 +17,14 @@ python --version # 应输出 3.10+
pip --version pip --version
``` ```
### 1.2 OpenSTF 服务 ### 1.2 设备池(已摘除 OpenSTF 依赖)
本项目依赖 OpenSTF 管理设备池。如已有 STF 服务,跳过本节。 设备池由平台自身管理(SQLite `devices` 表 + 本机 adb 在线状态),**不再依赖 OpenSTF**。
新增设备在管理后台「工具 → 设备池管理」添加(serial 形如 `100.100.10.x:5555`)。
STF 部署参考官方文档:https://github.com/DeviceFarmer/stf USB 设备(serial 无冒号)插在部署机(220)时,经 220 的 adb server 驱动:
- 220 的 adb 容器需为 **host 网络模式**(5037 已监听所有网卡,含 Tailscale)
**需要获取的信息**: - 平台配置 `USB_ADB_HOST`(默认 `100.100.10.1`)/ `USB_ADB_PORT`(默认 5037)
- STF 服务地址(如 `http://192.168.20.220:7100`)
- STF API Token(在 STF 个人设置 → API Keys 里生成)
### 1.3 adb 工具 ### 1.3 adb 工具
@@ -41,8 +40,9 @@ STF 部署参考官方文档:https://github.com/DeviceFarmer/stf
设备需满足: 设备需满足:
- 开启 **USB 调试**(设置 → 开发者选项) - 开启 **USB 调试**(设置 → 开发者选项)
- 或通过 **adb 网络连接**(设置 → 开发者选项 → 无线调试,获取 IP:5555) - 转网络调试:USB 连上后 `adb -s <serial> tcpip 5555`(管理后台「adb 终端」有一键快捷命令)
- 已接入 STF 设备池(STF 显示 present=True, ready=True) - 设备加入 Tailscale(同一账号,获得 100.100.10.x IP)
- 在「工具 → 设备池管理」添加 `IP:5555`(自动尝试连接,任务运行时 u2 自动推送 atx-agent)
--- ---
@@ -87,15 +87,9 @@ pip install -r requirements.txt
```ini ```ini
# .env 示例 # .env 示例
STF_TOKEN=你的STF_API_Token
WEB_SECRET_KEY=随机字符串(会话密钥,python -c "import secrets;print(secrets.token_hex(32))" 生成) WEB_SECRET_KEY=随机字符串(会话密钥,python -c "import secrets;print(secrets.token_hex(32))" 生成)
TAILSCALE_API_KEY=你的Tailscale_API_key TAILSCALE_API_KEY=你的Tailscale_API_key
``` # USB_ADB_HOST=100.100.10.1 # 220 的 Tailscale IP(USB 设备远程 adb server,默认值已可用)
`config.py` 只改非密钥项,如 `STF_URL`:
```python
STF_URL = "http://你的STF地址:端口"
``` ```
其他配置按需调整: 其他配置按需调整:
@@ -105,36 +99,24 @@ STF_URL = "http://你的STF地址:端口"
| `WEB_HOST` | `0.0.0.0` | 仅本机访问改为 `127.0.0.1` | | `WEB_HOST` | `0.0.0.0` | 仅本机访问改为 `127.0.0.1` |
| `WEB_PORT` | `18050` | 端口冲突时修改 | | `WEB_PORT` | `18050` | 端口冲突时修改 |
| `ADB_PATH` | 自动识别 | 用自定义 adb 时修改 | | `ADB_PATH` | 自动识别 | 用自定义 adb 时修改 |
| `STF_SSH_TARGET` | `[email protected]` | 维护页"一键重启 STF 容器"与工具页"STF 设备管理"的 SSH 目标 | | `USB_ADB_HOST` | `100.100.10.1` | USB 设备所在部署机(220)的 Tailscale IP |
| `STF_SSH_PASSWORD` | (空) | SSH 密码认证(.env 配置后走 paramiko 密码登录,跨平台);留空则退回系统 ssh 免密密钥(BatchMode) | | `USB_ADB_PORT` | `5037` | 220 adb 容器监听端口(host 网络模式) |
| `STF_DOCKER_CONTAINER` | `stf` | 220 上 STF Docker 容器名(`docker ps` 查看) |
| `STF_ADB_CONTAINER` | `adb` | 220 上 adb Docker 容器名(STF 设备管理执行 `docker exec adb adb ...`) |
| `STF_SCRIPT_PATH` | `/mnt/data/openstf/connect_devices.sh` | STF 设备池脚本路径(工具页增删设备会读写其 DEVICES 列表) |
| `TAILSCALE_TAILNET` | 按邮箱前缀 | tailnet 名称/ID(个人账号一般为登录邮箱前缀) | | `TAILSCALE_TAILNET` | 按邮箱前缀 | tailnet 名称/ID(个人账号一般为登录邮箱前缀) |
> **未配置 `WEB_SECRET_KEY`**:启动时随机生成(每次重启登录态失效,生产务必配置固定值)。 > **未配置 `WEB_SECRET_KEY`**:启动时随机生成(每次重启登录态失效,生产务必配置固定值)。
> **工具页 Tailscale 管理前置**:`.env` 写入 `TAILSCALE_API_KEY` 后重启服务; > **工具页 Tailscale 管理前置**:`.env` 写入 `TAILSCALE_API_KEY` 后重启服务;
> 未配置时管理分区显示明确提示,不影响其他功能。 > 未配置时管理分区显示明确提示,不影响其他功能。
> (历史遗留的 `STF_URL`/`STF_TOKEN`/`STF_SSH_*` 等配置已废弃,代码不再读取。)
> **维护页 STF 重启 / 工具页 STF 设备管理前置**:需能 SSH 到部署机——两种方式任选:
> 1. `.env` 配置 `STF_SSH_PASSWORD` 走密码认证(paramiko,无需额外工具,推荐)
> 2. 免密密钥:本机公钥加入目标机 `authorized_keys`
> 且 `STF_DOCKER_CONTAINER`/`STF_ADB_CONTAINER` 与真实容器名一致;未配置好时按钮会返回明确错误。
### 2.4 启动服务 ### 2.4 启动服务
**方式一:命令行启动** **命令行启动**
```bash ```bash
python web_server.py python web_server.py
``` ```
**方式二:Windows 一键启动** (推荐配合 `scripts/supervise.sh` 进程守护,见 3.1 节)
双击 `start_web.bat`:
- 自动提权到管理员
- 添加防火墙规则(支持局域网访问)
- 启动 web_server
### 2.5 验证部署 ### 2.5 验证部署
@@ -254,7 +236,7 @@ server {
| 7100 | STF | STF 服务端口(STF 自己的配置) | | 7100 | STF | STF 服务端口(STF 自己的配置) |
| 5555 | adb | 设备 adb 网络端口(设备端) | | 5555 | adb | 设备 adb 网络端口(设备端) |
### 4.2 Windows 端口问题 ### 4.2 Windows 端口问题(仅 Windows)
Windows 可能将某些端口范围划为动态排除范围,导致绑定失败(WinError 10013)。 Windows 可能将某些端口范围划为动态排除范围,导致绑定失败(WinError 10013)。
@@ -265,12 +247,10 @@ netsh interface ipv4 show excludedportrange protocol=tcp
如果 18050 在排除范围内,修改 `config.py` 的 `WEB_PORT` 到一个不在排除范围内的端口。 如果 18050 在排除范围内,修改 `config.py` 的 `WEB_PORT` 到一个不在排除范围内的端口。
或运行 `scripts/fix_web.bat`(关闭系统代理 + 添加防火墙规则 + 刷新 DNS)。
### 4.3 局域网访问 ### 4.3 局域网访问
- `WEB_HOST = "0.0.0.0"` 允许局域网访问 - `WEB_HOST = "0.0.0.0"` 允许局域网访问
- 需要添加防火墙入站规则(`start_web.bat` 会自动处理) - 需要添加防火墙入站规则(Windows 手动添加,或参考 `netsh advfirewall firewall add rule`)
- 局域网其他机器访问 `http://部署机IP:18050/` - 局域网其他机器访问 `http://部署机IP:18050/`
--- ---
+18 -16
View File
@@ -13,7 +13,7 @@
| `dev` | **开发分支**,所有新功能/修复都在这里开发 | | `dev` | **开发分支**,所有新功能/修复都在这里开发 |
| `main` | **生产分支**(主分支),只放已确认的稳定版本 | | `main` | **生产分支**(主分支),只放已确认的稳定版本 |
生产环境 = STF 主机 `192.168.20.220` 的 `/mnt/data/openstf/auto_control`(python-app 容器运行 `web_server.py`)。 生产环境 = 部署机 `192.168.20.220` 的 `/mnt/data/openstf/auto_control`(python-app 容器运行 `web_server.py`)。
### 1.2 git 操作铁律(重要) ### 1.2 git 操作铁律(重要)
@@ -42,47 +42,49 @@
| 环境 | 位置 | 说明 | | 环境 | 位置 | 说明 |
|------|------|------| |------|------|------|
| 开发机 | 本机(192.168.20.57) | `.venv` + 本地运行 `web_server.py` | | 开发机 | 本机(192.168.20.57) | `.venv` + 本地运行 `web_server.py` |
| 生产机 | STF 主机 220 的 `auto_control` | python-app 容器,`network_mode: host` | | 生产机 | 部署机 220 的 `auto_control` | python-app 容器,`network_mode: host` |
| STF 服务 | `192.168.20.220:7100` | 设备农场(REST API) | | STF 服务 | ~~`192.168.20.220:7100`~~ | 已停用(2026-08-18 `docker stop stf`,代码已摘除依赖) |
| 设备 | Tailscale `100.100.10.x:5555` | 5 台 Xiaomi,本机 `100.100.10.2` 在 tailnet 内 | | adb 容器 | 220 上 `adb`(host 网络 5037) | USB 设备远程 adb server;网络设备补连用 |
| 设备 | Tailscale `100.100.10.x:5555` | Xiaomi 舰队,本机 `100.100.10.2` 在 tailnet 内 |
| uiautodev | 本机 `20242` | 元素抓取服务(web_server 自动拉起) | | uiautodev | 本机 `20242` | 元素抓取服务(web_server 自动拉起) |
### 2.1 adb key(关键) ### 2.1 adb key(关键)
- 本机 `~/.android/adbkey` 已换成 **STF adb 容器的 key**(5 台设备都信任) - 本机 `~/.android/adbkey` 沿用历史 key(原取自 STF adb 容器,全部设备都信任)
- **不要随意更换 key**——设备会变 unauthorized 连不上 - **不要随意更换 key**——设备会变 unauthorized 连不上
- 旧 key 备份在 `~/.android/adbkey.local.bak` - 旧 key 备份在 `~/.android/adbkey.local.bak`
- 生产环境的容器也需要用这把 key(部署时处理) - 生产环境的容器也需要用这把 key(部署时处理)
### 2.2 设备连接方式 ### 2.2 设备连接方式
- 设备 serial 是 `IP:5555`(Tailscale 地址),**直连**优先 - 设备 serial 是 `IP:5555`(Tailscale 地址),**直连**优先(只 connect、绝不 disconnect)
- STF 桥接(remoteConnect)在此环境**不可用**(隧道 adb key 认证失败,显示 unauthorized) - USB 设备(serial 无冒号):插本机走本地 adb;插 220 走远程 adb server(`USB_ADB_HOST:5037`)
- 本机已在 tailnet 内,直连可靠且快(<1s) - 本机已在 tailnet 内,直连可靠且快(<1s)
- 设备加入/退出平台:工具 → 设备池管理(SQLite 清单,自动连接 + 型号采集)
--- ---
## 3. 技术红线(开发限制)—— 违反会打断 STF,需人工恢复 ## 3. 技术红线(开发限制)—— 违反会打断共享 adb transport,需人工恢复
这些是踩过坑后总结的,**任何修改都不能引入**。违反任何一条都会导致设备被 STF 误判离线、需人工处理: 这些是踩过坑后总结的,**任何修改都不能引入**。违反任何一条都会导致设备连接被全部重建(历史原因:STF provider 共享同一 adb transport,摘除 STF 后仍保留此约束):
1. **绝不 `adb kill-server`** 1. **绝不 `adb kill-server`**
- 会断开所有设备的 adb transport,STF 对全网设备误判离线并触发重连 - 会断开所有设备的 adb transport,全部设备连接被重建,运行中任务中断
- 见 `core/adb_helper.py` - 见 `core/adb_helper.py`
2. **绝不对 `IP:5555` 设备 `adb disconnect`** 2. **绝不对 `IP:5555` 设备 `adb disconnect`**
- STF provider 共享该地址的 adb transport,disconnect 会断 STF - 该地址的 adb transport 是共享的(历史与 STF provider 共用),disconnect 会断掉全部相关连接
- 直连模式下 `release()` 不 disconnect - 直连模式下 `release()` 不 disconnect
- 见 `core/device_worker.py` `STFDevice.release()` - 见 `core/device_worker.py` `STFDevice.release()`
3. **空闲设备扫描不主动 connect/disconnect** 3. **空闲设备扫描不主动 connect/disconnect**
- STF provider 内部通过 IP:5555 维持连接,外部 connect/disconnect 会让 STF 误判离线 - IP:5555 的 transport 由多方共享(历史与 STF provider 共用),外部 connect/disconnect 会扰动共享连接
- `_ForegroundScanner._scan_free` 对空闲设备直接返回"空闲",不碰 adb - `_ForegroundScanner._scan_free` 对空闲设备直接返回"空闲",不碰 adb
- 见 `core/task_manager.py` - 见 `core/task_manager.py`
4. **adb key 保持为 STF 容器的 key**(见 2.1) 4. **adb key 保持历史 key 不变**(见 2.1,设备信任该 key)
5. **直连优先,不引入 STF 桥接**(见 2.2) 5. **直连优先,不引入第三方桥接**(见 2.2)
### 3.1 其他开发限制 ### 3.1 其他开发限制
@@ -92,7 +94,7 @@
- **数据库是 SQLite**(`data/users.db`,WAL 模式):运行时数据不提交 git - **数据库是 SQLite**(`data/users.db`,WAL 模式):运行时数据不提交 git
- **前端 JS 在 `static/admin/monitor.js`**(已从 HTML 拆分),HTML 里用 `<script src>` 引用 - **前端 JS 在 `static/admin/monitor.js`**(已从 HTML 拆分),HTML 里用 `<script src>` 引用
- **监控页/大列表已加分页**:100 台设备也只渲染 10 行/页,不要移除分页逻辑 - **监控页/大列表已加分页**:100 台设备也只渲染 10 行/页,不要移除分页逻辑
- **任务批量触发已错峰**(`_START_STAGGER_SEC`):避免大量设备同时启动造成 adb/STF 风暴,不要移除 - **任务批量触发已错峰**(`_START_STAGGER_SEC`):避免大量设备同时启动造成 adb 连接风暴,不要移除
--- ---
@@ -151,7 +153,7 @@ with sync_playwright() as p:
- **看日志**:`logs/` 下 `core.log` / `task.log` / `web.log` / `action.log`(10MB 滚动,保留 5 份) - **看日志**:`logs/` 下 `core.log` / `task.log` / `web.log` / `action.log`(10MB 滚动,保留 5 份)
- **看设备/任务状态**:浏览器监控页,或 `GET /api/status`、`GET /api/health` - **看设备/任务状态**:浏览器监控页,或 `GET /api/status`、`GET /api/health`
- **清理 STF 残留占用**:`python scripts/cleanup.py`(或前端"强制释放占用") - **清理 STF 残留占用**:前端监控页"强制释放占用"(或 `.env` 配 `AUTO_RELEASE_STALE_OCCUPY=true` 启动自动清理)
- **打包项目**:`python scripts/pack.py` - **打包项目**:`python scripts/pack.py`
--- ---
+147
View File
@@ -0,0 +1,147 @@
# MCP 手机控制 Server(`mcp_server/`)
多模态 AI(DeepSeek/Claude 等)通过 [MCP(Model Context Protocol)](https://modelcontextprotocol.io) 实时操作 Android 手机的统一出口。AI 控制台(`mcp_agent/`)与外部 MCP 客户端都经它控制设备池中的手机。
> 本文档 = 使用手册(工具清单/用法)。架构与演进设计见 [MCP_DESIGN.md](MCP_DESIGN.md)。
## 架构
```
AI 控制台 / 外部 MCP 客户端
│ Streamable HTTP
▼
MCP Server (:8033, mcp_server/mcp_server.py) ← 19 个 de_* 工具
│ 平台 HTTP API(登录 + CSRF)
▼
auto_control 平台 (:18050) ← 设备池/任务/看屏
│ adb / uiautomator2 / uiautodev / OCR
▼
Android 设备(IP:5555)
```
- 轻量通道直连(adb monkey 开 App、u2 输入、本地 OCR)不绕平台,省时省 token
- 坐标换算、UI 吸附、剪贴板注入等细节全部在 Server 层消化,模型只需给意图
## 运行与配置
服务跑在 220 的 `python-app` 容器内(`scripts/start.sh` 自动拉起,端口 8033)。
生产访问方式:`http://192.168.20.220:8033/mcp`;本机调试 `python3 -m mcp_server.mcp_server`。
| 环境变量 | 默认 | 说明 |
|---|---|---|
| `MCP_PLATFORM_URL` | `http://127.0.0.1:18050` | 平台地址(Agent 与平台同机时用本机) |
| `MCP_PLATFORM_USER` / `MCP_PLATFORM_PASS` | `admin` / 空 | 平台登录账号 |
| `MCP_ALLOW_WRITE` | `0` | **写门控**:=1 才允许点击/输入/开关 App 等写操作(只读工具不受限) |
| `MCP_ALLOWED_SERIALS` | 空=不限 | 设备白名单(逗号分隔),非空时只允许列出的 serial |
| `MCP_HTTP_HOST` / `MCP_HTTP_PORT` | `0.0.0.0` / `8033` | 监听地址 |
| `MCP_SCREENSHOT_WIDTH` | `540` | 截图返回宽度上限(px),模型看到的图即该坐标系 |
| `MCP_JPEG_QUALITY` | `70` | 截图 JPEG 质量 |
| `MCP_AUDIT_FILE` | `/var/log/mcp/audit.log` | 审计日志(每次工具调用一行) |
| `MCP_PLATFORM_TIMEOUT` | `30` | 平台请求超时(秒) |
生产(220 容器)已设 `MCP_ALLOW_WRITE=1`;`MCP_ALLOWED_SERIALS` 未设(= 平台设备池内可操作)。
## 坐标空间(重要约定)
- `de_screenshot` 返回 ≤540px 宽的 JPEG(display 空间),并附 `native_size`(设备原生分辨率)
- `de_tap` / `de_swipe` 的坐标一律使用 **de_screenshot 返回图像的坐标系**,Server 按比例换算为原生坐标
- **先截图、后点击**:Server 需要最近一次截图才能建立坐标空间(未截图就点击会报「请先执行 de_screenshot」)
- `de_tap` 带**自动吸附**:点击点若落在某个可点击元素内,实际会点该元素中心——坐标只需大致对准,偏十几像素也能点准;点空白处则按原坐标
- 返回的 `snapped/label` 可核对吸附结果(snapped=true 表示已吸到元素,label 为该元素文案)
## 工具清单(19 个)
### 设备与状态
| 工具 | 用途 |
|---|---|
| `de_list_devices` | 列出可控制设备:serial / 型号 / 在线 / 任务状态 / 前台 App。**开局第一步** |
| `de_foreground_app(serial)` | 当前前台 App 包名(dumpsys,MIUI 焦点为空时自动兜底) |
### 观察屏幕(感知)
| 工具 | 用途 |
|---|---|
| `de_screenshot(serial)` | 截图并返回图像(≤540px JPEG)+ 尺寸 + 亮/熄屏状态。多模态模型直接看图 |
| `de_ui_tree(serial, limit=150)` | 当前界面元素树(text/id/desc/class/bounds)。**可点击元素排前**;limit 1-300 控制条数防 token 膨胀。用于确认界面上有什么 |
| `de_ocr(serial)` | OCR 识别当前屏幕文字(UI 树没有的图片/WebView 文字也能识别),返回 [{text, score}] |
### 点击与滑动(操作)
| 工具 | 用途 |
|---|---|
| `de_tap_text(serial, text)` | **按屏幕文字点击**(推荐):给一个屏幕上可见的文字(子串匹配)即找到并点其中心。原生控件走 UI 树,WebView/图片文字自动 OCR 兜底。找不到返回明确错误 |
| `de_tap_element(serial, by, value, index=1)` | 按元素点击:`by` = text / id / desc(精确)或 text_contains / desc_contains(模糊)。多命中用 index 取第几个 |
| `de_tap(serial, x, y)` | 坐标点击(截图坐标系,自动吸附,见上)。**纯图形目标(视频画面/无文字图标)才用它** |
| `de_swipe(serial, x1,y1,x2,y2, duration=0.2)` | 滑动(截图坐标系;长按=同点起止 + duration≥1) |
| `de_press_key(serial, key)` | 按键:back / home / recent / menu / power / volume_up / volume_down / enter / delete / search / camera |
### 输入与剪贴板
| 工具 | 用途 |
|---|---|
| `de_type_text(serial, text)` | 向当前界面输入框输入文字(支持中文,直设 EditText 不依赖剪贴板/粘贴) |
| `de_set_clipboard(serial, text)` | 写入设备剪贴板(ClipInject 通道注入 + 读回验证) |
| `de_read_clipboard(serial)` | 读取设备当前剪贴板内容 |
### App 管理(轻量 adb 直连,不建 u2 会话)
| 工具 | 用途 |
|---|---|
| `de_open_app(serial, package)` | 打开 App(adb monkey 直启,无需知道 activity——最快的打开路径) |
| `de_stop_app(serial, package)` | 强制停止 App(am force-stop) |
| `de_list_apps(serial, keyword="")` | 列出第三方已装应用(pm list packages -3),keyword 可过滤(如 "douyin") |
| `de_sleep(serial)` | 熄屏(**运行中任务会中断,慎用**) |
| `de_wake(serial)` | 亮屏并解锁(熄屏时先调它再截图) |
### 平台联动
| 工具 | 用途 |
|---|---|
| `de_list_tasks()` | 列出平台任务计划(名称/类型/启用/调度),了解已自动化的工作 |
## 推荐使用模式(操作手机的正确姿势)
1. **`de_list_devices`** 确认目标设备在线
2. **`de_screenshot`** 看图理解当前界面(图像会在下一次模型回合送达)
3. **点击定位优先级**(从高到低):
- 目标有可见文字 → **`de_tap_text`**(一次调用完成「找到并点击」,最可靠)
- 文字有歧义/多候选 → `de_ui_tree` 确认后 `de_tap_element`(text_contains 模糊匹配)
- 纯图形目标 → `de_tap` 坐标(**无需精算**,Server 自动吸附到可点元素中心)
4. 输入文字:先点中输入框(de_tap_text / de_tap),再 `de_type_text`
5. 每次关键操作后 `de_screenshot` 验证:界面变化 = 成功;无变化 = 未命中,换 de_tap_text / de_tap_element 重新定位,**不要重复点同一坐标**
6. 完成/失败时用中文总结:做了什么、当前状态、注意事项
7. 效率约束:界面未变不重复截图/点同位置;连续 6 步无进展停止并总结
## 安全与审计
- **写门控**:`MCP_ALLOW_WRITE=0`(默认)时点击/输入/开关 App 全部拒绝,只读工具可用
- **设备白名单**:`MCP_ALLOWED_SERIALS` 非空时限制可操作的 serial
- **审计日志**:每次调用记录 `{ts, tool, serial, args, result}` 到 `MCP_AUDIT_FILE`(220 上 `/tmp/mcp_audit.log`)
- 操作对象限定平台设备池;`adb kill-server` / `adb disconnect` 属项目红线,任何工具不触碰
## 客户端接入示例
```python
import asyncio
from fastmcp import Client
async def main():
async with Client("http://192.168.20.220:8033/mcp", timeout=30) as c:
devs = await c.call_tool("de_list_devices", {})
serial = devs.data["data"][0]["serial"] # 取第一台在线设备
shot = await c.call_tool("de_screenshot", {"serial": serial})
img = shot.data["data"]["image"] # base64 JPEG(多模态模型可直接看图)
await c.call_tool("de_tap_text", {"serial": serial, "text": "搜索"})
asyncio.run(main())
```
## AI 控制台(内置 Agent)
Web 端「AI 控制台」Tab 内建的 Agent(`mcp_agent/`,OpenAI 兼容协议:DeepSeek 等)也通过本 Server 的同一批工具跑任务:
- 流式输出 + 每步 MCP 工具调用实时展示(含截图缩略)
- 多轮会话记忆(同会话上下文保留,新建会话清空)
- **自进化经验记忆**:一轮成功操作会被提炼成「配方」存入 `agent_experience` 表,下次相似任务自动注入参考(命中/写入均有 🧠 提示卡)
- 模型与 API Key 在 AI 控制台右上角 ⚙ 配置,存平台 `app_meta`
+244
View File
@@ -0,0 +1,244 @@
# MCP 手机控制(Mobile Control MCP Server)设计文档
> 分支:dev:mcp | 状态:设计稿 | 日期:2026-09-04
## 1. 背景与目标
平台(auto_control,生产 220:18050)已具备完整的手机远程控制与自动化能力:设备池管理、u2 控制(tap/swipe/key/输入/剪贴板)、minicap 看屏(截图/流)、uiautodev 元素树、RapidOCR、任务调度与动作编辑器。
本设计的目标:**新增一个 MCP Server,把平台能力以 MCP 协议暴露给多模态 AI(Claude 等),使 AI 能像人一样"看到手机屏幕 → 理解 → 操作 → 再看到"地闭环控制手机**。
核心判断:平台能力全部已有,MCP Server 是**薄封装层**——主要工作在:工具规格设计、多模态图像链路、认证与安全、部署。
## 2. 术语
| 术语 | 说明 |
|---|---|
| MCP | Model Context Protocol,模型上下文协议(客户端-服务器,工具调用) |
| Tools | MCP 暴露的可调用能力(本设计按 L1/L2/L3 分层) |
| serial | 设备标识,如 `192.168.20.66:5555`(设备池唯一主键) |
| 平台 | 指 auto_control web 服务(18050)及其 core 层能力 |
| 宿主 | MCP Server 运行进程/容器 |
## 3. 总体架构
```
┌──────────────┐ MCP (Streamable HTTP / stdio) ┌──────────────────┐
│ 多模态 AI │ ────────────────────────────────▶ │ MCP Server │
│ Claude Desktop│ tools + image content block │ (220 独立进程) │
│ Claude Code │ ◀──────────────────────────────── │ mcp/ 包 │
└──────────────┘ 截图图像 / JSON 结果 └────────┬─────────┘
│ 内部调用
▼
┌──────────────────┐
│ auto_control │
│ 18050 REST API │
│ (登录会话 token)│
├──────────────────┤
│ core 层能力: │
│ u2 / minicap / │
│ uiautodev / OCR /│
│ 任务调度 / 设备池 │
└──────────────────┘
```
- **图像方向**:MCP 协议支持 `image` content block(base64),截图以此返回,多模态模型原生可读——这是"AI 看到手机"的关键链路。
- **控制方向**:AI 返回结构化工具调用(tap/swipe/type…),MCP Server 转发平台执行。
- **循环**:截图 → 模型视觉推理 → 工具调用 → 平台执行 → 截图验证。
## 4. 平台现有能力复用清单
| 平台能力 | 现有入口 | MCP 复用方式 |
|---|---|---|
| 设备列表/状态 | `GET /api/status`、`/api/devices/pool` | MCP server 内部 HTTP 调用(带会话) |
| 截图 | `GET /api/screen/thumb?serial=`(单帧 JPEG) | 直接转发为 image block(亦可直连 core u2 screenshot) |
| 看屏流 | `/api/screen/stream`(MJPEG) | M0-M2 不需要;M3 可选(视频理解场景) |
| 点击 | `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` |
| 元素树 | `GET /api/uiauto/...`(uiautodev) | 封装 `get_ui_tree` |
| OCR | core.ocr(RapidOCR) | 封装 `ocr_screen` |
| 打开 App | u2 `app_start`(经任务层/直连) | 封装 `open_app`(走 core device 直连) |
| 设备在线状态/亮熄屏 | `/api/device/screen_all`、dumpsys | 封装 `screen_state`/`wake` |
> 决策:MCP Server 优先走 **HTTP API**(薄封装,认证简单、与平台解耦、平台安全逻辑全复用)。对延迟敏感且 HTTP 无入口的能力(u2 直连截图/输入),经平台 core 模块进程内调用或新增少量只读端点,不绕过平台安全层。
## 5. MCP Server 设计
### 5.1 技术栈
- 语言/运行时:Python 3.11(与平台一致)
- 框架:**FastMCP**(`fastmcp`,官方 SDK 之上,装饰器式 tools,自带 Streamable HTTP/stdio 双传输)
- 依赖:`fastmcp`、`httpx`、`Pillow`(图像处理)、`mcp[cli]`
- 日志:logging → 平台同款格式(时间/级别/模块)
### 5.2 进程与容器
- 独立容器 `mcp-server`(220 docker-compose 追加),image `python:3.11-slim`
- 挂载:无数据挂载(无状态,配置走环境变量);网络 host 或独立端口(**候选:8033**,避免与 18050/18051 冲突)
- 独立于 auto_control 重启,互不阻塞;MCP Server 崩溃不影响平台,平台不可用时 MCP tools 返回明确错误
### 5.3 配置(环境变量)
| 变量 | 默认 | 说明 |
|---|---|---|
| `MCP_PLATFORM_URL` | `http://127.0.0.1:18050` | 平台地址 |
| `MCP_PLATFORM_USER` / `MCP_PLATFORM_PASS` | 空 | 平台登录账号(admin) |
| `MCP_ALLOW_WRITE` | `0` | 写操作总开关(0=只读感知,1=可操作) |
| `MCP_ALLOWED_SERIALS` | 空=全部 | 设备白名单(逗号分隔;为空时自动=设备池内设备) |
| `MCP_HTTP_PORT` | `8033` | HTTP 传输端口 |
| `MCP_SCREENSHOT_WIDTH` | `540` | 截图宽度(等比缩放,控图像 token 成本) |
| `MCP_JPEG_QUALITY` | `70` | 截图 JPEG 质量 |
| `MCP_AUDIT_FILE` | `/var/log/mcp/audit.log` | 审计日志路径 |
## 6. Tools 规格
命名空间 `de_`(device)前缀避免与常见工具冲突。全部工具对**未配置白名单/离线设备**返回明确错误,不做静默跳过。
### L1 感知层
#### `de_list_devices`
- 描述:列出可控制设备及其状态(在线/任务/型号/前台 App)
- 返回:`[{serial, model, online, task_job, worker_status, foreground_app, screen_state}]`
- 错误:平台不可达 → `platform_unavailable`
#### `de_screenshot(serial: str) -> image`
- 描述:截取设备当前屏幕(JPEG),返回 image content block;同时返回 `width/height/serial` 元信息
- 实现:平台 `/api/screen/thumb`(X-Screen-State 头复用判断亮熄屏);失败(离线/超时)→ 明确错误
- 图像规格:宽 ≤ `MCP_SCREENSHOT_WIDTH`(默认 540),质量 `MCP_JPEG_QUALITY`;**需保证图像方向正确**(设备可能横竖屏,含 EXIF 或由调用方按截图尺寸推断)
- 限制:截图频率 ≥ 1s/次(防 AI 疯狂截图),可配置
#### `de_ui_tree(serial: str) -> str`
- 描述:获取当前界面元素树(扁平 JSON:resource-id/text/content-desc/class/bounds),供定位
- 返回:文本 JSON(大树截断至 `MCP_UI_TREE_MAX` 字符,默认 20000,防 context 爆炸)
- 空界面/无元素 → 明确错误
#### `de_ocr(serial: str) -> str`
- 描述:OCR 识别当前屏幕文字,返回 `[{text, score, box}]`
- 场景:图片/画布/WebView 渲染文字(UI 树里没有的)
#### `de_screen_state(serial: str) -> str`
- 描述:亮屏/熄屏/未知
### L2 操作层(受 `MCP_ALLOW_WRITE=1` 门控)
#### `de_tap(serial, x: int, y: int)`
- 坐标:设备**原生分辨率**像素(与截图 1:1 换算——调用方用截图尺寸按比例换算,server 不做猜测)
#### `de_swipe(serial, x1,y1,x2,y2, duration: float=0.2)`
#### `de_press_key(serial, key: str)`
- 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 实测通过)
#### `de_set_clipboard(serial, text: str)`
- 写入设备剪贴板(ClipInject am start 通道),读回验证
#### `de_open_app(serial, package: str)`
- 打开指定 App(u2 app_start);`package` 需在已知清单或完全限定名
#### `de_wake(serial)` / `de_sleep(serial)`
### L3 语义层(M2 里程碑)
#### `de_find_and_tap(serial, target: str, by: "text"|"id"|"ocr"|"desc"=...)`
- UI 树/OCR 定位含 target 的元素 → 自动点击其中心;多命中返回候选列表让 AI 选择
- 复用平台元素选择器策略(属性唯一/重复序号消歧)
#### `de_wait_until(serial, condition: str, timeout: int=15)`
- 轮询 OCR/UI 树直到出现条件文本(或消失),返回命中的坐标/文本
#### `de_get_task_state(serial)`(若二期接任务系统)
- 设备当前任务状态;可选 `de_run_task(task_id)` 需单独授权
### 工具返回约定
- 全部工具返回结构化 JSON:`{ok: true, data: ...}` 或 `{ok: false, error: {code, message}}`
- 错误码:`platform_unavailable` / `device_offline` / `device_not_allowed` / `invalid_param` / `write_disabled` / `busy`(设备被任务占用)
- image 返回:`{ok:true, image: <content block>, width, height}`
## 7. 图像链路细节
1. 平台截图(`/api/screen/thumb`)→ JPEG 字节
2. MCP Server 校验尺寸 → 等比缩放到 `MCP_SCREENSHOT_WIDTH`(用 Pillow,不重压缩已够质量则跳过)
3. 组装 MCP `image` content block(`{"type":"image","data":<base64>,"mimeType":"image/jpeg"}`)
4. 客户端(Claude)原生把 image 传入视觉上下文
性能预算:540px JPEG ~40-80KB/张;模型一次多步操作 5-15 张 ≈ 1MB 以内,可接受。
## 8. 认证与安全
1. **平台会话**:MCP Server 启动时用 `MCP_PLATFORM_USER/PASS` 登录平台拿会话(Cookie),会话失效自动重登;不向客户端暴露平台凭据
2. **写操作门控**:`MCP_ALLOW_WRITE=0`(默认)时 L2/L3 操作全部拒绝(`write_disabled`)——先部署只读,验证感知链路后再开写
3. **设备白名单**:`MCP_ALLOWED_SERIALS` 指定;为空时自动限制为**设备池 enabled 设备**(平台口径)
4. **占用互斥**:操作前查设备 `worker_status`——running/connecting 中拒绝操作(`busy`),避免 MCP 与任务打架
5. **审计**:每次调用(含只读)写审计日志:`ts|tool|serial|args摘要|result`;落盘 `MCP_AUDIT_FILE`
6. **传输安全**:内网部署(host 网络 8033)默认无 TLS;若外网暴露需前置 TLS/网络隔离(平台红线:设备控制接口不进公网)
7. **无状态**:MCP Server 不存设备数据,全量透传平台
## 9. 部署(220 docker-compose 追加)
```yaml
mcp-server:
container_name: mcp-server
image: dockerproxy.net/library/python:3.11-slim
restart: unless-stopped
working_dir: /app
volumes:
- "./mcp_server:/app"
command: sh -c "pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt && python -m mcp_server"
environment:
- MCP_PLATFORM_URL=http://127.0.0.1:18050
- MCP_PLATFORM_USER=admin
- MCP_PLATFORM_PASS=${MCP_PLATFORM_PASS} # .env 提供
- MCP_ALLOW_WRITE=1
- MCP_ALLOWED_SERIALS=
network_mode: host
```
客户端接入(Claude Code / Desktop):
```json
// .mcp.json / claude_desktop_config.json
{ "mcpServers": { "mobile": {
"url": "http://192.168.20.220:8033/mcp",
"transport": "streamable-http"
}}}
```
## 10. 里程碑与验收
| 里程碑 | 内容 | 验收标准 |
|---|---|---|
| M0 | Server 骨架 + `de_list_devices` + `de_screenshot` + `de_tap`/`de_swipe`(HTTP 传输、登录会话、审计骨架) | Claude Code 连上后:能列设备、截图(AI 能描述屏幕内容)、点击指定坐标生效 |
| M1 | L1 完备(ui_tree/ocr/screen_state)+ L2 全量(key/type/clipboard/open_app/wake) | AI 完成「打开抖音搜索『奚学东』」多步操作 |
| M2 | L3 语义层(find_and_tap/wait_until)+ 图像尺寸/性能调优 | AI 用自然语言指令(含中文输入)完成跨 3 步以上操作且自适应页面变化 |
| M3 | 生产化:安全加固复核、审计看板、部署脚本、接入文档、任务系统互斥回归 | 日常稳定使用 1 周无异常 |
## 11. 风险与开放问题
1. **AI 误操作**:模型点击错位/误触关键按钮(如删除)——缓解:L3 定位优先于裸坐标、写门控先行、审计可追溯;**不做**二次确认(破坏自动化体验),靠白名单+占用互斥兜底
2. **截图成本**:长会话多截图 → token/延迟成本——缓解:尺寸/质量可调、截图上限、`de_ui_tree` 作为低成本替代(文本树比图像便宜)
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 层
## 12. 附录:平台 API 映射(MCP → 平台)
| MCP tool | 平台调用 |
|---|---|
| de_screenshot | GET /api/screen/thumb?serial= |
| de_tap / de_swipe / de_press_key | POST /api/screen/tap|swipe|key |
| de_type_text / de_set_clipboard | POST /api/tools/clipboard/set(ClipInject)+ u2 input |
| de_ui_tree | GET /api/uiauto/…(或 core 直连) |
| de_ocr | core.ocr(进程内或新只读端点) |
| de_list_devices | GET /api/status + /api/devices/pool |
| de_open_app | core device app_start(或任务层 open_app 动作) |
| de_screen_state | dumpsys(core.common._device_screen_state) |
> 实现时对平台缺失的只读入口(如 ocr/open_app 的直连面)优先在平台加**只读端点**(复用现有权限体系),MCP 不越权直连设备。
+154
View File
@@ -0,0 +1,154 @@
# 摘除 STF 迁移计划
背景:全舰队设备为 Tailscale IP:5555 直连(adb key 沿用 STF 的),单实例部署。
STF 当前仅提供:occupy/release 互斥、present+ready 健康信号、设备池清单(与
220 的 connect_devices.sh 双维护)、remoteConnect 桥接(IP:port 已禁用)、网页看屏。
任务本体跑 uiautomator2(atx-agent),与 STF agent(jp.co.cyberagent.stf)无关。
摘除收益:告别 agent 安装死锁(MIUI 弹窗)、STF 容器重启风险、occupy 冲突悬空;
调度直接基于 adb 真实现状。风险点:多实例互斥(当前单实例无此需求,见阶段 3)。
## 已确认的决策(2026-08-17)
1. **设备清单放本地 SQLite**(data/users.db 新增 devices 表),220 的 connect_devices.sh
退役(其"每 5 分钟补连"职责由平台定时器 + SSH 兜底接管,或直接依赖 adb 重试)
2. **USB 有线设备保留支持**——当前舰队 0 台 USB(全 IP:5555),但能力要保留。
实施中发现 **220 的 adb 容器是 host 网络模式、5037 已监听所有网卡(含 Tailscale
100.100.10.1),无需改动 220 任何配置**。平台用 `adb -H 100.100.10.1 -P 5037`
和 `adbutils.AdbClient(host=..., port=5037)`(u2 底层)驱动远端 USB 设备;
本机 USB 设备照旧走本地 adb。已验证(阶段 2 完成)
3. **网页远程看屏**——原计划 ws-scrcpy **不在 npm 分发**(仅 GitHub,国内下载不可靠),
改**自建**:MJPEG 流(u2/atx-agent minicap 截图,~5fps,实测 0.19s/帧)+ u2 触控
(tap/swipe/key/text),管理页新子分栏,零新依赖(阶段 2 完成)
---
## 迁移难度与程度评估(2026-08-17)
### 代码量
| 项 | 量级 |
|---|---|
| 删除 `core/stf_client.py` 及引用 | ~250 行 |
| 新增 `core/device_pool.py`(清单+在线状态) | ~150 行 |
| 新增 devices 表 CRUD(复用现有 SQLite 基建) | ~120 行 |
| 修改 `task_manager`(resolve_serials/调度) | ~120 行 |
| 重写 `device_worker.STFDevice`(去 occupy/release) | ~80 行 |
| 修改 `web_server` 约 8 处调用点 | ~60 行 |
| 改造 `stf_device_mgmt`(SSH 脚本 → SQLite 管理) | ~100 行 |
| 前端 6 个文件文案/组件 | ~80 行 |
净效果:**-600 行 / +400 行 / 改 ~300 行**,集中在 task_manager 与 device_worker 两个核心文件。
### 分项难度(单人)
| 模块 | 难度 | 估算 | 关键点 |
|---|---|---|---|
| 设备池 SQLite + device_pool | 低 | 0.5-1 天 | 表结构 + CRUD + 管理页改数据源 |
| 调度替换 | 中 | 1-2 天 | 内存锁已有(`_running[serial]`),换数据源 + 对照验证 |
| web_server 调用点 | 低 | 0.5 天 | 8 处机械替换 |
| USB 支持 | 中 | 1 天 | 220 容器发端口 + adb_helper 支持 `-H/-P` + u2 远程 server;**需一台真机验证** |
| 网页看屏(ws-scrcpy) | 中 | 0.5-1 天 | 独立 Node 服务 + 内嵌页 + 认证联动 |
| 界面清理 | 低 | 0.5 天 | 文案/按钮/列 |
| 回归验证 | 中 | 1 天 | 调度/抢占/重试/离线跳过/亮屏/剪贴板/终端/看屏 |
**总计约 4-6 人日**,分 4 个 commit 阶段,每阶段可独立回滚。
### 风险
- **中**:USB 真机方案当前无设备可验,落地时需借一台 USB 设备
- **低**:离线跳过判定语义变化(STF 状态 → adb 状态),个别边界行为可能微变,需对照
- **低**:唯一动 220 的地方 = adb 容器发布 5037 端口(一次性)
- **低**:ws-scrcpy 是新增 Node 依赖,需评估其维护性与内存占用(可先试用再定)
### 实施顺序(调整后)
- **阶段 0**:SQLite 设备表 + `core/device_pool.py`(纯新增,STF 照常跑)
- **阶段 1**:调度/生命周期替换(STF 保留运行对照验证)
- **阶段 2**:USB 远程 adb server 打通 + ws-scrcpy 看屏部署
- **阶段 3**:界面清理 + 220 停 STF 容器(`docker stop` 不删,可回滚)
---
## 阶段 0:新建设备池模块(纯新增,不改行为)
新建 `core/device_pool.py` + devices 表(data/users.db):
- devices 表:serial(主键)、name、enabled、note、created_at;管理页增删改
- `list_configured()` — 读 SQLite devices 表(取代 SSH 读 220 脚本)
- `list_online()` — 本机 `adb devices` 中 state=device 的设备(实时)
- `is_online(serial)` — 在 list_online 中;可选 `adb -s get-state` 兜底(带超时)
- `list_ready()` — list_configured 与 list_online 的交集(取代 STF list_free_devices)
- 全模块不 connect/不 kill-server/disconnect,遵守既有红线
- 迁移脚本:现有 8 台设备从 STF 池导入 devices 表
产出:`core/device_pool.py` + `doc/ARCHITECTURE.md` 设备章节初稿 + 数据迁移脚本。
## 阶段 1:替换调度与生命周期(STF 保留运行,可对照验证)
| 位置 | 现状 | 改为 |
|---|---|---|
| `task_manager.resolve_serials` (145-183) | `stf.list_free_devices()` / `list_all_devices()` | `device_pool.list_ready()`;preempt 模式 = 全部 list_online |
| `resolve_serials` skip_offline 过滤 (166-183) | STF present/ready 判定 | `device_pool.is_online()`;跳过原因文案"离线/未连接" |
| `device_worker.STFDevice.acquire` (45-70) | `stf.occupy()` + 直连/桥接 | 去掉 occupy(互斥由 `_running[serial]` 负责);**USB 桥接保留到阶段 2**(避免迁移期 USB 断档,阶段 2 换成 220 adb server);保留 adb_connect 重试 + 2s 等待 |
| `STFDevice.release` (78-93) | `stf.release()` | 置空(仅保留 USB 隧道断开) |
| `STFDevice._pick_free` (72-76) | `list_free_devices` | `device_pool.list_ready()` |
| `_ForegroundScanner` (234-386) | `list_all_devices` / `list_my_devices` / remote_connect | `device_pool.list_online()`;桥接分支删除 |
| `web_server.api_device_screen_all` (646) | STF present 列表 | `device_pool.list_online()` |
| `web_server` 设备列表 (924 / 1011 / 1079) | STF 池合并 | SQLite devices 表 + 本地 adb(`_merged_device_list` 改数据源,保留 stf_not_ready 状态的等价物"未连接") |
| `web_server.api_release` (740) / 启动清理 (1581) | release_all_mine | 删除或改为清理本实例 `_running` 状态(重启本就清零) |
验证清单(阶段 1 完成后全跑一遍):
- [ ] 任务调度(serial/group/all 三种模式)+ 抢占/归还 + 重试 + 离线跳过
- [ ] 一键亮屏/息屏、剪贴板注入、应用版本查询、维护终端设备列表
- [ ] 前台 App 扫描
- [ ] 对照 STF 状态确认无行为差异
## 阶段 2:USB 打通 + 网页看屏(阶段 1 稳定运行 ≥ 3 天后)
- **USB 远程 adb server**:
- 220 侧一次性改动:adb 容器发布 5037 端口(`docker run -p 5037:5037` 或 iptables),
Tailscale 可达
- `core/adb_helper._adb` 支持 `-H <220-tailscale-ip> -P 5037`(仅 USB 序列号设备)
- u2 连接:USB 设备走 `adbutils.Adb(host=220_ip, port=5037)` → `u2.Device(dev)`
(已验证 u2 3.7.0 底层 adbutils 支持远程 adb server)
- 本机 USB 设备照旧走本地 adb,零改动
- 需要一台真机 USB 设备验证
- **网页看屏(ws-scrcpy)**:
- 平台机部署 ws-scrcpy(Node 服务,WebRTC 低延迟流 + 触控),本机 adb 为数据源
- 管理员页面新增「远程看屏」入口,iframe 内嵌;登录态复用(ws-scrcpy 自身鉴权
走 token 或仅内网/Tailscale 暴露)
- 备选方案(若 ws-scrcpy 试用不达标):自建 minicap JPEG 流 + u2 触控注入(2-3 天)
## 阶段 3:界面清理 + STF 停用(已完成代码部分,2026-08-18)
✅ 已完成的代码部分(commit a5ce57b,-956 行):
- 删「一键重启 STF 容器」区块、设备表 STF 占用列、强制释放占用按钮
- 删「STF 设备管理」子分栏(设备池管理面板接管)+ 卸载/检查 STF agent 按钮
- 删除 `core/stf_client.py`、`core/stf_device_mgmt.py` 及全部引用(含 web_server
全部 STF API、启动残留清理、设备池首导 seed)
- 错误类(STFError/DeviceOfflineError)迁入 `core/device_worker.py`;
`create_worker`/`BaseWorker` 去掉 stf 参数;`stf_occupied` 字段全链路移除
- 文案清理(tools/apps/editor/monitor)+ `config.py` STF/SSH 配置标注废弃
- 文档更新(ARCHITECTURE/DEPLOY)
⏳ 待人工确认的最后一步(220 侧):
- **停 STF 容器**(`docker stop stf`,不删除,可 `docker start stf` 回滚)
- 保留 adb 容器(host 网络 5037,USB 设备远程驱动依赖它);
connect_devices.sh cron 可留可退役(对网络设备补连已无必要,无害)
- 停容器前请确认:任务调度/看屏/设备池管理已稳定运行 ≥1 天
## 阶段 4(可选,未来多实例/多机时)
跨实例互斥:SQLite 行锁替代 occupy——
```sql
UPDATE device_locks SET owner=?, ts=? WHERE serial=? AND (owner IS NULL OR owner=?)
```
---
## 回滚方式
- 每个阶段一个 commit,问题可整体 `git revert`;STF 阶段 3 前一直运行,随时回切
- 阶段 3 的 STF 容器是 `docker stop` 而非删除,回滚 = `git revert` + `docker start stf`
- 每个阶段 commit 后在 dev 观察 ≥3 天再进下一阶段
+1 -2
View File
@@ -26,9 +26,8 @@
``` ```
platform-tools/ platform-tools/
├── config.py # 根配置(STF/adb/web,不放任务参数) ├── config.py # 根配置(部署值从 .env 读,不放任务参数)
├── web_server.py # Flask 入口(JSON API + 登录页 + 单页应用) ├── web_server.py # Flask 入口(JSON API + 登录页 + 单页应用)
├── main.py # 根目录入口
├── core/ # 核心程序层 ├── core/ # 核心程序层
│ ├── __init__.py │ ├── __init__.py
│ ├── logger.py # 日志器(分文件、10MB 滚动) │ ├── logger.py # 日志器(分文件、10MB 滚动)
-43
View File
@@ -1,43 +0,0 @@
"""抖音养号命令行入口。
直接运行:python main.py
- 单设备跑一次养号,不走 web/调度
- 任务参数默认值在 tasks/douyin/task.py 顶部,可通过环境变量 WATCH_COUNT 覆盖视频数
web 管理后台请运行:python web_server.py
清理 STF 残留占用:python scripts/cleanup.py
目录结构:
config.py — 项目根配置(STF 地址/token、adb 路径、web 端口、数据目录)
bin/adb/ — adb.exe 及依赖 dll
core/ — 核心程序(STF 客户端、adb 工具、BaseWorker 基类、任务管理器)
tasks/ — 任务定义(每个 App 一个子包,自带参数默认值,继承 BaseTask + @register_task)
templates/ — 前端页面(admin/monitor.html 单页应用)
data/ — 持久化数据(SQLite:用户/分组/任务)
scripts/ — 实用脚本
"""
import os
from core.stf_client import STFClient
from tasks import get_task_class
def run_once(serial=None, **overrides):
"""单设备跑一次养号(命令行入口,不启动 web)。
走任务框架:取 douyin_nurture 任务类 → create_worker → 直接 run(当前线程)。
overrides 可传任务参数覆盖默认值,如 run_once(watch_count=20, like_enabled=False)。
"""
stf = STFClient()
task_cls = get_task_class("douyin_nurture")
task = task_cls()
params = dict(overrides)
# 命令行友好:环境变量 WATCH_COUNT 覆盖视频数
if "WATCH_COUNT" in os.environ and "watch_count" not in params:
params["watch_count"] = int(os.environ["WATCH_COUNT"])
worker = task.create_worker(stf, serial, params)
worker.run() # 直接在当前线程跑,不起子线程
if __name__ == "__main__":
run_once()
+1
View File
@@ -0,0 +1 @@
"""Agent 编排层:第三方 LLM(OpenAI 兼容)经 MCP 工具控制手机。"""
+291
View File
@@ -0,0 +1,291 @@
"""Agent 编排层:OpenAI 兼容模型(DeepSeek 等)经 MCP 工具控制手机。
支持两种运行模式:
- run_stream():流式(SSE 逐 token + 工具调用实时回调)——Web AI 控制台用
- run():非流式收集结果——CLI 用(内部调 run_stream)
流式细节(OpenAI 兼容):
- content/reasoning_content 增量逐 chunk 回调(kind 区分)
- tool_calls 分片累积(arguments 按 index 拼接),流结束后统一执行
- 截图(de_screenshot)图像转 image_url 追加下一轮,同时 on_tool 回调带缩略
"""
import base64
import json
import logging
import httpx
from fastmcp import Client
from mcp_agent.config import AgentSettings
_log = logging.getLogger("agent")
S = AgentSettings()
SYSTEM_PROMPT = """你是手机自动化控制助手。你通过工具实时操作 Android 手机。
工作规范:
1. 先 de_list_devices 确定目标设备(在线才可操作)
2. 观察屏幕:先 de_screenshot 获取截图(图像会随后给你),基于截图理解当前界面
3. 点击定位分优先级(不要自己推算像素坐标,那是精度最差的方式):
a) 目标有可见文字(按钮/菜单/列表标题/标签/输入框提示)→ de_tap_text 直接给文字,
一次完成「找到并点击」,原生控件与 WebView/图片渲染文字都支持
b) 文字有歧义或 de_tap_text 未命中 → de_ui_tree(limit=80) 看可点元素后
用 de_tap_element(text/text_contains 匹配)
c) 只有纯图形目标(视频画面/无文字图标且树里没有)才用 de_tap 给坐标——
坐标只需大致对准目标中心,服务端会自动吸附到该处可点击元素中心,无需精算
4. de_tap 点击后若返回 snapped=true 表示已吸附命中元素(可核对 label);
截图判断界面变化=点击成功,无变化=未命中
5. 输入文字:先 de_tap_text 或 de_tap 点中输入框,再 de_type_text 输入
6. 每次关键操作后再次 de_screenshot 验证结果,直到完成用户目标
7. 若点击后截图无任何变化:不要重复点同一坐标,换 de_tap_text/de_tap_element
重新定位,或先 de_ui_tree 确认元素文案再试
8. 完成或失败时用中文总结:做了什么、当前状态、需要用户注意的事项
9. 设备不可用/操作失败时如实报告错误,不要臆测成功
10. 效率:界面未变化时不要重复截图/点击同一位置;每步都要推进目标;
若连续 6 步无进展(截图内容未变/操作无效),停止并总结原因,不要空转
可用工具清单将由系统提供。"""
class Agent:
def __init__(self, settings: AgentSettings = None):
self.s = settings or S
self.tools_schema = [] # OpenAI function schema
self.messages = []
# 回调(Web 展示用,均可选):
# on_delta(text, kind) kind: content | reasoning —— 流式文本增量
# on_tool(step) step: {tool, args, result, image} —— 工具调用完成
self.on_delta = None
self.on_tool = None
self._mcp = None
# ---------- MCP 工具桥 ----------
async def _load_tools(self):
"""从 MCP Server 拉工具,转 OpenAI function schema。"""
# 短连接超时:MCP 不可用时快速失败(默认会无限重试卡死线程)
self._mcp = Client(self.s.mcp_url, timeout=10.0, init_timeout=10.0)
await self._mcp.__aenter__()
tools = await self._mcp.list_tools()
self.tools_schema = []
for t in tools:
# MCP SDK v2 改名 input_schema,兼容新旧字段
schema = getattr(t, "input_schema", None) or getattr(t, "inputSchema", {})
name = getattr(t, "name", "")
desc = getattr(t, "description", "") or ""
self.tools_schema.append({
"type": "function",
"function": {"name": name, "description": desc,
"parameters": schema}})
_log.info("MCP 工具已加载: %s", [s["function"]["name"] for s in self.tools_schema])
async def close(self):
if self._mcp:
try:
await self._mcp.__aexit__(None, None, None)
except Exception:
pass
# ---------- 模型调用(流式) ----------
async def _chat_stream(self):
"""流式 chat/completions:逐 chunk 产出 JSON(async generator)。"""
body = {
"model": self.s.model,
"messages": self.messages,
"tools": self.tools_schema if self.tools_schema else None,
"max_tokens": 4096,
"stream": True,
}
headers = {"Authorization": f"Bearer {self.s.api_key}",
"Content-Type": "application/json"}
url = f"{self.s.api_base.rstrip('/')}/chat/completions"
async with httpx.AsyncClient(timeout=self.s.request_timeout) as client:
async with client.stream("POST", url, json=body, headers=headers) as r:
if r.status_code != 200:
text = (await r.aread()).decode(errors="replace")
raise RuntimeError(f"模型 API HTTP {r.status_code}: {text[:300]}")
async for line in r.aiter_lines():
if not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
break
try:
yield json.loads(data)
except json.JSONDecodeError:
continue
# ---------- 工具执行 ----------
async def _execute_tool(self, name, arguments):
"""执行 MCP 工具,返回 (文本结果, image_data_or_None)。"""
args = json.loads(arguments) if isinstance(arguments, str) else (arguments or {})
_log.info("执行工具 %s %s", name, args)
try:
result = await self._mcp.call_tool(name, args)
data = getattr(result, "data", result)
except Exception as e:
return {"ok": False, "error": f"工具执行失败: {e}"}, None
# de_screenshot:图像分离(作为 image_url 追加给模型看 + on_tool 缩略展示)
image_b64 = None
text_result = data
if name == "de_screenshot" and isinstance(data, dict) and data.get("ok"):
img = (data.get("data") or {}).get("image") or {}
if img.get("data"):
text_result = {k: v for k, v in (data.get("data") or {}).items()
if k != "image"}
image_b64 = img["data"]
if self.on_tool:
try:
self.on_tool({"tool": name, "args": args,
"result": text_result, "image": image_b64})
except Exception:
pass
return text_result, image_b64
def _repair_tool_messages(self):
"""修复 tool_calls 配对不完整:从尾部移除「assistant 带 tool_calls 但其后
tool 回应不足」的消息段(流中断可能丢失分片,400 重试前自愈)。"""
for i in range(len(self.messages) - 1, -1, -1):
m = self.messages[i]
if m.get("role") == "assistant" and m.get("tool_calls"):
# 统计其后 tool 消息数是否匹配
need = len(m["tool_calls"])
have = sum(1 for x in self.messages[i + 1:]
if x.get("role") == "tool")
if have < need:
_log.warning("修复不完整 tool_calls 段(need=%d have=%d),回退 %d 条消息",
need, have, len(self.messages) - i)
self.messages = self.messages[:i]
return
# ---------- 主循环(流式) ----------
async def run_stream(self, prompt: str, serial: str = "",
history=None, on_delta=None, on_tool=None,
should_stop=None, extra_context=None):
"""流式执行一轮指令,返回最终完整文本。
history:上一轮的 [{"role": "user"|"assistant", "content": 文本}] 列表,
用于多轮对话保持上下文(截图/工具消息不入历史,控制 token)。
on_delta(text, kind):content/reasoning 文本增量(实时推给前端)
on_tool(step):工具调用完成(实时显示 MCP 步骤)
should_stop:可调用 fn() -> bool,每轮模型调用前检查(用户中断用)
extra_context:附加文本(经验记忆注入,放在 system prompt 末尾)
"""
self.on_delta = on_delta
self.on_tool = on_tool
target = serial or self.s.default_serial
sys_txt = SYSTEM_PROMPT
if target:
sys_txt += f"\n\n本次默认目标设备 serial:{target}(未指定设备时用它)。"
if extra_context:
sys_txt += f"\n\n## 过往成功经验参考(同类任务,可参考其中的操作套路,但要根据当前界面灵活调整)\n{extra_context}"
self.messages = [{"role": "system", "content": sys_txt}]
for h in (history or []):
if h.get("role") in ("user", "assistant") and h.get("content"):
self.messages.append({"role": h["role"], "content": h["content"]})
self.messages.append({"role": "user", "content": prompt})
for _step in range(self.s.max_steps):
if should_stop and should_stop():
_log.info("Agent 被用户中断")
return "(已按用户要求停止操作)"
content_parts = []
tool_acc = {} # index -> {id, name, args}
has_tool = False
retried = False
while True:
try:
async for chunk in self._chat_stream():
choice = (chunk.get("choices") or [{}])[0]
delta = choice.get("delta") or {}
text = delta.get("content")
if text:
content_parts.append(text)
if on_delta:
on_delta(text, "content")
rtext = delta.get("reasoning_content")
if rtext:
if on_delta:
on_delta(rtext, "reasoning")
for tc in delta.get("tool_calls") or []:
has_tool = True
idx = tc.get("index", 0)
acc = tool_acc.setdefault(idx, {"id": "", "name": "", "args": ""})
if tc.get("id"):
acc["id"] = tc["id"]
fn = tc.get("function") or {}
if fn.get("name"):
acc["name"] += fn["name"]
if fn.get("arguments"):
acc["args"] += fn["arguments"]
break
except RuntimeError as e:
# 流中断导致 tool_calls 分片丢失:修复后重试一次
if ("tool_calls" in str(e) or "must be followed" in str(e)) and not retried:
_log.warning("tool_calls 消息不完整,自愈重试")
self._repair_tool_messages()
retried = True
continue
raise
full_content = "".join(content_parts)
if has_tool:
# 组装 assistant 消息(含 tool_calls)并执行工具
tcs = []
for idx in sorted(tool_acc):
acc = tool_acc[idx]
tcs.append({"id": acc["id"] or f"call_{idx}",
"type": "function",
"function": {"name": acc["name"],
"arguments": acc["args"]}})
self.messages.append({"role": "assistant",
"content": full_content,
"tool_calls": tcs})
for tc in tcs:
fn = tc["function"]
text_result, image_b64 = await self._execute_tool(
fn["name"], fn["arguments"])
self.messages.append({
"role": "tool", "tool_call_id": tc["id"],
"content": json.dumps(text_result, ensure_ascii=False)[:4000]})
if image_b64:
self.messages.append({
"role": "user",
"content": [{"type": "text",
"text": "这是最新屏幕截图,请基于它继续判断"},
{"type": "image_url",
"image_url": {"url":
f"data:image/jpeg;base64,{image_b64}"}}]})
continue
# 无工具调用:本轮即最终回答
return full_content
# 步骤超限:不带工具让模型做最终总结(避免机械提示,给用户有意义的结论)
try:
_log.warning("达到最大步骤数,请求模型收尾总结")
saved_tools = self.tools_schema
self.tools_schema = []
self.messages.append({"role": "user",
"content": "已达最大操作步骤数,请立即用中文总结:"
"已完成的部分、当前设备状态、未能完成的原因与下一步建议。"
"不要调用任何工具。"})
parts = []
async for chunk in self._chat_stream():
delta = (chunk.get("choices") or [{}])[0].get("delta") or {}
text = delta.get("content")
if text:
parts.append(text)
if on_delta:
on_delta(text, "content")
self.tools_schema = saved_tools
summary = "".join(parts)
return summary or "(已达步骤上限,模型未能生成总结)"
except Exception as e:
return f"(已达最大步骤数,且收尾总结失败: {e})"
# ---------- 非流式(CLI) ----------
async def run(self, prompt: str, serial: str = "") -> str:
"""非流式执行,返回最终文本(CLI 用,内部走流式收集)。"""
return await self.run_stream(prompt, serial)
+60
View File
@@ -0,0 +1,60 @@
"""Agent CLI:命令行给 AI 下指令控制手机。
用法:
export AGENT_API_KEY=sk-xxx # DeepSeek API Key
export AGENT_DEFAULT_SERIAL=192.168.20.66:5555 # 可选:默认设备
python -m mcp_agent.cli "打开抖音,搜索奚学东,截个图"
python -m mcp_agent.cli -s 192.168.20.66:5555 "打开微信"
python -m mcp_agent.cli # 交互模式(exit 退出)
"""
import argparse
import asyncio
import logging
import os
from mcp_agent.agent import Agent
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)s [%(name)s] %(message)s")
_log = logging.getLogger("cli")
async def _run_once(prompt, serial):
if not os.environ.get("AGENT_API_KEY") and not os.environ.get("DEEPSEEK_API_KEY"):
print("❌ 未配置 API Key:export AGENT_API_KEY=sk-xxx")
return
agent = Agent()
try:
await agent._load_tools()
print(f"🤖 指令: {prompt}\n")
answer = await agent.run(prompt, serial)
print(f"\n✅ 结果:\n{answer}")
finally:
await agent.close()
def main():
ap = argparse.ArgumentParser(description="MCP 手机控制 Agent CLI")
ap.add_argument("prompt", nargs="?", default="", help="指令(不填则交互模式)")
ap.add_argument("-s", "--serial", default="", help="目标设备 serial")
args = ap.parse_args()
if args.prompt:
asyncio.run(_run_once(args.prompt, args.serial))
return
print("交互模式:输入指令(如「打开抖音搜索奚学东」),exit 退出")
while True:
try:
prompt = input("\n指令> ").strip()
except (EOFError, KeyboardInterrupt):
break
if not prompt:
continue
if prompt.lower() in ("exit", "quit", "退出"):
break
asyncio.run(_run_once(prompt, args.serial))
if __name__ == "__main__":
main()
+30
View File
@@ -0,0 +1,30 @@
"""Agent 层配置(第三方 LLM API,OpenAI 兼容格式)。
DeepSeek 官方 API:https://api.deepseek.com(OpenAI 兼容)。
生产用 .env 注入 DEEPSEEK_API_KEY,不要提交 git。
"""
import os
def _env(key, default):
return os.environ.get(key, default)
class AgentSettings:
# 模型 API(OpenAI 兼容)
api_base = _env("AGENT_API_BASE", "https://api.deepseek.com")
api_key = _env("AGENT_API_KEY", _env("DEEPSEEK_API_KEY", ""))
model = _env("AGENT_MODEL", "deepseek-v4-flash-vision-exp")
# MCP Server(工具源)
mcp_url = _env("AGENT_MCP_URL", "http://127.0.0.1:8033/mcp")
# 默认目标设备(命令行不指定 serial 时用它;空则让模型先 de_list_devices)
default_serial = _env("AGENT_DEFAULT_SERIAL", "")
# Agent 循环上限与请求超时
max_steps = int(_env("AGENT_MAX_STEPS", "40"))
request_timeout = float(_env("AGENT_TIMEOUT", "120"))
# system prompt 语言
language = _env("AGENT_LANG", "zh")
+1
View File
@@ -0,0 +1 @@
"""MCP 手机控制 Server 包。"""
+27
View File
@@ -0,0 +1,27 @@
"""审计日志:每次 MCP 调用记录一行(含只读)。"""
import json
import os
from datetime import datetime
_file = None
def init(path):
global _file
os.makedirs(os.path.dirname(path), exist_ok=True)
_file = open(path, "a", encoding="utf-8")
audit("system", "startup", "", "")
def audit(tool, serial, args_summary, result):
global _file
if _file is None:
return
rec = {"ts": datetime.now().isoformat(timespec="seconds"),
"tool": tool, "serial": serial,
"args": str(args_summary)[:200], "result": str(result)[:200]}
try:
_file.write(json.dumps(rec, ensure_ascii=False) + "\n")
_file.flush()
except Exception:
pass
+32
View File
@@ -0,0 +1,32 @@
"""MCP Server 配置(环境变量,生产用 .env 注入)。"""
import os
def _env(key, default):
return os.environ.get(key, default)
class Settings:
# 平台(auto_control)地址与账号
platform_url = _env("MCP_PLATFORM_URL", "http://127.0.0.1:18050")
platform_user = _env("MCP_PLATFORM_USER", "admin")
platform_pass = _env("MCP_PLATFORM_PASS", "")
# 安全
allow_write = _env("MCP_ALLOW_WRITE", "0") == "1" # 写操作门控(默认只读)
allowed_serials = [s.strip() for s in
_env("MCP_ALLOWED_SERIALS", "").split(",") if s.strip()] # 空=不限
# 传输
http_host = _env("MCP_HTTP_HOST", "0.0.0.0")
http_port = int(_env("MCP_HTTP_PORT", "8033"))
# 截图
screenshot_width = int(_env("MCP_SCREENSHOT_WIDTH", "540"))
jpeg_quality = int(_env("MCP_JPEG_QUALITY", "70"))
# 审计
audit_file = _env("MCP_AUDIT_FILE", "/var/log/mcp/audit.log")
# 平台请求超时(秒)
platform_timeout = float(_env("MCP_PLATFORM_TIMEOUT", "30"))
+110
View File
@@ -0,0 +1,110 @@
"""MCP 直连操作封装:adb/u2/OCR/剪贴板——轻量通道优先。
为什么直连而不是全部走平台 HTTP:
- adb monkey 打开 App / am force-stop:一条 adb 命令,不建 u2 连接(省时省 token)
- 输入文字:u2 EditText.set_text 直接设文本(比剪贴板+粘贴少依赖)
- OCR:平台 RapidOCR(本地模型,截图即识别)
平台 REST 无法表达的操作(无独立端点)在此封装;安全(白名单/写门控)
仍在 MCP 工具层统一把关。绝不 kill-server / 绝不 disconnect(项目红线)。
"""
import re
import subprocess
from config import ADB_PATH
_log = None # mcp 层 logging 由调用方配置
def _adb(serial, *args, timeout=25):
"""对指定设备执行 adb shell 命令,返回输出文本(超时返回空)。"""
try:
r = subprocess.run([ADB_PATH, "-s", serial, "shell", *args],
capture_output=True, timeout=timeout)
return (r.stdout or b"").decode("utf-8", errors="replace")
except subprocess.TimeoutExpired:
return ""
except Exception:
return ""
def open_app(serial, package):
"""adb monkey 打开 App(无需知道 activity,最轻量)。"""
out = _adb(serial, "monkey", "-p", package,
"-c", "android.intent.category.LAUNCHER", "1")
return out or ""
def stop_app(serial, package):
"""强制停止 App(am force-stop)。"""
return _adb(serial, "am", "force-stop", package)
def foreground_app(serial):
"""当前前台 App 包名(dumpsys window;mCurrentFocus 为空时 fallback mFocusedApp)。"""
out = _adb(serial, "dumpsys", "window", timeout=15) or ""
m = re.search(r"mCurrentFocus=.*?([\w.]+)/", out)
if m:
return m.group(1)
# 部分 MIUI 焦点在 IME/过渡时 mCurrentFocus=null,用 mFocusedApp 兜底
m2 = re.search(r"mFocusedApp=.*?([\w.]+)/", out)
return m2.group(1) if m2 else ""
def list_apps(serial, keyword=""):
"""第三方已装应用包名列表(pm list packages -3,可关键词过滤)。"""
out = _adb(serial, "pm", "list", "packages", "-3")
pkgs = []
for line in (out or "").splitlines():
p = line.replace("package:", "").strip()
if p and (not keyword or keyword.lower() in p.lower()):
pkgs.append(p)
return pkgs
def type_text(serial, text):
"""向当前界面输入框输入文字(u2 定位 EditText set_text,支持中文)。
返回 (ok, msg)。
"""
import uiautomator2 as u2
d = u2.connect(serial)
# 优先聚焦输入框;找不到则第一个 EditText(与任务 input_text 兜底一致)
try:
el = d(focused=True)
if el.exists:
el.set_text(text)
return True, "已输入到聚焦输入框"
except Exception:
pass
try:
edit = d(className="android.widget.EditText")
if edit.exists:
edit.set_text(text)
return True, "已输入到输入框"
except Exception as e:
return False, f"输入失败: {type(e).__name__}: {str(e)[:100]}"
return False, "未找到输入框(请先点击输入框或提供界面信息)"
def set_clipboard(serial, text):
"""剪贴板注入(ClipInject 通道,读回验证)。"""
from core.clipboard_helper import inject_clipboard
return inject_clipboard(serial, text)
def ocr(serial):
"""截屏 + RapidOCR 识别,返回 [{text, score, box}]。"""
import uiautomator2 as u2
from core.ocr import recognize
d = u2.connect(serial)
img = d.screenshot()
if img is None:
return []
return recognize(img)
def read_clipboard(serial):
"""读设备剪贴板(u2)。"""
import uiautomator2 as u2
d = u2.connect(serial)
return d.clipboard or ""
+520
View File
@@ -0,0 +1,520 @@
"""MCP 手机控制 Server(M0:设备列表/截图/点击/滑动)。
运行:MCP_ALLOW_WRITE=1 python -m mcp_server.mcp_server
客户端:Streamable HTTP @ http://<host>:8033/mcp
"""
import base64
import io
import logging
from fastmcp import FastMCP
from PIL import Image
from mcp_server import audit, config
from mcp_server.platform_client import PlatformClient, PlatformError
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)s [%(name)s] %(message)s")
_log = logging.getLogger("mcp")
S = config.Settings()
# 坐标空间缓存:serial -> (display_w, display_h, native_w, native_h)
# de_screenshot 返回的图与 de_tap/de_swipe 的坐标同一空间(display),
# server 按比例换算为设备原生坐标(模型只感知截图坐标系,不感知原生分辨率)。
_coord = {}
audit.init(S.audit_file)
mcp = FastMCP("mobile-control")
_platform = None
def platform():
"""惰性初始化平台客户端(启动即登录,失败明确报错)。"""
global _platform
if _platform is None:
_platform = PlatformClient(S.platform_url, S.platform_user,
S.platform_pass, S.platform_timeout)
return _platform
def _check_serial(serial):
"""白名单校验:未配置时限制为平台设备池(enabled)设备。"""
if not serial:
raise PlatformError("invalid_param", "缺少 serial")
if S.allowed_serials and serial not in S.allowed_serials:
raise PlatformError("device_not_allowed", f"设备 {serial} 不在白名单")
return serial
def _check_write():
if not S.allow_write:
raise PlatformError("write_disabled", "写操作未启用(MCP_ALLOW_WRITE=1 开启)")
# 设备任务占用锁:serial -> (ts, worker_status, task_job)。AI 写操作前检查,
# 任务 running/connecting 的设备拒绝操作(AI 不与任务抢设备)。5s 缓存。
_busy_cache = {}
def _ensure_device_free(serial):
"""写操作前检查设备是否有任务在跑(worker running/connecting → 拒绝)。"""
import time
now = time.time()
c = _busy_cache.get(serial)
if not c or now - c[0] > 5:
try:
devs = platform().list_devices()
info = next((d for d in devs if d.get("serial") == serial), {})
c = (now, info.get("worker_status") or "idle",
info.get("task_job") or "")
_busy_cache[serial] = c
except Exception:
return # 状态查询失败不阻塞(操作失败会另行报错)
if c[1] in ("running", "connecting"):
raise PlatformError(
"device_busy",
f"设备正在执行任务「{c[2] or '未知'}」——AI 不与任务抢设备,"
f"任务结束后才能操作(可在平台任务页先停止任务)")
def _to_native(serial, x, y):
"""截图坐标 → 设备原生坐标(按最近一次截图的比例换算)。"""
c = _coord.get(serial)
if not c:
raise PlatformError("invalid_param",
"请先对该设备执行 de_screenshot(需要建立坐标空间)")
dw, dh, nw, nh = c
return (round(x * nw / dw), round(y * nh / dh))
def _err(e: PlatformError):
return {"ok": False, "error": {"code": e.code, "message": e.message}}
def _ok(data):
return {"ok": True, "data": data}
@mcp.tool()
def de_list_devices() -> dict:
"""列出可控制设备:serial/在线状态/型号/任务状态/前台 App。
返回 [{serial, model, online, task_job, worker_status, foreground_app}]。
"""
try:
devs = platform().list_devices()
except PlatformError as e:
return _err(e)
out = []
for d in devs:
out.append({
"serial": d.get("serial"),
"model": d.get("model") or d.get("device_name") or "",
"online": bool(d.get("present")),
"task_job": d.get("task_job") or "",
"worker_status": d.get("worker_status") or "idle",
"foreground_app": d.get("foreground_app") or "",
})
audit.audit("de_list_devices", "", "", f"{len(out)} 台")
return _ok(out)
@mcp.tool()
def de_screenshot(serial: str) -> dict:
"""截取设备屏幕并返回图像(image/jpeg,宽 ≤540px)。
同时返回 {width, height, screen_state}。多模态客户端可直接看图。
"""
try:
serial = _check_serial(serial)
jpeg, screen_state = platform().screenshot(serial)
img = Image.open(io.BytesIO(jpeg))
w, h = img.size
if w > S.screenshot_width:
ratio = S.screenshot_width / w
img = img.resize((S.screenshot_width, int(h * ratio)))
buf = io.BytesIO()
img.convert("RGB").save(buf, "JPEG", quality=S.jpeg_quality)
data = base64.b64encode(buf.getvalue()).decode()
# 记录坐标空间(display=返回图尺寸,native=设备原生),供 tap/swipe 换算
nw, nh = platform().screen_size(serial)
_coord[serial] = (img.size[0], img.size[1], nw, nh)
except PlatformError as e:
return _err(e)
except Exception as e:
return _err(PlatformError("platform_unavailable", f"截图处理失败: {e}"))
audit.audit("de_screenshot", serial, f"{w}x{h}", "ok")
dw, dh, nw, nh = _coord[serial]
return _ok({"image": {"type": "image", "data": data,
"mimeType": "image/jpeg"},
"width": dw, "height": dh,
"native_size": {"width": nw, "height": nh},
"screen_state": screen_state})
@mcp.tool()
def de_tap(serial: str, x: int, y: int) -> dict:
"""点击设备屏幕指定坐标(坐标空间 = de_screenshot 的图像坐标)。
自动吸附:若该点落在某个可点击元素内,实际点击会改为该元素的中心——
坐标只需大致对准目标即可(模型视觉定位常有偏差,吸附保证点准);
点在空白处则按原坐标点击。返回中的 snapped/label 可核对吸附结果。
"""
try:
_check_write()
serial = _check_serial(serial)
_ensure_device_free(serial)
if x < 0 or y < 0:
raise PlatformError("invalid_param", "坐标不能为负")
nx, ny = _to_native(serial, x, y)
res = platform().tap(serial, nx, ny, snap=True)
except PlatformError as e:
return _err(e)
audit.audit("de_tap", serial,
f"({x},{y})->native({nx},{ny})"
+ (f" 吸附[{res.get('label')}]" if res.get("snapped") else ""),
"ok")
return _ok({"action": "tap", "serial": serial, "x": x, "y": y,
"snapped": bool(res.get("snapped")),
"label": res.get("label") or ""})
@mcp.tool()
def de_swipe(serial: str, x1: int, y1: int, x2: int, y2: int,
duration: float = 0.2) -> dict:
"""在设备屏幕上滑动(坐标空间同 de_tap:截图坐标,server 换算原生)。"""
try:
_check_write()
serial = _check_serial(serial)
_ensure_device_free(serial)
nx1, ny1 = _to_native(serial, x1, y1)
nx2, ny2 = _to_native(serial, x2, y2)
platform().swipe(serial, nx1, ny1, nx2, ny2, duration)
except PlatformError as e:
return _err(e)
audit.audit("de_swipe", serial, f"({x1},{y1})->({x2},{y2})", "ok")
return _ok({"action": "swipe", "serial": serial})
_KEYS = ("back", "home", "recent", "menu", "power", "volume_up",
"volume_down", "enter", "delete", "search", "camera")
@mcp.tool()
def de_ui_tree(serial: str, limit: int = 150) -> dict:
"""获取当前界面元素树(文本 JSON):每元素含 text/resource_id/description/class/bounds。
可点击元素排在前面(可点性优先)。多数场景不需要读整棵树——直接给
de_tap_text 一个屏幕上可见的文字即可自动定位点击;本工具用于确认界面
上有什么、元素文案是否与预想一致。limit 控制返回条数(默认 150,防 token 膨胀)。
"""
try:
serial = _check_serial(serial)
if limit < 1 or limit > 300:
raise PlatformError("invalid_param", "limit 需在 1-300 之间")
els = platform().ui_elements(serial)
except PlatformError as e:
return _err(e)
# 精简输出:去掉 suggested/深度噪音,保留可定位属性;可点击优先、有文案优先
slim = []
for e in els:
slim.append({
"text": e.get("text", "")[:50],
"id": e.get("resource_id", "")[:80],
"desc": e.get("description", "")[:50],
"class": e.get("class", "").split(".")[-1],
"clickable": e.get("clickable", "") == "true",
"bounds": e.get("bounds", ""),
})
slim.sort(key=lambda x: (not x["clickable"], not (x["text"] or x["desc"])))
audit.audit("de_ui_tree", serial, "", f"{len(slim)} 元素")
return _ok({"count": len(slim), "elements": slim[:limit]})
@mcp.tool()
def de_tap_element(serial: str, by: str, value: str, index: int = 1) -> dict:
"""按元素点击(不需要坐标):by=text|id|desc|text_contains|desc_contains。
text/id/desc 为精确匹配;text_contains/desc_contains 为子串模糊匹配
(只记得部分文字时用,如 by=text_contains value=搜索)。
元素驱动操作比坐标可靠(界面变化自适应);元素不存在时返回错误,
可改用 de_ui_tree 查元素 / de_tap_text 按屏幕文字点 / de_tap 坐标兜底。
index 用于多命中取第几个(默认 1)。
"""
try:
_check_write()
serial = _check_serial(serial)
_ensure_device_free(serial)
if by not in ("text", "id", "desc", "text_contains", "desc_contains"):
raise PlatformError("invalid_param",
"by 可选 text/id/desc/text_contains/desc_contains")
if not value or index < 1:
raise PlatformError("invalid_param", "value 不能为空且 index>=1")
import uiautomator2 as u2
d = u2.connect(serial)
kw = {"text": value} if by == "text" else (
{"resourceId": value} if by == "id" else (
{"description": value} if by == "desc" else (
{"textContains": value} if by == "text_contains"
else {"descriptionContains": value})))
if index > 1:
kw["instance"] = index - 1
el = d(**kw)
if not el.exists:
raise PlatformError("device_offline",
f"未找到元素({by}={value},index={index})——"
f"建议 de_ui_tree 查看实际元素或 de_tap 用坐标")
el.click()
except PlatformError as e:
return _err(e)
except Exception as e:
return _err(PlatformError("platform_unavailable",
f"元素点击失败: {type(e).__name__}: {str(e)[:100]}"))
audit.audit("de_tap_element", serial, f"{by}={value}#{index}", "ok")
return _ok({"action": "tap_element", "serial": serial,
"by": by, "value": value, "index": index})
@mcp.tool()
def de_read_clipboard(serial: str) -> dict:
"""读取设备当前剪贴板内容(ClipInject/atx-agent 通道读回,M1 起支持)。"""
try:
serial = _check_serial(serial)
import uiautomator2 as u2
d = u2.connect(serial)
text = d.clipboard
except PlatformError as e:
return _err(e)
except Exception as e:
return _err(PlatformError("platform_unavailable",
f"剪贴板读取失败: {type(e).__name__}: {str(e)[:100]}"))
audit.audit("de_read_clipboard", serial, "", f"{len(text or '')} 字符")
return _ok({"clipboard": text or ""})
@mcp.tool()
def de_wake(serial: str) -> dict:
"""点亮设备屏幕并解锁(熄屏时先调用它再截图)。"""
try:
_check_write()
serial = _check_serial(serial)
_ensure_device_free(serial)
platform().wake(serial)
except PlatformError as e:
return _err(e)
audit.audit("de_wake", serial, "", "ok")
return _ok({"action": "wake", "serial": serial})
@mcp.tool()
def de_press_key(serial: str, key: str) -> dict:
"""按设备按键:back/home/recent/menu/power/enter/delete 等。"""
try:
_check_write()
serial = _check_serial(serial)
_ensure_device_free(serial)
if key not in _KEYS:
raise PlatformError("invalid_param", f"不支持的按键: {key}(可选 {_KEYS})")
platform().press_key(serial, key)
except PlatformError as e:
return _err(e)
audit.audit("de_press_key", serial, key, "ok")
return _ok({"action": "press_key", "serial": serial, "key": key})
# ================== L2 扩展工具(轻量通道:adb/u2 直连,省 token) ==================
@mcp.tool()
def de_open_app(serial: str, package: str) -> dict:
"""打开 App(adb monkey 直启,最快路径)。package 为应用包名,如 com.ss.android.ugc.aweme。"""
try:
_check_write()
serial = _check_serial(serial)
_ensure_device_free(serial)
if not package:
raise PlatformError("invalid_param", "缺少包名")
from mcp_server import direct_ops
direct_ops.open_app(serial, package)
except PlatformError as e:
return _err(e)
except Exception as e:
return _err(PlatformError("platform_unavailable", f"打开失败: {str(e)[:100]}"))
audit.audit("de_open_app", serial, package, "ok")
return _ok({"action": "open_app", "package": package})
@mcp.tool()
def de_stop_app(serial: str, package: str) -> dict:
"""强制停止 App(am force-stop)。"""
try:
_check_write()
serial = _check_serial(serial)
_ensure_device_free(serial)
if not package:
raise PlatformError("invalid_param", "缺少包名")
from mcp_server import direct_ops
direct_ops.stop_app(serial, package)
except PlatformError as e:
return _err(e)
except Exception as e:
return _err(PlatformError("platform_unavailable", f"停止失败: {str(e)[:100]}"))
audit.audit("de_stop_app", serial, package, "ok")
return _ok({"action": "stop_app", "package": package})
@mcp.tool()
def de_foreground_app(serial: str) -> dict:
"""查询设备当前前台运行的 App 包名(轻量 dumpsys,不打扰设备)。"""
try:
serial = _check_serial(serial)
from mcp_server import direct_ops
pkg = direct_ops.foreground_app(serial)
except PlatformError as e:
return _err(e)
except Exception as e:
return _err(PlatformError("platform_unavailable", f"查询失败: {str(e)[:100]}"))
audit.audit("de_foreground_app", serial, "", pkg or "未知")
return _ok({"foreground_app": pkg or ""})
@mcp.tool()
def de_type_text(serial: str, text: str) -> dict:
"""向设备当前输入框输入文字(支持中文,直接 set_text 不依赖剪贴板)。"""
try:
_check_write()
serial = _check_serial(serial)
_ensure_device_free(serial)
if not text:
raise PlatformError("invalid_param", "内容为空")
from mcp_server import direct_ops
ok, msg = direct_ops.type_text(serial, text)
if not ok:
raise PlatformError("device_offline", msg)
except PlatformError as e:
return _err(e)
except Exception as e:
return _err(PlatformError("platform_unavailable", f"输入失败: {str(e)[:100]}"))
audit.audit("de_type_text", serial, f"{text[:30]}...", "ok")
return _ok({"action": "type_text"})
@mcp.tool()
def de_set_clipboard(serial: str, text: str) -> dict:
"""写入设备剪贴板(ClipInject 通道,读回验证)。"""
try:
_check_write()
serial = _check_serial(serial)
_ensure_device_free(serial)
if not text:
raise PlatformError("invalid_param", "内容为空")
from mcp_server import direct_ops
ok, msg = direct_ops.set_clipboard(serial, text)
if not ok:
raise PlatformError("device_offline", msg)
except PlatformError as e:
return _err(e)
except Exception as e:
return _err(PlatformError("platform_unavailable", f"注入失败: {str(e)[:100]}"))
audit.audit("de_set_clipboard", serial, f"{text[:30]}...", "ok")
return _ok({"action": "set_clipboard"})
@mcp.tool()
def de_sleep(serial: str) -> dict:
"""熄灭设备屏幕(运行中任务会中断,慎用)。"""
try:
_check_write()
serial = _check_serial(serial)
_ensure_device_free(serial)
platform().sleep(serial)
except PlatformError as e:
return _err(e)
audit.audit("de_sleep", serial, "", "ok")
return _ok({"action": "sleep"})
@mcp.tool()
def de_ocr(serial: str) -> dict:
"""OCR 识别当前屏幕文字(图片/画布/WebView 里 UI 树没有的文字也能识别)。
返回 [{text, score}]——搜屏幕关键词后可配合 de_tap_element/de_tap 操作。
"""
try:
serial = _check_serial(serial)
from mcp_server import direct_ops
results = direct_ops.ocr(serial)
except PlatformError as e:
return _err(e)
except Exception as e:
return _err(PlatformError("platform_unavailable", f"OCR 失败: {str(e)[:100]}"))
slim = [{"text": r["text"], "score": round(r["score"], 2)} for r in results]
audit.audit("de_ocr", serial, "", f"{len(slim)} 条")
return _ok({"count": len(slim), "texts": slim[:100]})
@mcp.tool()
def de_tap_text(serial: str, text: str) -> dict:
"""点击屏幕上显示该文字的位置(语义点击:一次调用完成「找到并点击」,无需坐标)。
想点带文字的按钮/列表项/标签/链接时用它:text 只需是屏幕上可见文字的
一部分(子串匹配,如「搜索」「立即购买」)。原生控件直接命中;
WebView/图片/画布里渲染的文字自动走 OCR 兜底。多命中点第一处(想点
更靠下的请把文字换独特些)。屏幕确实没有该文字时返回错误提示,
请截图确认后换关键词。比 de_tap 坐标点击可靠,涉及文字目标时优先使用。
"""
try:
_check_write()
serial = _check_serial(serial)
_ensure_device_free(serial)
if not text or len(text) > 100:
raise PlatformError("invalid_param", "text 不能为空且 ≤100 字符")
res = platform().tap_text(serial, text)
except PlatformError as e:
return _err(e)
if not res.get("found"):
err = PlatformError("text_not_found",
f"屏幕上未找到文字「{text}」——先 de_screenshot 看当前界面,"
f"换用屏幕上实际存在的文字;若文字在需滑动后才可见请先滑动")
audit.audit("de_tap_text", serial, f"「{text[:30]}」", "未找到")
return _err(err)
audit.audit("de_tap_text", serial,
f"「{text[:30]}」via {res.get('method')} @({res.get('x')},{res.get('y')})", "ok")
return _ok({"action": "tap_text", "serial": serial, "text": text,
"method": res.get("method"), "matched": res.get("matched") or text,
"x": res.get("x"), "y": res.get("y")})
@mcp.tool()
def de_list_apps(serial: str, keyword: str = "") -> dict:
"""列出设备第三方已装应用包名(可关键词过滤,如 keyword='douyin' 找抖音)。"""
try:
serial = _check_serial(serial)
from mcp_server import direct_ops
pkgs = direct_ops.list_apps(serial, keyword)
except PlatformError as e:
return _err(e)
except Exception as e:
return _err(PlatformError("platform_unavailable", f"查询失败: {str(e)[:100]}"))
audit.audit("de_list_apps", serial, keyword or "", f"{len(pkgs)} 个")
return _ok({"count": len(pkgs), "packages": pkgs[:200]})
@mcp.tool()
def de_list_tasks() -> dict:
"""列出平台任务计划(名称/启用状态/调度),供了解可自动化的任务。"""
try:
tasks = platform().list_tasks()
except PlatformError as e:
return _err(e)
audit.audit("de_list_tasks", "", "", f"{len(tasks)} 个")
return _ok({"tasks": tasks})
def main():
_log.info("MCP mobile-control 启动: allow_write=%s port=%s",
S.allow_write, S.http_port)
mcp.run(transport="http", host=S.http_host, port=S.http_port)
if __name__ == "__main__":
main()
+186
View File
@@ -0,0 +1,186 @@
"""平台(auto_control)HTTP 客户端:登录会话 + CSRF + API 封装。
会话失效自动重登;CSRF token 每次登录后获取,POST 必带。
只读接口(GET)与写接口(POST)分离封装,写操作由 MCP 门控层决定是否调用。
"""
import httpx
import logging
_log = logging.getLogger("mcp.platform")
_CSRF_HEADER = "X-CSRF-Token"
class PlatformError(Exception):
def __init__(self, code, message):
super().__init__(message)
self.code = code
self.message = message
class PlatformClient:
def __init__(self, url, user, password, timeout=30.0):
self.url = url.rstrip("/")
self.user = user
self.password = password
self._client = httpx.Client(base_url=self.url, timeout=timeout,
follow_redirects=True)
self._csrf = ""
self._login()
# ---------- 会话 ----------
def _login(self):
"""登录平台,拿会话 cookie + CSRF token。"""
r = self._client.post("/login", data={
"username": self.user, "password": self.password})
if r.status_code != 302 and r.status_code != 200:
raise PlatformError("platform_unavailable",
f"平台登录失败 HTTP {r.status_code}: {r.text[:120]}")
csrf = self._client.get("/api/csrf")
if csrf.status_code == 200:
self._csrf = (csrf.json() or {}).get("token", "")
_log.info("平台登录成功: %s", self.user)
def _ensure_session(self):
"""会话过期(401/403/302 到登录页)时重登。"""
r = self._client.get("/api/status")
if r.status_code in (401, 403) or r.url.path.endswith("/login"):
_log.warning("会话失效,重新登录")
self._login()
return True
return False
# ---------- 基础请求 ----------
def _get(self, path, **params):
self._ensure_session()
return self._client.get(path, params=params)
def _post(self, path, json=None):
self._ensure_session()
headers = {_CSRF_HEADER: self._csrf} if self._csrf else {}
return self._client.post(path, json=json or {}, headers=headers)
# ---------- 平台 API 封装(M0) ----------
def list_devices(self):
"""设备列表与状态(GET /api/status)。"""
r = self._get("/api/status")
if r.status_code != 200:
raise PlatformError("platform_unavailable",
f"/api/status HTTP {r.status_code}")
j = r.json() or {}
return j.get("devices", [])
def screenshot(self, serial):
"""截图(GET /api/screen/thumb),返回 (jpeg_bytes, screen_state)。"""
r = self._get("/api/screen/thumb", serial=serial)
if r.status_code == 503:
raise PlatformError("device_offline", r.text[:120])
if r.status_code != 200:
raise PlatformError("platform_unavailable",
f"截图 HTTP {r.status_code}")
return r.content, r.headers.get("X-Screen-State", "unknown")
def screen_size(self, serial):
"""屏幕原生分辨率(GET /api/screen/size),返回 (w, h)。"""
r = self._get("/api/screen/size", serial=serial)
if r.status_code == 503:
raise PlatformError("device_offline", r.text[:120])
if r.status_code != 200:
raise PlatformError("platform_unavailable",
f"分辨率 HTTP {r.status_code}")
j = r.json() or {}
if not j.get("ok"):
raise PlatformError("device_offline", str(j.get("error", "取分辨率失败"))[:120])
return int(j["width"]), int(j["height"])
def tap(self, serial, x, y, snap=False):
"""点击(POST /api/screen/tap)。
snap=True:点落在可点击元素内则吸附到元素中心(AI 粗略坐标也能点准)。
返回平台 JSON(含 snapped/x/y/label)。
"""
r = self._post("/api/screen/tap", json={"serial": serial,
"x": int(x), "y": int(y),
"snap": 1 if snap else 0})
return self._check_op(r, "tap")
def tap_text(self, serial, text):
"""按屏幕文字点击(平台解析:UI 树子串匹配 → OCR 兜底)。
返回 {ok, found, method, matched, x, y}——found=false 是业务结果
(屏幕无该文字),非设备错误;设备离线/不可达仍抛 PlatformError。
"""
r = self._post("/api/screen/tap_text",
json={"serial": serial, "text": str(text)})
if r.status_code == 503:
raise PlatformError("device_offline", r.text[:120])
if r.status_code != 200:
raise PlatformError("platform_unavailable",
f"tap_text HTTP {r.status_code}: {r.text[:120]}")
return r.json() or {}
def swipe(self, serial, x1, y1, x2, y2, duration=0.2):
"""滑动(POST /api/screen/swipe)。"""
r = self._post("/api/screen/swipe", json={
"serial": serial, "x1": int(x1), "y1": int(y1),
"x2": int(x2), "y2": int(y2),
"duration": float(duration)})
return self._check_op(r, "swipe")
def ui_elements(self, serial):
"""UI 元素树(GET /api/uiauto/elements,uiautodev 服务)。"""
r = self._get("/api/uiauto/elements", serial=serial)
if r.status_code == 503:
raise PlatformError("device_offline", r.text[:120])
if r.status_code != 200:
raise PlatformError("platform_unavailable",
f"元素树 HTTP {r.status_code}")
j = r.json() or {}
if not j.get("ok"):
raise PlatformError("device_offline", str(j.get("error", "取元素失败"))[:120])
return j.get("elements", [])
def wake(self, serial):
"""亮屏并解锁(POST /api/device/screen_all mode=on)。"""
r = self._post("/api/device/screen_all",
json={"mode": "on", "serials": [serial]})
return self._check_op(r, "wake")
def sleep(self, serial):
"""熄屏(POST /api/device/screen_all mode=off)。"""
r = self._post("/api/device/screen_all",
json={"mode": "off", "serials": [serial]})
return self._check_op(r, "sleep")
def list_tasks(self):
"""任务计划列表(GET /api/jobs)。"""
r = self._get("/api/jobs")
if r.status_code != 200:
raise PlatformError("platform_unavailable",
f"/api/jobs HTTP {r.status_code}")
j = r.json() or {}
tasks = []
for t in j.get("jobs") or []:
tasks.append({"id": t.get("id"), "name": t.get("name"),
"task_type": t.get("task_type"),
"enabled": t.get("enabled"),
"schedule": (t.get("schedule") or {}).get("mode", "")})
return tasks
def press_key(self, serial, key):
"""按键(POST /api/screen/key)。"""
r = self._post("/api/screen/key",
json={"serial": serial, "key": key})
return self._check_op(r, "key")
@staticmethod
def _check_op(r, name):
if r.status_code == 503:
raise PlatformError("device_offline", r.text[:120])
if r.status_code != 200:
raise PlatformError("platform_unavailable",
f"{name} HTTP {r.status_code}: {r.text[:120]}")
j = r.json() or {}
if not j.get("ok"):
raise PlatformError("device_offline", str(j.get("error", "操作失败"))[:120])
return j
+3
View File
@@ -0,0 +1,3 @@
fastmcp>=2.0
httpx>=0.27
Pillow>=10.0
+8 -3
View File
@@ -15,10 +15,10 @@ Flask-SQLAlchemy>=3.0,<4.0
# 定时任务调度 # 定时任务调度
APScheduler>=3.10,<4.0 APScheduler>=3.10,<4.0
# HTTP 客户端(STF API 调用) # HTTP 客户端
requests>=2.28 requests>=2.28
# SSH 远程执行(STF 重启 / STF 设备管理;.env 配 STF_SSH_PASSWORD 时走密码认证, # SSH 远程执行(预留运维用;.env 配 STF_SSH_PASSWORD 时走密码认证,
# 跨平台无需 sshpass;未配置则退回系统 ssh 免密密钥) # 跨平台无需 sshpass;未配置则退回系统 ssh 免密密钥)
paramiko>=3.0 paramiko>=3.0
@@ -29,8 +29,13 @@ uiautomator2>=3.0
uiautodev>=0.14 uiautodev>=0.14
# 屏幕 OCR(条件判断的 OCR识别 选择器,ONNX 推理,跨平台:Windows/Linux/macOS, # 屏幕 OCR(条件判断的 OCR识别 选择器,ONNX 推理,跨平台:Windows/Linux/macOS,
# 中英文模型随包内置;服务器无显示器环境建议把 opencv-python 换成 opencv-python-headless) # 中英文模型随包内置;服务器/容器环境必须用 headless 版 opencv——
# opencv-python 依赖 X11 库(libxcb),slim 容器里 import cv2 直接崩,OCR 步骤任务失败退出)
rapidocr_onnxruntime>=1.4 rapidocr_onnxruntime>=1.4
opencv-python-headless>=4.8,<5 # 锁定 4.x:5.x wheel 打包异常(无 cv2 模块)
# APK 元信息解析(应用管理功能:自动读取包名/版本/应用名) # APK 元信息解析(应用管理功能:自动读取包名/版本/应用名)
pyaxmlparser>=0.3.27 pyaxmlparser>=0.3.27
# MCP Server + AI 控制台 Agent(fastmcp:Streamable HTTP 服务端/客户端)
fastmcp>=2.0
-48
View File
@@ -1,48 +0,0 @@
"""清理 STF 上残留的设备占用(强杀进程导致没正常释放时用)。
运行:python scripts/cleanup.py
"""
import os
import sys
# 让脚本既能直接运行(python scripts/cleanup.py)又能作为模块运行。
# scripts/ 的上一级是项目根目录(core 包的父目录),需加入 sys.path。
_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _PROJECT_ROOT not in sys.path:
sys.path.insert(0, _PROJECT_ROOT)
import requests
from config import STF_URL, STF_TOKEN
headers = {"Authorization": f"Bearer {STF_TOKEN}"}
# /user/devices 返回当前用户正在占用的设备
resp = requests.get(f"{STF_URL}/api/v1/user/devices", headers=headers)
resp.raise_for_status()
data = resp.json()
mine = data.get("devices", [])
print(f"当前账户占用的设备数: {len(mine)}")
for d in mine:
print(f" - {d.get('serial')} using={d.get('using')}")
# 全部释放
for d in mine:
serial = d["serial"]
try:
requests.post(
f"{STF_URL}/api/v1/user/devices/{serial}/remoteDisconnect",
headers=headers,
)
except Exception:
pass
r = requests.delete(f"{STF_URL}/api/v1/user/devices/{serial}", headers=headers)
print(f"释放 {serial}: HTTP {r.status_code}")
# 再看一次总设备池
resp = requests.get(f"{STF_URL}/api/v1/devices", headers=headers)
resp.raise_for_status()
free = [d for d in resp.json().get("devices", [])
if d.get("present") and d.get("ready") and not d.get("using") and d.get("owner") is None]
print(f"\n清理后空闲设备数: {len(free)}")
for d in free:
print(f" - {d.get('serial')}")
-57
View File
@@ -1,57 +0,0 @@
@echo off
title Fix Web Access
:: Auto elevate to admin
net session >nul 2>&1
if %errorLevel% neq 0 (
echo Requesting admin privileges...
powershell -Command "Start-Process cmd -ArgumentList '/c ""%~f0""' -Verb RunAs"
exit /b
)
echo ============================================
echo Fix Web Backend Access
echo ============================================
echo.
:: 1. Disable residual system proxy (blocks localhost)
echo [1/4] Disabling system proxy...
reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings" /v ProxyEnable /t REG_DWORD /d 0 /f >nul 2>&1
reg delete "HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings" /v ProxyServer /f >nul 2>&1
echo Done.
echo.
:: 2. Add firewall rules for Python
echo [2/4] Adding firewall rules for Python...
for /f "delims=" %%i in ('where python') do set PYTHON_PATH=%%i
if not defined PYTHON_PATH (
echo ERROR: Python not found in PATH
pause
exit /b 1
)
echo Python path: %PYTHON_PATH%
netsh advfirewall firewall delete rule name="PythonWeb" >nul 2>&1
netsh advfirewall firewall add rule name="PythonWeb" dir=in action=allow program="%PYTHON_PATH%" enable=yes profile=any >nul
netsh advfirewall firewall delete rule name="PythonWebOut" >nul 2>&1
netsh advfirewall firewall add rule name="PythonWebOut" dir=out action=allow program="%PYTHON_PATH%" enable=yes profile=any >nul
echo Done.
echo.
:: 3. Flush DNS cache
echo [3/4] Flushing DNS cache...
ipconfig /flushdns >nul 2>&1
echo Done.
echo.
:: 4. Show excluded port ranges for reference
echo [4/4] TCP excluded port ranges (for reference):
netsh interface ipv4 show excludedportrange protocol=tcp
echo.
echo ============================================
echo Fix complete! Now:
echo 1. Close ALL browser windows and reopen
echo 2. Run: python web_server.py
echo 3. Open: http://127.0.0.1:18050/
echo ============================================
pause
+1 -1
View File
@@ -15,7 +15,7 @@ SKIP_EXTS = {".pyc", ".pyo"}
# 路径片段包含这些的跳过 # 路径片段包含这些的跳过
SKIP_PARTS = {"__pycache__"} SKIP_PARTS = {"__pycache__"}
# 文件名完全匹配这些的跳过 # 文件名完全匹配这些的跳过
SKIP_FILES = {"fix_web.bat"} SKIP_FILES = {}
# 文件名后缀匹配这些的跳过 # 文件名后缀匹配这些的跳过
SKIP_SUFFIX = (".migrated",) SKIP_SUFFIX = (".migrated",)
# 特定目录下特定后缀跳过 # 特定目录下特定后缀跳过
+167
View File
@@ -0,0 +1,167 @@
"""全量路由回归测试:启动后遍历所有 API 路由发请求,抓 500/异常。
用法(项目根目录):
.venv/bin/python scripts/regression_test.py
设计:
- 用 Flask test_client(不占用 18050,不影响运行中的服务)
- GET 路由全量请求(路径参数自动替换);流式/二进制接口跳过
- 写操作(POST/PUT/DELETE)发空 body 探测:期望 400/403/404(参数校验),
返回 500 = 代码缺陷(import 遗漏/引用错误),立即报出
- 关键业务 POST 用真实参数验证(亮屏/剪贴板/测试步骤/设备池增删)
- 退出码:有失败返回 1(可接入检查流程)
背景:web/ 蓝图拆分后多次出现 import 遗漏导致接口 500(ADB_PATH/_log/常量等),
每次都是线上报错才暴露。此脚本让这类问题在改动后 30 秒内现形。
"""
import os
import sys
import signal
# 项目根加入 sys.path(脚本在 scripts/ 下运行,保证可 import web_server/config)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# 测试模式:禁用 cron 调度器(否则 test_client 会按 DB 里的任务 cron 真跑任务,
# 干扰回归/占用设备——实测 ocr关键字任务每 30 分钟触发导致回归超时)
os.environ["DISABLE_SCHEDULER"] = "1"
# 单接口超时保护:adb 相关接口可能慢(连接/查询设备),给足时间
signal.alarm(300)
from web_server import app # noqa: E402 (导入即完成 app 装配,与生产一致)
# 流式/二进制接口:无法用普通 GET 断言,跳过(已有单独验证路径)
_SKIP_PREFIXES = (
"/api/screen/stream", "/api/screen/thumb",
"/api/uiauto/screenshot", "/api/device/screenshot",
)
# 关键业务 POST:用真实参数验证(这些覆盖了核心链路)
_KEY_POSTS = [
("一键亮屏", "/api/device/screen_all", {"mode": "on"}),
("一键息屏", "/api/device/screen_all", {"mode": "off"}),
("剪贴板注入", "/api/tools/clipboard/set",
{"serials": ["100.100.10.11:5555"], "text": "回归测试"}),
("测试步骤(wait)", "/api/steps/test",
{"serial": "100.100.10.11:5555",
"step": {"type": "wait", "label": "等待", "params": {"max": 1, "min": 1, "probability": 100}}}),
("adb 终端执行", "/api/adb/cmd", {"cmd": "adb devices"}),
("adb 红线拦截", "/api/adb/cmd", {"cmd": "adb kill-server"}),
("应用版本查询", "/api/tools/appver", {"pkg": "jp.co.cyberagent.stf"}),
]
# 红线接口:期望被拒绝(不是 ok)
_EXPECT_REJECT = {"/api/adb/cmd"}
def _fill_path_params(rule):
"""把路由路径参数 <xxx> 替换为测试值(serial 用真实设备)。"""
url = rule
url = url.replace("<serial>", "100.100.10.11:5555")
url = url.replace("<apk_id>", "x")
url = url.replace("<action_id>", "x")
url = url.replace("<device_id>", "x")
url = url.replace("<uid>", "1")
return url
def main():
c = app.test_client()
# 登录 + CSRF
r = c.post("/login", data={"username": "admin", "password": "admin123"})
if r.status_code != 302:
print("❌ 登录失败(请检查 admin 账号)")
sys.exit(1)
token = c.get("/api/csrf").get_json()["token"]
H = {"X-CSRF-Token": token}
fails = []
warns = []
passed = 0
# ========== 1. GET 全量 ==========
rules = sorted({r.rule for r in app.url_map.iter_rules()
if r.rule.startswith("/api") and "GET" in r.methods})
for rule in rules:
if rule.startswith(_SKIP_PREFIXES):
continue
url = _fill_path_params(rule)
try:
r = c.get(url)
if r.status_code == 500:
fails.append(f"GET {rule} -> HTTP 500(代码缺陷)")
elif r.status_code >= 502:
warns.append(f"GET {rule} -> HTTP {r.status_code}(业务错误,如未配置/设备离线)")
else:
passed += 1
except Exception as e:
fails.append(f"GET {rule} -> {type(e).__name__}: {e}")
print(f"GET 路由: {len(rules)} 个(跳过 {len([r for r in rules if r.startswith(_SKIP_PREFIXES)])} 个流式/二进制)")
# ========== 1.5 未登录访问(登录重定向链路) ==========
try:
r = c.get("/")
if r.status_code == 302 and "login" in (r.headers.get("Location") or ""):
passed += 1
elif r.status_code == 200:
passed += 1 # 已登录态也可能 200(测试客户端无 cookie 时走 302)
else:
fails.append(f"未登录访问 / -> HTTP {r.status_code}(应 302 到登录页)")
except Exception as e:
fails.append(f"未登录访问 / -> {type(e).__name__}: {e}")
# ========== 2. 写操作空 body 探测(抓 import 遗漏/引用错误) ==========
write_rules = sorted({r.rule for r in app.url_map.iter_rules()
if r.rule.startswith("/api")
and (r.methods & {"POST", "PUT", "DELETE"})})
for rule in write_rules:
url = _fill_path_params(rule)
try:
methods = {m for r2 in app.url_map.iter_rules() if r2.rule == rule for m in r2.methods}
if "DELETE" in methods:
r = c.delete(url, headers=H)
elif "PUT" in methods:
r = c.put(url, json={}, headers=H)
else:
r = c.post(url, json={}, headers=H)
# 空 body 期望 400(参数校验);403=权限/404=路径参数、500=代码缺陷
if r.status_code == 500:
fails.append(f"写操作 {rule} -> HTTP 500(空 body 应 400,500=代码缺陷)")
elif r.status_code >= 502:
warns.append(f"写操作 {rule} -> HTTP {r.status_code}(业务错误,如未配置/设备离线)")
else:
passed += 1
except Exception as e:
fails.append(f"写操作 {rule} -> {type(e).__name__}: {e}")
print(f"写操作路由: {len(write_rules)} 个(空 body 探测)")
# ========== 3. 关键业务 POST(真实参数) ==========
for name, url, body in _KEY_POSTS:
try:
r = c.post(url, json=body, headers=H)
j = r.get_json() if r.is_json else None
is_reject = url in _EXPECT_REJECT and "kill" in (body.get("cmd") or "")
good = (not j.get("ok")) if is_reject else bool(j and j.get("ok") is not False)
if good:
passed += 1
print(f" ✅ {name}: {r.status_code}")
else:
fails.append(f"关键 POST {name} -> HTTP {r.status_code} {str(j)[:80]}")
except Exception as e:
fails.append(f"关键 POST {name} -> {type(e).__name__}: {e}")
# ========== 汇总 ==========
print("-" * 50)
if warns:
print(f"⚠ 业务性提示 {len(warns)} 个(非代码缺陷):")
for w in warns:
print(" ", w)
if fails:
print(f"❌ 发现 {len(fails)} 个代码缺陷:")
for f in fails:
print(" ", f)
sys.exit(1)
print(f"✅ 全部通过({passed} 项检查,含 {len(rules)} GET + {len(write_rules)} 写操作探测 + {len(_KEY_POSTS)} 关键业务)")
if __name__ == "__main__":
main()
+81
View File
@@ -0,0 +1,81 @@
#!/bin/bash
# 容器启动脚本:依赖安装(仅首次/缺失时)+ 环境修复 + 启动 web_server。
# compose 的 python-app 服务 command 指向本脚本(不要在 compose 里再跑 pip,
# 否则每次容器重启都重装依赖——实测每轮重新下载 opencv 5.0(73MB) 并破坏 cv2)。
#
# 为什么需要环境修复(必须在 pip install 之后):
# rapidocr_onnxruntime 的依赖声明是 opencv-python(GUI 版),pip 解析依赖时
# 会强制装回它;而 GUI 版依赖 X11 库(libxcb),python:slim 容器里 import cv2
# 直接崩溃 → RapidOCR 不可用 → 条件判断的 OCR 步骤抛异常 → 任务失败退出。
# 因此在安装完 requirements 后,最后强制卸载 GUI 版并装 headless(幂等)。
# 本地 Mac 有 GUI 环境不受影响。
# ---- 依赖就绪守卫:flask/u2/uiautodev/rapidocr/cv2 全部可用则跳过安装 ----
# (容器重启秒级启动;只有首次部署或依赖缺失时才走完整安装)
python3 - <<'PY'
import importlib.util, sys
mods = ("flask", "flask_login", "flask_sqlalchemy", "apscheduler",
"uiautomator2", "uiautodev", "rapidocr_onnxruntime", "PIL",
"fastmcp")
if all(importlib.util.find_spec(m) for m in mods):
try:
import cv2
ok = hasattr(cv2, "__version__") and cv2.__file__ is not None
except Exception:
ok = False
sys.exit(0 if ok else 1)
sys.exit(1)
PY
if [ $? -ne 0 ]; then
echo "[start] 依赖缺失或 cv2 异常,执行安装与环境修复(首次启动较慢)..."
if [ -f requirements.txt ]; then
pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
fi
# 环境修复:最后强制 headless(覆盖 rapidocr 拉回的 GUI 版)
pip uninstall -y -q opencv-python 2>/dev/null || true
pip install -q -i https://pypi.tuna.tsinghua.edu.cn/simple "opencv-python-headless>=4.8,<5" # 5.x wheel 无 cv2 模块,锁定 4.x
# pip 24.0 解压 abi3 wheel 偶发丢根文件(site-packages/cv2 只剩子目录,
# cv2.abi3.so / __init__.py 缺失 → import cv2 成空壳,OCR 全挂)。
# 手动解压兜底在部分环境修不好(解压后仍无 cv2.__version__),
# 实测 pip --force-reinstall 完整安装有效(220 验证 cv2 4.14.0 恢复)。
python3 - <<'PY'
import subprocess, sys
def cv2_ok():
try:
import cv2
return hasattr(cv2, "__version__") and cv2.__file__ is not None
except Exception:
return False
if cv2_ok():
sys.exit(0)
print("cv2 异常,pip 强制重装 opencv-python-headless...")
r = subprocess.run(["pip", "install", "--force-reinstall", "-q",
"-i", "https://pypi.tuna.tsinghua.edu.cn/simple",
"opencv-python-headless>=4.8,<5"])
if r.returncode == 0:
import cv2
print(f"cv2 {cv2.__version__} 重装修复完成")
sys.exit(r.returncode)
PY
else
echo "[start] 依赖已就绪(cv2 $(python3 -c 'import cv2; print(cv2.__version__)' 2>/dev/null)),跳过安装"
fi
# 启动 MCP server(后台,AI 控制台/外部客户端经 127.0.0.1:8033 调用)。
# web_server.py 内 agent 依赖它,容器重启必须自动拉起(MCP_ENABLED=0 可关)。
# 平台账号:MCP_PLATFORM_USER 默认 admin;密码优先取环境变量(compose/.env),
# 兜底 admin123 与部署时的手动启动参数一致。
if [ -f mcp_server/mcp_server.py ] && [ "${MCP_ENABLED:-1}" = "1" ]; then
echo "[start] 启动 MCP server..."
MCP_ALLOW_WRITE=1 \
MCP_PLATFORM_USER="${MCP_PLATFORM_USER:-admin}" \
MCP_PLATFORM_PASS="${MCP_PLATFORM_PASS:-admin123}" \
MCP_AUDIT_FILE="${MCP_AUDIT_FILE:-/tmp/mcp_audit.log}" \
python3 -m mcp_server.mcp_server > /tmp/mcp_server.log 2>&1 &
fi
exec python -u web_server.py
-34
View File
@@ -1,34 +0,0 @@
@echo off
chcp 65001 >nul 2>&1
title 设备自动化后台(局域网模式)
REM ===== 自动提权到管理员 =====
net session >nul 2>&1
if %errorlevel% neq 0 (
echo 需要管理员权限以支持局域网访问,正在提权...
powershell -Command "Start-Process -FilePath '%~f0' -Verb RunAs"
exit /b
)
cd /d "%~dp0"
REM ===== 添加防火墙入站规则(放行 Python,仅一次) =====
for /f "delims=" %%i in ('where python') do set PY_EXE=%%i
netsh advfirewall firewall show rule name="PlatformTools Web" >nul 2>&1
if %errorlevel% neq 0 (
echo 正在添加防火墙规则...
netsh advfirewall firewall add rule name="PlatformTools Web" dir=in action=allow program="%PY_EXE%" enable=yes >nul 2>&1
netsh advfirewall firewall add rule name="PlatformTools Web" dir=out action=allow program="%PY_EXE%" enable=yes >nul 2>&1
)
echo ============================================
echo 管理后台已启动(局域网可访问)
echo 本机访问: http://127.0.0.1:18050/
echo 局域网访问: http://本机IP:18050/
echo 默认账号: admin / admin123
echo 按 Ctrl+C 停止
echo ============================================
echo.
python web_server.py
pause
+220
View File
@@ -0,0 +1,220 @@
// 管理 Tab:分组/日志/用户
// ================== Tab 3: 分组 ==================
async function loadGroups(){
const r=await apiGet('/api/groups');if(!r)return;
_groupsList=r.groups||[];
const dr=await apiGet('/api/devices');
_devicesList=dr?dr.devices||[]:[];
renderGroupTable();
}
function renderGroupTable(){
const groups=_groupsList||[];
setListPager('groups',{
data:()=>groups,
filter:(g,kw)=>((g.name||'').toLowerCase().includes(kw)||(g.description||'').toLowerCase().includes(kw)||(g.serials||[]).join(' ').toLowerCase().includes(kw)),
sortValue:(g,k)=>{ if(k==='count')return (g.serials||[]).length; return g[k]||''; },
render:renderGroupRow,
tbody:'tb-groups',pager:'pager-groups',pageSize:10,
empty:'<tr><td colspan="5" class="empty">暂无分组,点击"新建分组"创建</td></tr>'
});
applyListPager('groups');
initListSearch('groups','search-groups');
}
function renderGroupRow(g){
const serials=g.serials||[];
return '<tr>'+
'<td><strong>'+esc(g.name)+'</strong></td>'+
'<td>'+esc(g.description||'-')+'</td>'+
'<td>'+serials.length+'</td>'+
'<td class="text-muted" style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+(serials.length?serials.map(esc).join(', '):'-')+'</td>'+
'<td>'+
(_can('tasks')?(
'<button class="btn btn-xs" onclick="openGroupModal(\''+esc(g.name)+'\')">编辑</button>'+
'<button class="btn btn-danger btn-xs" onclick="deleteGroup(\''+esc(g.name)+'\')">删除</button>'
):'<span class="text-muted">只读</span>')+
'</td>'+
'</tr>';
}
function openGroupModal(name){
const group=name?(_groupsList||[]).find(g=>g.name===name):null;
const isEdit=!!group;
document.getElementById('modal-title').textContent=isEdit?'编辑分组':'新建分组';
const g=group||{name:'',description:'',serials:[]};
const current= new Set(g.serials||[]);
const allSerials=[...new Set([..._devicesList, ...(g.serials||[])])].sort();
const deviceCheckboxes=allSerials.map(s=>
'<label><input type="checkbox" value="'+esc(s)+'" '+(current.has(s)?'checked':'')+'>'+esc(s)+'</label>'
).join('');
document.getElementById('modal-body').innerHTML=
'<div class="form-group"><label>分组名</label><input type="text" class="form-control" id="g-name" value="'+esc(g.name)+'" placeholder="如:测试组" '+(isEdit?'readonly':'')+'></div>'+
'<div class="form-group"><label>描述</label><input type="text" class="form-control" id="g-description" value="'+esc(g.description||'')+'" placeholder="可选"></div>'+
'<div class="form-group"><label>设备列表(勾选属于此分组的设备)</label>'+
'<div class="device-list">'+(deviceCheckboxes||'<div class="text-muted">暂无在线设备</div>')+'</div>'+
'<div class="help">仅显示当前在线设备,已选设备即使离线也会保留</div>'+
'</div>';
document.getElementById('modal-footer').innerHTML=
'<button class="btn" onclick="closeModal()">取消</button>'+
'<button class="btn btn-primary" onclick="saveGroup('+(isEdit?'\''+esc(g.name)+'\'':'null')+')">'+(isEdit?'保存':'创建')+'</button>';
showModal();
}
async function saveGroup(name){
const newName=document.getElementById('g-name').value.trim();
if(!newName){showToast('分组名不能为空','error');return;}
const desc=document.getElementById('g-description').value;
const serials=Array.from(document.querySelectorAll('.device-list input:checked')).map(c=>c.value);
const data={name:newName,description:desc,serials:serials};
if(name){
const r=await apiPut('/api/groups/'+encodeURIComponent(name),data);
handleResult(r,()=>{closeModal();loadGroups();});
}else{
const r=await apiPost('/api/groups',data);
handleResult(r,()=>{closeModal();loadGroups();});
}
}
async function deleteGroup(name){
if(!confirm('确认删除分组"'+name+'"吗?'))return;
const r=await apiDelete('/api/groups/'+encodeURIComponent(name));
handleResult(r,()=>loadGroups());
}
// ================== Tab 4: 日志 ==================
async function loadLogs(){
const file=document.getElementById('log-file').value;
const lines=document.getElementById('log-lines').value;
const r=await apiGet('/api/logs?file='+encodeURIComponent(file)+'&lines='+lines);
if(!r||!r.ok)return;
const content=r.content||'';
// 简单着色
const colored=esc(content)
.replace(/\[ERROR\]/g,'<span class="log-err">[ERROR]</span>')
.replace(/\[WARNING\]/g,'<span class="log-warn">[WARNING]</span>')
.replace(/\[INFO\]/g,'<span class="log-info">[INFO]</span>');
document.getElementById('log-content').innerHTML=colored||'<span class="text-muted">(空)</span>';
document.getElementById('log-hint').textContent=file+' · '+content.split('\n').length+' 行';
// 自动滚动到底部
const el=document.getElementById('log-content');
el.scrollTop=el.scrollHeight;
}
function toggleLogAuto(){
if(_logTimer){clearInterval(_logTimer);_logTimer=null;}
if(document.getElementById('log-auto').checked && _activeTab==='logs'){
_logTimer=setInterval(loadLogs,3000);
}
}
// ================== Tab 5: 用户 ==================
async function loadUsers(){
const r=await apiGet('/api/users');if(!r)return;
const users=r.users||[];
setListPager('users',{
data:()=>users,
filter:(u,kw)=>(u.username||'').toLowerCase().includes(kw),
sortValue:(u,k)=>{ if(k==='is_admin')return u.is_admin?1:0; return u[k]||''; },
render:renderUserRow,
tbody:'tb-users',pager:'pager-users',pageSize:10,
empty:'<tr><td colspan="5" class="empty">暂无用户</td></tr>'
});
applyListPager('users');
initListSearch('users','search-users');
}
function renderUserRow(u){
const admin=u.is_admin?'<span class="label label-primary">管理员</span>':'<span class="label label-default">普通</span>';
const perms=(u.perms||[]).map(p=>'<span class="label label-info">'+(PERM_LABELS[p]||p)+'</span>').join(' ')
||'<span class="text-muted">-</span>';
return '<tr>'+
'<td>'+u.id+'</td>'+
'<td><strong>'+esc(u.username)+'</strong></td>'+
'<td>'+admin+'</td>'+
'<td>'+perms+'</td>'+
'<td>'+
'<button class="btn btn-xs" onclick="openUserModal('+u.id+')">编辑</button>'+
(u.username==='admin'?'':'<button class="btn btn-danger btn-xs" onclick="deleteUser('+u.id+')">删除</button>')+
'</td>'+
'</tr>';
}
function togglePermBoxes(){
// 勾选"管理员权限"时:权限位全选并禁用(管理员拥有全部权限)
const isAdmin=document.getElementById('u-is_admin').checked;
document.querySelectorAll('.u-perm').forEach(cb=>{
cb.disabled=isAdmin;
if(isAdmin)cb.checked=true;
});
}
function openUserModal(uid){
const isEdit=!!uid;
document.getElementById('modal-title').textContent=isEdit?'编辑用户':'新建用户';
const permBoxes=Object.keys(PERM_LABELS).map(p=>
'<label style="margin-right:12px"><input type="checkbox" class="u-perm" value="'+p+'">'+PERM_LABELS[p]+'</label>'
).join('');
document.getElementById('modal-body').innerHTML=
'<div class="form-group"><label>用户名</label><input type="text" class="form-control" id="u-username" '+(isEdit?'disabled':'')+' placeholder="用户名"></div>'+
'<div class="form-group"><label>密码'+(isEdit?'(留空不修改)':'')+'</label><input type="password" class="form-control" id="u-password" placeholder="密码"></div>'+
'<div class="form-group form-check"><input type="checkbox" id="u-is_admin" onchange="togglePermBoxes()" checked><label for="u-is_admin">管理员权限(拥有全部权限)</label></div>'+
'<div class="form-group"><label>业务权限(管理员自动拥有全部,无需勾选)</label>'+
'<div>'+permBoxes+'</div>'+
'<div class="help">任务管理=任务/自定义动作/分组;设备控制=停止/释放/清异常/扫描/抓元素;应用管理=APK上传安装;日志查看=日志页</div>'+
'</div>';
document.getElementById('modal-footer').innerHTML=
'<button class="btn" onclick="closeModal()">取消</button>'+
'<button class="btn btn-primary" onclick="saveUser('+(uid||'null')+')">'+(isEdit?'保存':'创建')+'</button>';
if(isEdit){
// 预填充(从表格数据获取)
apiGet('/api/users').then(r=>{
const u=(r&&r.users||[]).find(x=>x.id===uid);
if(u){
document.getElementById('u-username').value=u.username;
document.getElementById('u-is_admin').checked=u.is_admin;
document.querySelectorAll('.u-perm').forEach(cb=>{
cb.checked=(u.perms||[]).includes(cb.value);
});
togglePermBoxes();
}
});
}else{
togglePermBoxes();
}
showModal();
}
async function saveUser(uid){
const username=document.getElementById('u-username').value.trim();
const password=document.getElementById('u-password').value;
const is_admin=document.getElementById('u-is_admin').checked;
const perms=[...document.querySelectorAll('.u-perm:checked')].map(cb=>cb.value);
if(uid){
const data={is_admin:is_admin,perms:perms};
if(password)data.password=password;
const r=await apiPut('/api/users/'+uid,data);
handleResult(r,()=>{closeModal();loadUsers();});
}else{
if(!username||!password){showToast('用户名和密码不能为空','error');return;}
const r=await apiPost('/api/users',{username:username,password:password,is_admin:is_admin,perms:perms});
handleResult(r,()=>{closeModal();loadUsers();});
}
}
async function deleteUser(uid){
if(!confirm('确认删除此用户吗?'))return;
const r=await apiDelete('/api/users/'+uid);
handleResult(r,()=>loadUsers());
}
// ================== 初始化 ==================
initCsrf(); // 获取 CSRF token(非 GET 请求需携带)
loadMe(); // 获取当前用户权限,隐藏无权限的功能入口
showTab('monitor');
+586
View File
@@ -0,0 +1,586 @@
// AI 控制台(顶级 Tab):DeepSeek 风格聊天 + 流式输出 + 实时 MCP 步骤
let _agentBusy = false;
let _agentStream = null; // EventSource
let _agentCfgLoaded = false;
// ================== 初始化 ==================
let _agentDefaultSerial = '';
function initAgentChat(){
if(_agentCfgLoaded)return;
_agentCfgLoaded = true;
loadAgentConfig();
const chat = document.getElementById('agent-chat');
if(chat && !chat.children.length){
chat.innerHTML = '<div class="agent-empty">👋 给 AI 下达指令,它将通过截图观察手机并执行操作。<br>'
+ '例如:「打开抖音搜索奚学东,告诉我第一个视频的标题」<br>'
+ '先选「🎯 目标设备」(AI 只操作你选定的设备),点击右上角 ⚙ 配置模型与 API Key。</div>';
}
bindChatScroll();
loadLiveDevices();
loadAgentTargetDevices();
startRunPoll();
renderConvList(); // 会话列表(决定当前会话)
loadCurrentConvMessages(); // 渲染当前会话历史
restoreRunningFlow(); // 若任务运行中:重订阅事件流
// 输入框快捷键
const inp = document.getElementById('agent-input');
inp.addEventListener('keydown', ev=>{
if(ev.key==='Enter' && !ev.shiftKey){
ev.preventDefault();
if(!_agentBusy) sendAgentMsg();
}
});
}
// ================== 页面刷新/重进恢复(运行中任务续流) ==================
// 历史轮次由 loadCurrentConvMessages 从会话渲染;这里只处理「任务仍在后台跑」:
// 重新订阅事件流(服务端队列保留积压,重连后补发 delta/step/done)。
function restoreRunningFlow(){
apiGet('/api/agent/run').then(r=>{
if(!r||!r.ok) return;
if(r.state === 'running' && r.run_id){
setRunning(true);
listenStream(r.run_id);
}else if(r.state === 'error' && r.error && _currentConvId){
// 错误提示仅当本会话相关时展示
const chat = document.getElementById('agent-chat');
if(chat && !chat.children.length){
const div = newAssistantMsg();
div.querySelector('.agent-text').textContent = '⚠ ' + r.error;
}
}
});
}
// ================== 配置 ==================
function loadAgentConfig(){
apiGet('/api/agent/config').then(r=>{
if(!r||!r.ok)return;
const tag = document.getElementById('agent-model-tag');
if(tag && r.model) tag.textContent = r.model;
_agentDefaultSerial = r.default_serial || '';
});
}
// ================== 目标设备选择(AI 只操作选定设备) ==================
function loadAgentTargetDevices(){
apiGet('/api/agent/devices').then(r=>{
if(!r||!r.ok)return;
const sel = document.getElementById('agent-target-select');
if(!sel)return;
const cur = sel.value;
const devs = (r.devices||[]).filter(x=>x.online);
sel.innerHTML = '<option value="">请选择设备(AI 只操作选定设备)…</option>'
+ devs.map(d=>{
const label = d.serial + (d.model ? ' · ' + d.model : '')
+ (d.busy ? ' ⛔ 任务中:' + d.task_job : '');
return '<option value="'+esc(d.serial)+'"' + (d.busy ? ' disabled' : '')
+ '>' + esc(label) + '</option>';
}).join('');
// 保留当前选择;否则预选配置的默认设备
if(cur && [...sel.options].some(o=>o.value===cur)) sel.value = cur;
else if(_agentDefaultSerial && [...sel.options].some(o=>o.value===_agentDefaultSerial))
sel.value = _agentDefaultSerial;
const hint = document.getElementById('agent-target-hint');
if(hint && sel.value) hint.textContent = '将操作:' + sel.value;
});
}
// ================== 跨窗口运行状态(多人/多窗口可见并可停止) ==================
let _runPoll = null;
function startRunPoll(){
if(_runPoll) return;
_runPoll = setInterval(pollRunState, 8000);
}
function pollRunState(){
if(_agentStream) return; // 本窗口正在跑(事件流驱动),不轮询
apiGet('/api/agent/run').then(r=>{
if(!r||!r.ok)return;
const running = r.state === 'running';
document.getElementById('agent-running-tag').style.display = running ? 'inline' : 'none';
document.getElementById('btn-agent-stop').style.display = running ? 'inline-block' : 'none';
const hint = document.getElementById('agent-target-hint');
if(hint){
hint.textContent = running
? ('⏳ 运行中' + (r.started ? ' ' + r.started + ' 起' : '')
+ (r.serial ? ' · ' + r.serial : '')
+ ':' + (r.prompt||'').slice(0,70))
: (document.getElementById('agent-target-select').value
? '将操作:' + document.getElementById('agent-target-select').value : '');
}
document.getElementById('btn-agent-send').disabled = running;
if(!running && _agentBusy){ setRunning(false); } // 流异常丢失时复位
});
}
function openAgentConfig(){
apiGet('/api/agent/config').then(r=>{
if(!r||!r.ok)return;
document.getElementById('acfg-base').value = r.api_base || 'https://api.deepseek.com';
document.getElementById('acfg-model').value = r.model || '';
document.getElementById('acfg-serial').value = r.default_serial || '';
document.getElementById('acfg-steps').value = r.max_steps || 40;
document.getElementById('acfg-key-hint').textContent =
r.api_key_masked ? ('已配置 ' + r.api_key_masked) : '未配置';
document.getElementById('acfg-key').value = '';
document.getElementById('agent-cfg-overlay').style.display = 'flex';
});
}
function closeAgentConfig(){
document.getElementById('agent-cfg-overlay').style.display = 'none';
}
function saveAgentConfig(){
const body = {
api_base: document.getElementById('acfg-base').value.trim(),
model: document.getElementById('acfg-model').value.trim(),
default_serial: document.getElementById('acfg-serial').value.trim(),
max_steps: document.getElementById('acfg-steps').value.trim(),
};
const key = document.getElementById('acfg-key').value.trim();
if(key) body.api_key = key;
apiPost('/api/agent/config', body).then(r=>{
if(r&&r.ok){
showToast('配置已保存','success');
closeAgentConfig();
loadAgentConfig();
loadAgentTargetDevices();
}else showToast('保存失败: ' + ((r&&r.error)||''),'error');
});
}
// ================== 聊天渲染 ==================
function addUserMsg(text){
const chat = document.getElementById('agent-chat');
clearEmpty();
const div = document.createElement('div');
div.className = 'agent-msg user';
div.textContent = text;
chat.appendChild(div);
scrollChat();
}
function newAssistantMsg(){
const chat = document.getElementById('agent-chat');
clearEmpty();
const div = document.createElement('div');
div.className = 'agent-msg assistant';
div.innerHTML = '<div class="agent-toolcards"></div><div class="agent-text"></div>';
chat.appendChild(div);
scrollChat();
return div;
}
function clearEmpty(){
const empty = document.querySelector('#agent-chat .agent-empty');
if(empty) empty.remove();
}
let _chatPinned = true; // 用户是否在底部(贴底才自动跟随滚动)
function scrollChat(force){
const chat = document.getElementById('agent-chat');
if(force || _chatPinned){
chat.scrollTop = chat.scrollHeight;
}
}
function bindChatScroll(){
const chat = document.getElementById('agent-chat');
chat.addEventListener('scroll', ()=>{
_chatPinned = chat.scrollHeight - chat.scrollTop - chat.clientHeight < 60;
});
}
function clearAgentChat(){
document.getElementById('agent-chat').innerHTML = '';
}
// ================== 历史会话(DeepSeek 式:左侧列表,多会话持久化) ==================
let _currentConvId = localStorage.getItem('agent_conv_id') || '';
function saveConvId(id){
_currentConvId = id || '';
if(id) localStorage.setItem('agent_conv_id', id);
else localStorage.removeItem('agent_conv_id');
}
function createAgentSession(){
if(_agentBusy){showToast('运行中不能新建会话','error');return;}
apiPost('/api/agent/conversations',{}).then(r=>{
if(r&&r.ok){
saveConvId(r.id);
clearAgentChat();
showToast('已新建会话','success');
renderConvList();
}else showToast('新建失败: '+((r&&r.error)||''),'error');
});
}
function selectAgentSession(id){
if(_agentBusy){showToast('运行中不能切换会话','error');return;}
if(id === _currentConvId) return;
saveConvId(id);
clearAgentChat();
loadCurrentConvMessages();
renderConvList();
}
function deleteAgentSession(id){
if(!confirm('删除该会话?历史消息将不可恢复。')) return;
apiDelete('/api/agent/conversations/'+id).then(r=>{
if(r&&r.ok){
if(_currentConvId === id) saveConvId('');
showToast('会话已删除','success');
renderConvList();
loadCurrentConvMessages();
}else showToast('删除失败: '+((r&&r.error)||''),'error');
});
}
function renderConvList(){
apiGet('/api/agent/conversations').then(r=>{
if(!r||!r.ok)return;
const list = document.getElementById('agent-sess-list');
if(!list)return;
const convs = r.conversations || [];
let cur = _currentConvId;
if(!convs.some(c=>c.id===cur)) cur = convs.length ? convs[0].id : '';
if(cur !== _currentConvId){ saveConvId(cur); clearAgentChat(); loadCurrentConvMessages(); }
if(!convs.length){
list.innerHTML = '<div class="agent-empty" style="padding:20px 10px;font-size:12px">'
+ '暂无历史会话<br><br>点「+ 新建会话」开始</div>';
return;
}
list.innerHTML = convs.map(c=>{
const t = c.title || '新会话';
return '<div class="agent-sess-item'+(c.id===cur?' active':'')
+'" onclick="selectAgentSession(\''+c.id+'\')">'
+'<button class="agent-sess-del" title="删除会话" '
+'onclick="event.stopPropagation();deleteAgentSession(\''+c.id+'\')">✕</button>'
+'<div class="t">'+esc(t)+'</div>'
+'<div class="m">'+esc(c.updated_at||'')+(c.count ? ' · '+c.count+' 轮' : '')+'</div></div>';
}).join('');
});
}
function loadCurrentConvMessages(){
const chat = document.getElementById('agent-chat');
if(!chat) return;
if(!_currentConvId){
chat.innerHTML = '<div class="agent-empty">👋 点左侧「+ 新建会话」开始,'
+ '给 AI 下达指令(先选 🎯 目标设备)。</div>';
return;
}
apiGet('/api/agent/conversations/'+_currentConvId).then(r=>{
if(!r||!r.ok)return;
chat.innerHTML = '';
(r.messages||[]).forEach(m=>{
const txt = (m.content||'').trim();
if(!txt) return;
if(m.role==='user'){ addUserMsg(txt); }
else{
const div = newAssistantMsg();
div.querySelector('.agent-text').textContent = txt;
}
});
if(!chat.children.length){
chat.innerHTML = '<div class="agent-empty">新会话。给 AI 下达指令'
+ '(先选 🎯 目标设备)…</div>';
}
});
}
// ================== 发送与流式接收 ==================
function setRunning(on){
_agentBusy = on;
document.getElementById('agent-running-tag').style.display = on ? 'inline' : 'none';
document.getElementById('btn-agent-stop').style.display = on ? 'inline-block' : 'none';
document.getElementById('btn-agent-send').disabled = on;
}
function stopAgent(){
if(!confirm('确定停止当前 AI 运行任务?\n(其他人发起的任务也会被停止)')) return;
apiPost('/api/agent/stop',{}).then(r=>{
if(r&&r.ok) showToast('已请求停止,正在中断…','success');
else showToast((r&&r.error)||'停止失败','error');
});
}
function sendAgentMsg(){
if(_agentBusy){showToast('上一轮还在运行','error');return;}
const sel = document.getElementById('agent-target-select');
const serial = sel ? sel.value : '';
if(!serial){
showToast('请先选择目标设备(AI 只操作你选定的设备;任务中的设备不可选)','error');
if(sel) sel.focus();
return;
}
const inp = document.getElementById('agent-input');
const text = inp.value.trim();
if(!text){showToast('请输入指令','error');return;}
inp.value = '';
addUserMsg(text);
// 会话:无当前会话时自动新建(DeepSeek 式:消息总归属于一个会话)
const doRun = (convId)=>{
apiPost('/api/agent/run', {prompt: text, serial: serial,
conversation_id: convId}).then(r=>{
if(!r||!r.ok){
const msg = r ? (r.error||'启动失败') : '请求失败';
const div = newAssistantMsg();
div.querySelector('.agent-text').textContent = '⚠ ' + msg;
showToast(msg,'error');
return;
}
setRunning(true);
listenStream(r.run_id);
});
};
if(_currentConvId){
doRun(_currentConvId);
}else{
apiPost('/api/agent/conversations',{}).then(r=>{
if(r&&r.ok){
saveConvId(r.id);
renderConvList();
doRun(r.id);
}else{
const div = newAssistantMsg();
div.querySelector('.agent-text').textContent = '⚠ 新建会话失败';
showToast('新建会话失败','error');
}
});
}
}
function listenStream(runId){
if(_agentStream) _agentStream.close();
const es = new EventSource('/api/agent/stream?run_id=' + runId);
_agentStream = es;
let msgEl = null;
es.addEventListener('delta', ev=>{
if(!msgEl) msgEl = newAssistantMsg();
const d = JSON.parse(ev.data);
const textEl = msgEl.querySelector('.agent-text');
if(d.kind === 'reasoning'){
let r = msgEl.querySelector('.reasoning');
if(!r){
r = document.createElement('div');
r.className = 'reasoning';
msgEl.insertBefore(r, textEl);
}
r.textContent += d.text;
}else{
textEl.textContent += d.text;
}
scrollChat();
});
es.addEventListener('step', ev=>{
const d = JSON.parse(ev.data);
followSerialFromArgs(d.args);
if(!msgEl) msgEl = newAssistantMsg();
const cards = msgEl.querySelector('.agent-toolcards');
const card = document.createElement('div');
card.className = 'agent-toolcard';
const argsTxt = typeof d.args === 'string' ? d.args
: JSON.stringify(d.args || {});
card.innerHTML = '<span class="dot"></span>'
+ '<code style="color:var(--teal)">' + esc(d.tool||'') + '</code>'
+ '<span class="text-muted">' + esc(argsTxt) + '</span>';
if(d.image){
const img = document.createElement('img');
img.src = 'data:image/jpeg;base64,' + d.image;
card.appendChild(img);
}
cards.appendChild(card);
scrollChat();
});
es.addEventListener('done', ev=>{
const d = JSON.parse(ev.data);
if(d.answer){
if(!msgEl) msgEl = newAssistantMsg();
msgEl.querySelector('.agent-text').textContent = d.answer;
}
endRun();
renderConvList(); // 落库已完成:刷新标题/时间/轮数
});
es.addEventListener('error', ev=>{
let msg = '连接中断';
try{
if(ev.data) msg = JSON.parse(ev.data).message || msg;
}catch(e){}
if(!msgEl) msgEl = newAssistantMsg();
msgEl.querySelector('.agent-text').textContent = '⚠ ' + msg;
endRun();
});
es.onerror = ()=>{
// 重要:不能在这里 endRun()——断网/后台标签页节流/服务端瞬时抖动都会触发
// onerror,但后端任务仍在跑。EventSource 会自动重连,服务端事件队列保留
// 积压(delta/step/done),重连成功后全部补发,界面无缝续上。
// 只有连接彻底关闭(非自动重连态)且任务还在跑时才提示。
if(_agentBusy && es.readyState === EventSource.CLOSED){
const hint = document.getElementById('agent-target-hint');
if(hint) hint.textContent = '⚠ 实时连接已中断,任务仍在后台运行——可刷新页面恢复查看';
// 连接彻底失败时周期探测,恢复后重订阅
setTimeout(()=>{
if(!_agentStream && _agentBusy){
apiGet('/api/agent/run').then(r=>{
if(r&&r.ok&&r.state==='running'&&r.run_id) listenStream(r.run_id);
});
}
}, 5000);
}
};
}
// ============ 实时画面(右侧,MJPEG 流) ============
let _liveSerial = '';
function showLive(serial){
if(!serial || serial === _liveSerial) return;
_liveSerial = serial;
const img = document.getElementById('agent-live-img');
img.src = '/api/screen/stream?serial=' + encodeURIComponent(serial) + '&q=80&fps=8&t=' + Date.now();
img.style.display = 'block';
document.getElementById('agent-live-empty').style.display = 'none';
document.getElementById('agent-live-serial').textContent = serial;
const sel = document.getElementById('agent-live-select');
if(sel) sel.value = serial;
}
function stopLive(){
_liveSerial = '';
const img = document.getElementById('agent-live-img');
img.src = '';
img.style.display = 'none';
document.getElementById('agent-live-empty').style.display = 'block';
document.getElementById('agent-live-serial').textContent = '';
}
function followSerialFromArgs(args){
// 工具 args(后端 SSE 已序列化为对象;兼容历史字符串格式)
let a = args;
if(typeof args === 'string'){
try{ a = JSON.parse(args); }catch(e){ return; }
}
if(a && a.serial) showLive(a.serial);
}
function watchDevice(serial){
if(!serial){ stopLive(); return; }
showLive(serial);
}
function loadLiveDevices(){
apiGet('/api/devices').then(r=>{
if(!r||!r.ok)return;
const sel = document.getElementById('agent-live-select');
if(!sel)return;
const cur = sel.value;
const devs = (r.devices||[]).filter(x=>x.indexOf(':')>=0);
sel.innerHTML = '<option value="">选择设备观看…</option>'
+ devs.map(s=>'<option value="'+esc(s)+'"'+(s===cur?' selected':'')+'>'+esc(s)+'</option>').join('');
});
}
// 截图点击放大查看
function zoomScreenshot(img){
const ov = document.getElementById('agent-zoom');
if(!ov){
const d = document.createElement('div');
d.id = 'agent-zoom';
d.style.cssText = 'display:none;position:fixed;inset:0;background:rgba(0,0,0,.8);z-index:1400;align-items:center;justify-content:center;cursor:zoom-out';
d.onclick = ()=>d.style.display='none';
document.body.appendChild(d);
}
const ovEl = document.getElementById('agent-zoom');
ovEl.innerHTML = '';
const big = document.createElement('img');
big.src = img.src;
big.style.cssText = 'max-width:88vw;max-height:88vh;border-radius:8px';
ovEl.appendChild(big);
ovEl.style.display = 'flex';
}
// 截图 img 绑定点击放大
document.addEventListener('click', ev=>{
const t = ev.target;
if(t && t.tagName==='IMG' && t.closest('.agent-toolcard') && !t.closest('#agent-zoom')){
zoomScreenshot(t);
}
});
function endRun(){
setRunning(false);
_agentStream && _agentStream.close();
_agentStream = null;
scrollChat();
}
// ================== 经验库(自进化记忆管理) ==================
function openExpLib(){
document.getElementById('agent-exp-overlay').style.display = 'flex';
loadExpLib();
}
function closeExpLib(){
document.getElementById('agent-exp-overlay').style.display = 'none';
}
function loadExpLib(){
const list = document.getElementById('exp-lib-list');
apiGet('/api/agent/experience').then(r=>{
if(!r||!r.ok){ showToast((r&&r.error)||'加载失败','error'); return; }
document.getElementById('exp-lib-last').textContent =
(r.last ? ('最近巡检 ' + r.last + ':' + (r.last_summary||'')) : '');
if(r.running){
list.innerHTML = '<div class="text-muted" style="padding:20px;text-align:center">🔍 AI 巡检进行中(约 1 分钟),完成后请刷新…</div>';
return;
}
const exps = r.experiences||[];
if(!exps.length){
list.innerHTML = '<div class="text-muted" style="padding:20px;text-align:center">暂无经验——跑一轮带手机操作的任务后会自动沉淀</div>';
return;
}
list.innerHTML = '';
exps.forEach(e=>{
const a = e.audit||{};
const card = document.createElement('div');
card.style.cssText = 'border:1px solid var(--card-line);border-radius:10px;padding:9px 13px;background:#0d1117';
// 徽章
let badge = '';
if(a.action === 'pending' && a.verdict === 'delete'){
badge = '<span style="background:#7f1d1d;color:#fecaca;font-size:10.5px;padding:1px 8px;border-radius:9px;margin-left:8px">⚠ 建议删除</span>';
}else if(a.action === 'kept'){
badge = '<span style="background:#0f3d2e;color:#a7f3d0;font-size:10.5px;padding:1px 8px;border-radius:9px;margin-left:8px">' +
(a.verdict === 'delete' ? '已人工保留' : '✓ 已保留') + '</span>';
}
let actBtns = '';
if(a.action === 'pending'){
actBtns = '<button class="btn btn-xs btn-danger" onclick="expDelete(' + e.id + ')">确认删除</button>'
+ '<button class="btn btn-xs" onclick="expKeep(' + e.id + ')">保留</button>';
}else{
actBtns = '<button class="btn btn-xs" style="border-color:#7f1d1d;color:#f87171" onclick="expDelete(' + e.id + ')" title="强制删除此经验">删除</button>';
}
card.innerHTML =
'<div style="display:flex;align-items:center;gap:6px;font-size:12px">'
+ '<span class="text-muted" style="font-family:var(--mono)">#' + e.id + '</span>'
+ '<span class="text-muted">' + esc(e.created_at||'') + '</span>'
+ '<span class="text-muted">引用 ' + (e.hits||0) + ' 次</span>' + badge
+ '<span style="flex:1"></span>' + actBtns + '</div>'
+ '<div style="font-size:13px;color:#e5e7eb;margin:5px 0 3px">' + esc(e.task_prompt) + '</div>'
+ '<div class="text-muted" style="font-size:11.5px;line-height:1.6;white-space:pre-wrap">' + esc((e.recipe||'').slice(0,220)) + '</div>'
+ (a.action === 'pending' && a.reason
? '<div style="font-size:11.5px;color:#fbbf24;margin-top:5px">AI 建议理由:' + esc(a.reason)
+ (a.score ? '(评分 ' + a.score + '/10)' : '') + '</div>'
: '');
list.appendChild(card);
});
});
}
function triggerExpAudit(){
apiPost('/api/agent/experience/audit',{}).then(r=>{
if(r&&r.ok){
showToast('巡检已启动,约 1 分钟完成','success');
document.getElementById('exp-lib-list').innerHTML =
'<div class="text-muted" style="padding:20px;text-align:center">🔍 AI 巡检进行中…</div>';
// 完成后自动刷新
setTimeout(()=>{ if(document.getElementById('agent-exp-overlay').style.display==='flex') loadExpLib(); }, 90000);
}else showToast((r&&r.error)||'巡检启动失败','error');
});
}
function expDelete(id){
if(!confirm('确认删除该经验?删除后不可恢复(下次相似任务不再自动参考)。')) return;
apiPost('/api/agent/experience/delete',{id:id}).then(r=>{
if(r&&r.ok){ showToast('已删除','success'); loadExpLib(); }
else showToast((r&&r.error)||'删除失败','error');
});
}
function expKeep(id){
apiPost('/api/agent/experience/keep',{id:id}).then(r=>{
if(r&&r.ok){ showToast('已保留,后续不再重复建议','success'); loadExpLib(); }
else showToast((r&&r.error)||'操作失败','error');
});
}
+264
View File
@@ -0,0 +1,264 @@
// 工具 Tab-应用管理:APK 上传/安装/删除 + 设备已装应用
// ================== Tab: 应用 ==================
let _apksCache=[];
let _installTimer=null;
let _devAppsData=[];
function fmtSize(b){
if(!b)return '0 B';
if(b<1024)return b+' B';
if(b<1048576)return (b/1024).toFixed(1)+' KB';
return (b/1048576).toFixed(1)+' MB';
}
async function loadApks(){
const r=await apiGet('/api/apks');if(!r||!r.ok)return;
const apks=r.apks||[];
_apksCache=apks;
document.getElementById('apk-hint').textContent='共 '+apks.length+' 个应用';
setListPager('apks',{
data:()=>apks,
sortValue:(a,k)=>{ if(k==='size')return a.size||0; if(k==='upload_time')return a.upload_time||''; if(k==='package_name')return a.package_name||''; if(k==='version_name')return a.version_name||''; return a.display_name||''; },
filter:(a,kw)=>((a.display_name||'').toLowerCase().includes(kw)||(a.package_name||'').toLowerCase().includes(kw)),
render:renderApkRow,
tbody:'tb-apks',pager:'pager-apks',pageSize:10,
empty:'<tr><td colspan="6" class="empty">暂无APK,点击"上传APK"上传</td></tr>'
});
applyListPager('apks');
initListSearch('apks','search-apks');
await fillAppsDeviceList();
}
function renderApkRow(a){
return '<tr>'+
'<td><strong>'+esc(a.display_name||'(未命名)')+'</strong></td>'+
'<td style="font-family:monospace;font-size:12px;color:#64748b">'+esc(a.package_name||'-')+'</td>'+
'<td>'+(a.version_name?esc(a.version_name):'-')+'</td>'+
'<td>'+fmtSize(a.size)+'</td>'+
'<td style="font-size:12px;color:#94a3b8">'+esc(a.upload_time)+'</td>'+
'<td>'+
'<button class="btn btn-primary btn-xs" onclick="openInstallModal(\''+a.id+'\')">安装</button> '+
'<button class="btn btn-danger btn-xs" onclick="deleteApk(\''+a.id+'\')">删除</button>'+
'</td>'+
'</tr>';
}
// 填充"设备已装应用"的设备下拉(保留当前选择)
async function fillAppsDeviceList(){
const sr=await apiGet('/api/status');if(!sr||!sr.ok)return;
const sel=document.getElementById('apps-device');
if(!sel)return;
const cur=sel.value;
const devices=(sr.devices||[]).filter(d=>d.present);
sel.innerHTML='<option value="">选择设备...</option>'+devices.map(d=>
'<option value="'+esc(d.serial)+'">'+esc((d.serial||'').replace(/:5555$/,'')+' ('+(d.device_name||d.model||'未知设备')+')')+'</option>'
).join('');
if(cur)sel.value=cur;
}
// 查看指定设备已安装应用
async function loadDeviceApps(){
const sel=document.getElementById('apps-device');
const serial=sel.value;
if(!serial){showToast('请先选择设备','error');return;}
const hint=document.getElementById('apps-hint');
const tb=document.getElementById('tb-device-apps');
hint.textContent='加载中...';
const r=await apiGet('/api/devices/'+encodeURIComponent(serial)+'/apps');
hint.textContent='';
if(!r||!r.ok){
tb.innerHTML='<tr><td colspan="4" class="empty">'+(r&&r.error?esc(r.error):'获取失败')+'</td></tr>';
showToast('获取失败','error');return;
}
const apps=r.apps||[];
if(!apps.length){tb.innerHTML='<tr><td colspan="4" class="empty">无应用</td></tr>';return;}
const userApps=apps.filter(a=>/^\/data\//.test(a.path||'')).length;
hint.textContent='共 '+apps.length+' 个应用(用户安装 '+userApps+')';
_devAppsData=apps;
setListPager('devapps',{
data:()=>_devAppsData||[],
filter:(a,kw)=>((a.label||a.package||'').toLowerCase().includes(kw)||(a.package||'').toLowerCase().includes(kw)||(a.path||'').toLowerCase().includes(kw)),
sortValue:(a,k)=>{ if(k==='version_code')return parseInt(a.version_code)||0; if(k==='version_name')return a.version_name||''; if(k==='label')return (a.label||a.package||''); return a[k]||''; },
render:renderDevAppRow,
tbody:'tb-device-apps',pager:'pager-devapps',pageSize:20,
empty:'<tr><td colspan="4" class="empty">无应用</td></tr>'
});
applyListPager('devapps');
initListSearch('devapps','search-devapps');
}
function renderDevAppRow(a){
return '<tr>'+
'<td><strong>'+esc(a.label||a.package)+'</strong>'+(a.label?' <span style="font-size:11px;color:#94a3b8;font-family:monospace">'+esc(a.package)+'</span>':'')+'</td>'+
'<td>'+(a.version_name?esc(a.version_name):'-')+'</td>'+
'<td style="font-size:12px;color:#94a3b8">'+(a.version_code!=null?esc(a.version_code):'-')+'</td>'+
'<td style="font-size:11px;color:#94a3b8;word-break:break-all">'+esc(a.path||'-')+'</td>'+
'</tr>';
}
async function uploadApk(input){
if(!input.files||!input.files[0])return;
const file=input.files[0];
if(!file.name.toLowerCase().endsWith('.apk')){
showToast('请选择 .apk 文件','error');return;
}
const hint=document.getElementById('apk-hint');
const fd=new FormData();
fd.append('file',file);
// 用 XMLHttpRequest 才能拿到上传进度(fetch 不支持上传进度事件)
const xhr=new XMLHttpRequest();
xhr.open('POST','/api/apks/upload');
if(_csrfToken)xhr.setRequestHeader('X-CSRF-Token',_csrfToken);
xhr.upload.onprogress=function(e){
if(e.lengthComputable){
const pct=Math.round(e.loaded*100/e.total);
const loaded=(e.loaded/1048576).toFixed(1);
const total=(e.total/1048576).toFixed(1);
hint.innerHTML='<span style="color:#f59e0b">正在上传 '+esc(file.name)+' '+pct+'% ('+loaded+'/'+total+' MB)</span>'+
'<div class="progress" style="width:200px;height:6px;margin-top:4px"><div class="progress-bar" style="width:'+pct+'%"></div></div>';
}
};
xhr.onload=function(){
if(xhr.status===401){window.location='/login';return;}
try{
const data=JSON.parse(xhr.responseText);
if(data.ok){
showToast(data.msg||'上传成功','success');
loadApks();
}else{
showToast(data.error||'上传失败','error');
hint.textContent='';
}
}catch(e){
showToast('解析响应失败','error');
hint.textContent='';
}
};
xhr.onerror=function(){
showToast('上传失败:网络错误','error');
hint.textContent='';
};
xhr.send(fd);
input.value='';
}
async function deleteApk(id){
if(!confirm('确认删除此APK文件?'))return;
const r=await apiDelete('/api/apks/'+id);
handleResult(r,loadApks);
}
async function openInstallModal(apkId){
const apk=_apksCache.find(a=>a.id===apkId);
const apkName=apk?(apk.display_name||'APK'):'APK';
const r=await apiGet('/api/apks/install/devices');
const online=(r&&r.ok)?(r.devices||[]):[];
if(!online.length){
showToast('没有可用设备(设备池或本机 adb 均无)','error');return;
}
const body=
'<div style="margin-bottom:12px">'+
'<strong>安装应用:</strong><span class="label label-primary">'+esc(apkName)+'</span>'+
' <span style="font-size:12px;color:#94a3b8">运行中设备将自动跳过</span>'+
'</div>'+
'<div style="margin-bottom:8px">'+
'<button class="btn btn-sm" onclick="document.querySelectorAll(\'#install-dev-list input[type=checkbox]\').forEach(c=>c.checked=true)">全选</button> '+
'<button class="btn btn-sm" onclick="document.querySelectorAll(\'#install-dev-list input[type=checkbox]\').forEach(c=>c.checked=false)">全不选</button> '+
'<button class="btn btn-sm btn-primary" onclick="selectIdleForInstall()">全选空闲</button>'+
'</div>'+
'<div id="install-dev-list" class="device-list">'+
online.map(d=>{
const isUsb=d.source==='usb';
const st='<span class="label label-'+(isUsb?'primary':'default')+'" style="margin-left:6px">'+(isUsb?'本地USB':(d.source==='pool'?'设备池':'adb'))+'</span>';
// serial 唯一,显示 IP + 型号便于区分同型号设备
const label=(d.serial||'').replace(/:5555$/,'')+' · '+(d.model||(isUsb?'USB 有线设备':'设备'));
return '<label><input type="checkbox" value="'+esc(d.serial)+'" checked>'+esc(label)+st+'</label>';
}).join('')+
'</div>';
document.getElementById('modal-title').textContent='安装应用到设备';
document.getElementById('modal-body').innerHTML=body;
document.getElementById('modal-footer').innerHTML=
'<button class="btn" onclick="closeModal()">取消</button>'+
'<button class="btn btn-primary" onclick="startInstall(\''+apkId+'\')">开始安装</button>';
document.getElementById('modal-overlay').classList.add('show');
}
function selectIdleForInstall(){
document.querySelectorAll('#install-dev-list label').forEach(lbl=>{
const cb=lbl.querySelector('input[type=checkbox]');
const isRunning=lbl.querySelector('.label-success');
cb.checked=!isRunning;
});
}
async function startInstall(apkId){
const checked=document.querySelectorAll('#install-dev-list input[type=checkbox]:checked');
const serials=Array.from(checked).map(c=>c.value);
if(!serials.length){
showToast('请至少选择一台设备','error');return;
}
const r=await apiPost('/api/apks/install',{apk_id:apkId,serials:serials});
if(!r||!r.ok){showToast(r?r.error:'请求失败','error');return;}
showToast(r.msg||'安装已启动','success');
showInstallProgress();
}
function showInstallProgress(){
document.getElementById('modal-title').textContent='安装进度';
document.getElementById('modal-body').innerHTML=
'<div id="install-progress" style="min-height:200px"><p class="text-muted">加载中...</p></div>';
document.getElementById('modal-footer').innerHTML=
'<button class="btn" onclick="closeInstallModal()" id="install-close-btn" style="display:none">关闭</button>';
pollInstallStatus();
if(_installTimer)clearInterval(_installTimer);
_installTimer=setInterval(pollInstallStatus,3000);
}
async function pollInstallStatus(){
const r=await apiGet('/api/apks/install/status');
if(!r||!r.ok)return;
const st=r.status;
if(!st){
document.getElementById('install-progress').innerHTML='<p class="text-muted">无安装任务</p>';
if(_installTimer){clearInterval(_installTimer);_installTimer=null;}
return;
}
const SS={pending:{t:'等待',c:'default'},installing:{t:'安装中',c:'warning'},success:{t:'成功',c:'success'},failed:{t:'失败',c:'danger'},skipped:{t:'跳过',c:'default'}};
const items=st.items||{};
const itemRows=Object.entries(items).map(([serial,info])=>{
const s=SS[info.status]||{t:info.status,c:'default'};
return '<tr><td>'+esc(info.name||serial)+'</td>'+
'<td style="font-family:monospace;font-size:11px;color:#94a3b8">'+esc(serial)+'</td>'+
'<td><span class="label label-'+s.c+'">'+s.t+'</span></td>'+
'<td style="font-size:12px;color:#64748b">'+esc(info.msg||'')+'</td></tr>';
}).join('');
const pct=st.total>0?Math.round((st.success+st.failed+st.skipped)*100/st.total):0;
const summary=
'<div style="margin-bottom:12px">'+
'<div style="display:flex;gap:8px;margin-bottom:8px;font-size:13px;flex-wrap:wrap">'+
'<span>应用:<strong>'+esc(st.apk_name)+'</strong></span>'+
'<span class="label label-success">成功 '+st.success+'</span>'+
'<span class="label label-danger">失败 '+st.failed+'</span>'+
'<span class="label label-default">跳过 '+st.skipped+'</span>'+
'<span class="label label-warning">安装中 '+st.installing+'</span>'+
'<span class="label label-default">等待 '+st.pending+'</span>'+
'</div>'+
'<div class="progress" style="width:100%;height:8px"><div class="progress-bar" style="width:'+pct+'%"></div></div>'+
'</div>';
document.getElementById('install-progress').innerHTML=
summary+'<table class="table" style="margin-bottom:0"><thead><tr><th>设备</th><th>Serial</th><th>状态</th><th>详情</th></tr></thead><tbody>'+itemRows+'</tbody></table>';
if(st.finished){
if(_installTimer){clearInterval(_installTimer);_installTimer=null;}
document.getElementById('install-close-btn').style.display='';
showToast('安装完成:成功'+st.success+' 失败'+st.failed+' 跳过'+st.skipped,'success');
}
}
function closeInstallModal(){
if(_installTimer){clearInterval(_installTimer);_installTimer=null;}
closeModal();
}
+173
View File
@@ -0,0 +1,173 @@
// 通用基础:esc/CSRF/权限/API/Toast、Tab 切换与页内子分栏、模态框、行内按钮操作
// ================== 通用工具 ==================
const ACTION_LABELS={like:'点赞',comment:'评论',follow:'关注',share:'分享'};
const STATUS_MAP={idle:{t:'未启动',c:'default'},connecting:{t:'连接中',c:'warning'},running:{t:'运行中',c:'success'},done:{t:'完成',c:'primary'},error:{t:'异常',c:'danger'},released:{t:'已释放',c:'default'},failed:{t:'失败',c:'danger'}};
const TARGET_MAP={all:'全部空闲',group:'按分组',serial:'指定设备'};
const APP_NAMES={'com.ss.android.ugc.aweme':'抖音','com.ss.android.ugc.aweme.lite':'抖音极速版','com.smile.gifmaker':'快手','com.kuaishou.nebula':'快手极速版','com.tencent.mm':'微信','com.tencent.mobileqq':'QQ','com.tencent.qqlive':'腾讯视频','com.eg.android.AlipayGphone':'支付宝','com.taobao.taobao':'淘宝','com.tmall.wireless':'天猫','com.jingdong.app.mall':'京东','com.xunmeng.pinduoduo':'拼多多','com.ss.android.article.news':'今日头条','com.zhihu.android':'知乎','com.miui.home':'桌面','com.android.launcher':'桌面','com.android.settings':'设置','com.android.systemui':'系统','com.github.uiautomator':'U2','com.github.uiautomator.test':'U2测试'};
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
// ===== CSRF =====
let _csrfToken='';
// 页面加载后调用:获取 CSRF token,非 GET 请求需携带
async function initCsrf(){
try{
const r=await fetch('/api/csrf');
const d=await r.json();
_csrfToken=d.token||'';
}catch(e){}
}
function _csrfHeaders(){
return _csrfToken?{'X-CSRF-Token':_csrfToken}:{};
}
// ===== 当前用户与权限 =====
// _me 来自 /api/me:{username, is_admin, perms:[tasks/devices/apks/logs]}
// 管理员 perms 恒为全部。无权限的功能入口(tab/按钮)用 data-perm 标记或 _can() 判断隐藏。
let _me={is_admin:false,perms:[],username:''};
async function loadMe(){
try{
const r=await apiGet('/api/me');
if(r&&r.ok)_me=r.user||{is_admin:false,perms:[],username:''};
const un=document.getElementById('me-username');
if(un)un.textContent=_me.username||'';
// 隐藏无权限的 tab 和按钮(data-perm="admin" 表示仅管理员)
document.querySelectorAll('[data-perm]').forEach(function(el){
const p=el.dataset.perm;
const ok=(p==='admin')?_me.is_admin:(_me.perms||[]).includes(p);
if(!ok)el.style.display='none';
});
}catch(e){}
}
function _can(perm){
return _me.is_admin||(_me.perms||[]).includes(perm);
}
const PERM_LABELS={tasks:'任务管理',devices:'设备控制',apks:'应用管理',logs:'日志查看'};
async function apiGet(url){
const r=await fetch(url);
if(r.status===401){window.location='/login';return null;}
return r.json();
}
async function apiPost(url,body){
const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json',..._csrfHeaders()},body:body?JSON.stringify(body):'{}'});
if(r.status===401){window.location='/login';return null;}
return r.json();
}
async function apiPut(url,body){
const r=await fetch(url,{method:'PUT',headers:{'Content-Type':'application/json',..._csrfHeaders()},body:body?JSON.stringify(body):'{}'});
if(r.status===401){window.location='/login';return null;}
return r.json();
}
async function apiDelete(url){
const r=await fetch(url,{method:'DELETE',headers:_csrfHeaders()});
if(r.status===401){window.location='/login';return null;}
return r.json();
}
function showToast(msg,type){
const t=document.getElementById('toast');
t.className='toast show toast-'+(type||'success');
t.textContent=msg;
setTimeout(()=>t.className='toast',3000);
}
function handleResult(r,callback){
if(!r)return;
if(r.ok){showToast(r.msg||'操作成功','success');if(callback)callback();}
else{showToast(r.error||'操作失败','error');}
}
// ================== Tab 切换 ==================
let _activeTab='monitor';
let _monitorTimer=null;
let _logTimer=null;
function showTab(name){
_activeTab=name;
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
document.querySelectorAll('.navbar .tab').forEach(t=>t.classList.remove('active'));
const panel=document.getElementById('tab-'+name);
if(panel)panel.classList.add('active');
const btn=document.querySelector('.navbar .tab[data-tab="'+name+'"]');
if(btn)btn.classList.add('active');
// 停止所有定时器
if(_monitorTimer){clearInterval(_monitorTimer);_monitorTimer=null;}
if(_logTimer){clearInterval(_logTimer);_logTimer=null;}
if(_discoveryTimer){clearInterval(_discoveryTimer);_discoveryTimer=null;}
// 按需加载数据
if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);}
if(name==='tasks'){showSubTab('tasks',_activeSubs.tasks);loadTasks();loadCustomActions();}
if(name==='tools'){showSubTab('tools',_activeSubs.tools);loadToolsDevices();loadAdbDevices();loadTailscaleDevices();loadApks();}
if(name==='logs'){loadLogs();if(document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);}
if(name==='agent' && typeof initAgentChat==='function') initAgentChat();
if(name==='users')loadUsers();
}
// ================== 页内子分栏(任务/工具 通用) ==================
// 每个带子分栏的 Tab 记住上次选中的子分栏,切走再切回来保持原位
let _activeSubs = {tasks: 'plan', tools: 'clipboard'};
let _discoveryTimer = null; // 设备自动发现 10s 轮询(仅 devpool 子分栏激活时)
function showSubTab(tabId, name){
_activeSubs[tabId] = name;
const tab = document.getElementById('tab-'+tabId);
tab.querySelectorAll('.sub-tab').forEach(b=>b.classList.toggle('active', b.dataset.sub===name));
tab.querySelectorAll('.sub-panel').forEach(p=>p.classList.toggle('active', p.id===tabId+'-sub-'+name));
if(name==='groups' && typeof loadGroups==='function') loadGroups();
if(name==='devpool' && typeof loadDevPool==='function'){
loadDevPool();
if(typeof loadDiscovery==='function'){
loadDiscovery();
if(_discoveryTimer){clearInterval(_discoveryTimer);_discoveryTimer=null;}
_discoveryTimer=setInterval(loadDiscovery,10000); // 10s 轮询:扫完自动出新结果
}
} else if(_discoveryTimer){clearInterval(_discoveryTimer);_discoveryTimer=null;}
}
// ================== 模态框 ==================
function showModal(){
document.getElementById('modal-overlay').classList.add('show');
}
function closeModal(){
// 先关闭弹窗(最重要的一步,确保一定执行)
const ov=document.getElementById('modal-overlay');
if(ov)ov.classList.remove('show');
// 再做清理工作,任何异常都不影响弹窗关闭
try{
stopShotTimer();
_shotSerial='';
const box=document.getElementById('modal-box');
if(box){box.classList.remove('shot-modal');box.classList.remove('wide-modal');}
}catch(e){console.warn('closeModal cleanup error:',e);}
}
// 测试此步骤弹窗关闭(editor.js 的测试按钮用;曾随 STF 清理误删)
function closeTestModal(){
const ov=document.getElementById('test-overlay');
if(ov)ov.classList.remove('show');
}
// 关闭元素抓取弹窗(不影响任务编辑窗口)
function closeElPicker(){
var ov=document.getElementById('el-picker-overlay');
if(ov)ov.classList.remove('show');
// 清理状态
try{ _stepEditor._elData=null; _stepEditor._elSerial=''; }catch(e){}
}
// ================== 行内按钮操作(停止/执行/启停等,自动 toast + 刷新)==================
async function apiAction(url,body){
const r=await apiPost(url,body);
if(!r)return;
if(r.ok){showToast(r.msg||'操作成功','success');if(_activeTab==='monitor')loadMonitor();if(_activeTab==='tasks')loadTasks();}
else{showToast(r.error||'操作失败','error');}
}
async function scanForeground(){
const r=await apiPost('/api/scan_foreground');
if(!r)return;
if(r.ok){showToast('扫描已启动,请等待几秒...','success');loadMonitor();}
else{showToast(r.error||'扫描失败','error');}
}
File diff suppressed because it is too large Load Diff
+94
View File
@@ -0,0 +1,94 @@
// 统一列表组件:setListPager 搜索 + 分页 + 排序(_lpSort)
// ================== 通用列表:搜索 + 分页 + 排序 ==================
// 用法:
// setListPager(key,{data:()=>array, filter:(item,kw)=>bool, render:(item)=>html,
// tbody:'tb-id', pager:'pager-id', pageSize:N,
// sortValue:(item,colKey)=>值, // 可排序列的取值函数
// empty:'<tr>...'});
// applyListPager(key); // 数据变化后重渲染(含搜索/翻页/排序状态)
// initListSearch(key,'search-id'); // 搜索框绑定
// _lpSort(key,'colKey'); // 表头排序:th 加 data-sort="colKey" + onclick,
// // 再点切换升降序,第三次取消;排序指示器自动更新
var _LIST_PAGERS={};
function _lp(key){ return _LIST_PAGERS[key] || (_LIST_PAGERS[key]={page:1,kw:'',cfg:null,sort:null}); }
function setListPager(key,cfg){
// 只更新配置,不重置 page/kw/sort —— 监控列表 5 秒自动刷新时保持用户当前状态
var st=_lp(key); st.cfg=cfg;
}
function initListSearch(key,inputId){
var input=document.getElementById(inputId);
if(!input||input.dataset.bound)return;
input.dataset.bound='1';
input.addEventListener('input',function(){
var st=_lp(key); st.kw=this.value.trim(); st.page=1; applyListPager(key);
});
}
function _lpSort(key,colKey){
var st=_lp(key);
if(st.sort&&st.sort.key===colKey){
if(st.sort.dir===1)st.sort.dir=-1; // 升序 → 降序
else if(st.sort.dir===-1)st.sort=null; // 降序 → 取消排序
else st.sort={key:colKey,dir:1};
}else{
st.sort={key:colKey,dir:1};
}
applyListPager(key);
}
function applyListPager(key){
var st=_lp(key);
if(!st.cfg)return;
var cfg=st.cfg;
var all=(cfg.data&&cfg.data())||[];
var kw=st.kw.toLowerCase();
var filtered=kw?all.filter(function(x){return cfg.filter(x,kw);}):all;
// 排序:cfg.sortValue 取列值;空值/缺失恒排最后;字符串用中文数字感知比较(10 排在 9 后)
if(st.sort&&cfg.sortValue){
var sk=st.sort.key, dir=st.sort.dir;
filtered=filtered.slice().sort(function(a,b){
var va=cfg.sortValue(a,sk), vb=cfg.sortValue(b,sk);
if(va===vb)return 0;
if(va==null||va==='')return 1;
if(vb==null||vb==='')return -1;
if(typeof va==='number'&&typeof vb==='number')return (va-vb)*dir;
return String(va).localeCompare(String(vb),'zh-Hans-CN',{numeric:true})*dir;
});
}
var pageSize=cfg.pageSize||10;
var pages=Math.max(1,Math.ceil(filtered.length/pageSize));
if(st.page>pages)st.page=pages;
if(st.page<1)st.page=1;
var start=(st.page-1)*pageSize;
var slice=filtered.slice(start,start+pageSize);
var tb=document.getElementById(cfg.tbody);
if(tb){
tb.innerHTML=slice.map(cfg.render).join('')||(cfg.empty||'<tr><td colspan="20" class="empty">无数据</td></tr>');
// 表头排序指示器(th[data-sort],所有表格统一)
if(cfg.sortValue){
var table=tb.closest('table');
if(table)table.querySelectorAll('thead th[data-sort]').forEach(function(th){
th.classList.add('sortable');
th.classList.toggle('sort-asc',!!st.sort&&st.sort.key===th.dataset.sort&&st.sort.dir===1);
th.classList.toggle('sort-desc',!!st.sort&&st.sort.key===th.dataset.sort&&st.sort.dir===-1);
});
}
}
var pg=document.getElementById(cfg.pager);
if(pg)pg.innerHTML=_lpPagerHtml(key,pages,st.page,filtered.length,pageSize);
}
function _lpPagerHtml(key,pages,cur,total,pageSize){
if(pages<=1)return total>0?'<div class="list-pager"><span class="lp-total">共 '+total+' 条</span></div>':'';
var h='<div class="list-pager"><span class="lp-total">共 '+total+' 条</span>';
h+='<button class="btn btn-xs" onclick="_lpGo(\''+key+'\','+(cur-1)+')"'+(cur<=1?' disabled':'')+'>‹ 上一页</button>';
for(var p=Math.max(1,cur-2);p<=Math.min(pages,cur+2);p++){
h+='<button class="btn btn-xs'+(p===cur?' btn-primary':'')+'" onclick="_lpGo(\''+key+'\','+p+')">'+p+'</button>';
}
h+='<button class="btn btn-xs" onclick="_lpGo(\''+key+'\','+(cur+1)+')"'+(cur>=pages?' disabled':'')+'>下一页 ›</button>';
h+='</div>';
return h;
}
function _lpGo(key,p){
var st=_lp(key); st.page=p; applyListPager(key);
}
+57 -2542
View File
File diff suppressed because it is too large Load Diff
+331
View File
@@ -0,0 +1,331 @@
// 任务 Tab:任务计划 CRUD/调度解析/运行窗口 + 自定义动作管理
// ================== Tab 2: 任务 ==================
let _taskTypes=[];
let _groupsList=[];
let _devicesList=[];
async function loadTasks(){
const [jr,gr,dr,tr]=await Promise.all([
apiGet('/api/jobs'),apiGet('/api/groups'),apiGet('/api/devices'),apiGet('/api/task_types')
]);
if(!jr)return;
_jobsData=jr.jobs||[];
_taskTypes=tr?tr.task_types||[]:[];
_groupsList=gr?gr.groups||[]:[];
_devicesList=dr?dr.devices||[]:[];
renderTaskTable();
}
function renderTaskTable(){
const jobs=_jobsData||[];
setListPager('tasks',{
data:()=>jobs,
filter:(j,kw)=>((j.name||'').toLowerCase().includes(kw)||(j.task_type||'').toLowerCase().includes(kw)||(j.id||'').toLowerCase().includes(kw)),
sortValue:(j,k)=>{ if(k==='id')return j.id||''; if(k==='enabled')return j.enabled?1:0; if(k==='next_run')return j.next_run||''; return j[k]||''; },
render:renderTaskRow,
tbody:'tb-tasks',pager:'pager-tasks',pageSize:10,
empty:'<tr><td colspan="9" class="empty">暂无任务,点击"新建任务"创建</td></tr>'
});
applyListPager('tasks');
initListSearch('tasks','search-tasks');
}
function renderTaskRow(j){
const tgt=TARGET_MAP[j.target.mode]||j.target.mode;
const tgtDetail=j.target.mode==='group'?(j.target.group_name||''):(j.target.mode==='serial'?(j.target.serial||''):'');
const sched=j.schedule.mode==='cron_stop'?('启停 '+cronToText(j.schedule.cron||'')+' → '+cronToText(j.schedule.stop_cron||'')):
j.schedule.mode==='cron'?('定时 '+cronToText(j.schedule.cron||'')):'手动';
const schedTxt=sched+((j.schedule.window&&j.schedule.window.start&&j.schedule.window.end)
?' [窗口 '+esc(j.schedule.window.start)+'-'+esc(j.schedule.window.end)+']':'');
const schedOut='<td>'+schedTxt+'</td>';
// 下次运行时间(后端已按运行窗口跳过窗口外触发点)
const nextRun=fmtNextRun(j.next_run);
const nextOut='<td>'+(nextRun||'<span class="text-muted">-</span>')+'</td>';
const retry=j.retry.max_attempts>1?(j.retry.max_attempts+'次/'+j.retry.delay+'s'):'不重试';
const en=j.enabled?'<span class="label label-success">启用</span>':'<span class="label label-default">停用</span>';
return '<tr>'+
'<td><code>'+esc(j.id)+'</code></td>'+
'<td><strong>'+esc(j.name)+'</strong></td>'+
'<td>'+esc(j.task_type)+'</td>'+
'<td>'+en+'</td>'+
'<td>'+esc(tgt)+(tgtDetail?'('+esc(tgtDetail)+')':'')+'</td>'+
schedOut+
nextOut+
'<td>'+retry+'</td>'+
'<td>'+
(_can('tasks')?(
'<button class="btn btn-primary btn-xs" onclick="apiAction(\'/api/jobs/'+j.id+'/run\')">执行</button>'+
'<button class="btn btn-xs" onclick="apiAction(\'/api/jobs/'+j.id+'/toggle\',{enabled:'+(!j.enabled)+'})">'+(j.enabled?'停用':'启用')+'</button>'+
'<button class="btn btn-xs" onclick="openTaskModal(\''+j.id+'\')">编辑</button>'+
'<button class="btn btn-danger btn-xs" onclick="deleteTask(\''+j.id+'\')">删除</button>'
):'<span class="text-muted">只读</span>')+
'</td>'+
'</tr>';
}
function openTaskModal(jobId){
const job=jobId?(_jobsData||[]).find(j=>j.id===jobId):null;
const isEdit=!!job;
document.getElementById('modal-title').textContent=isEdit?'编辑任务':'新建任务';
// 任务编辑器含步骤编辑,加宽弹窗;关闭时在 closeModal 移除
document.getElementById('modal-box').classList.add('wide-modal');
const j=job||{name:'',task_type:'douyin_nurture',enabled:true,target:{mode:'all'},params:{},schedule:{mode:'once'},retry:{max_attempts:1,delay:60}};
const tt=_taskTypes.map(t=>'<option value="'+t.task_type+'"'+(t.task_type===j.task_type?' selected':'')+'>'+esc(t.name)+'</option>').join('');
const gl=_groupsList.map(g=>'<option value="'+g.name+'"'+(g.name===j.target.group_name?' selected':'')+'>'+esc(g.name)+'</option>').join('');
const dl=_devicesList.map(s=>'<option value="'+s+'"'+(s===j.target.serial?' selected':'')+'>'+esc(s)+'</option>').join('');
const sm=j.schedule.mode;
const startCron=(sm==='cron'||sm==='cron_stop')?(j.schedule.cron||''):'';
const stopCron=sm==='cron_stop'?(j.schedule.stop_cron||''):'';
// 运行窗口(每天时间段,窗口外不启动)
const win=j.schedule.window||null;
const winStart=win?(win.start||'09:00'):'09:00';
const winEnd=win?(win.end||'18:00'):'18:00';
// 简单调度:把已有 cron 解析回"频率+时间",编辑时回填;解析不了落到自定义
const sc=parseCronToFreq(startCron);
const stopF=parseCronToFreq(stopCron).time;
const weekdayOpts=[0,1,2,3,4,5,6].map(w=>
'<option value="'+w+'"'+(sc.weekday===w?' selected':'')+'>'+WEEKDAY_NAMES[w]+'</option>').join('');
document.getElementById('modal-body').innerHTML=
'<div class="form-row">'+
'<div class="form-group"><label>任务名</label><input type="text" class="form-control" id="f-name" value="'+esc(j.name)+'" placeholder="如:抖音养号-上午"></div>'+
'<div class="form-group"><label>任务类型</label><select class="form-control" id="f-task_type" onchange="onTaskTypeChange()">'+tt+'</select></div>'+
'</div>'+
'<div class="form-group form-check"><input type="checkbox" id="f-enabled" '+(j.enabled?'checked':'')+'><label for="f-enabled">启用此任务</label></div>'+
'<div class="form-group"><label>目标设备</label>'+
'<select class="form-control" id="f-target_mode" onchange="toggleTargetFields()">'+
'<option value="all"'+(j.target.mode==='all'?' selected':'')+'>全部空闲设备</option>'+
'<option value="group"'+(j.target.mode==='group'?' selected':'')+'>按分组</option>'+
'<option value="serial"'+(j.target.mode==='serial'?' selected':'')+'>指定设备</option>'+
'</select>'+
'<div id="f-target_group_wrap" class="form-group" style="margin-top:8px;margin-bottom:0">'+
'<select class="form-control" id="f-target_group"><option value="">选择分组...</option>'+gl+'</select>'+
'</div>'+
'<div id="f-target_serial_wrap" class="form-group" style="margin-top:8px;margin-bottom:0">'+
'<select class="form-control" id="f-target_serial"><option value="">选择设备...</option>'+dl+'</select>'+
'</div>'+
'<div class="form-check" style="margin-top:8px">'+
'<input type="checkbox" id="f-skip_offline" '+(j.params&&j.params.skip_offline===false?'':'checked')+'>'+
'<label for="f-skip_offline">离线设备自动跳过(推荐)</label>'+
'</div>'+
'<div class="form-check" style="margin-top:6px">'+
'<input type="checkbox" id="f-preempt" '+(j.params&&j.params.preempt?'checked':'')+'>'+
'<label for="f-preempt" title="触发时停止目标设备上正在运行的其他任务,接管设备执行本任务">抢占设备(打断其他任务)</label>'+
'</div>'+
'</div>'+
'<div class="form-row">'+
'<div class="form-group"><label>调度方式</label>'+
'<select class="form-control" id="f-schedule_mode" onchange="toggleScheduleFields()">'+
'<option value="once"'+(sm==='once'?' selected':'')+'>手动(不自动启动)</option>'+
'<option value="cron"'+(sm==='cron'?' selected':'')+'>定时启动</option>'+
'<option value="cron_stop"'+(sm==='cron_stop'?' selected':'')+'>定时启动+停止</option>'+
'</select>'+
'</div>'+
'<div class="form-group" id="f-freq_wrap" style="display:none"><label>频率</label>'+
'<select class="form-control" id="f-schedule_freq" onchange="toggleScheduleFields()">'+
'<option value="daily"'+((sc.freq==='daily')?' selected':'')+'>每天</option>'+
'<option value="hourly"'+((sc.freq==='hourly')?' selected':'')+'>每小时(整点)</option>'+
'<option value="everyN"'+((sc.freq==='everyN')?' selected':'')+'>每隔 N 小时</option>'+
'<option value="weekly"'+((sc.freq==='weekly')?' selected':'')+'>每周</option>'+
'<option value="custom"'+((sc.freq==='custom')?' selected':'')+'>自定义 cron(高级)</option>'+
'</select>'+
'</div>'+
'<div class="form-group" id="f-time_wrap" style="display:none"><label>启动时间</label><input type="time" class="form-control" id="f-schedule_time" value="'+esc(sc.time||'09:00')+'"></div>'+
'<div class="form-group" id="f-interval_wrap" style="display:none"><label>间隔(小时)</label><input type="number" class="form-control" id="f-schedule_interval" value="'+(sc.interval||2)+'" min="1" max="12"><div class="help">从 0 点起算,如 2 = 0/2/4/6...点启动</div></div>'+
'<div class="form-group" id="f-weekday_wrap" style="display:none"><label>星期</label><select class="form-control" id="f-schedule_weekday">'+weekdayOpts+'</select></div>'+
'<div class="form-group" id="f-cron_wrap" style="display:none"><label>启动 Cron 表达式</label><input type="text" class="form-control" id="f-schedule_cron" value="'+esc(startCron)+'" placeholder="如 0 9 * * * = 每天9点启动" style="font-family:monospace"><div class="help">格式:分 时 日 月 周(0 0 * * 0 = 每周日零点)</div></div>'+
'<div class="form-group" id="f-stop_wrap" style="display:none"><label>停止时间(每天)</label><input type="time" class="form-control" id="f-schedule_stop_time" value="'+esc(stopF||'18:00')+'"><div class="help">到点自动停止所有运行中的 worker</div></div>'+
'</div>'+
'<div class="form-group form-check" style="margin-top:4px"><input type="checkbox" id="f-window_enable" '+(win?'checked':'')+' onchange="toggleWindowFields()"><label for="f-window_enable">启用运行窗口(只在指定时间段内运行)</label></div>'+
'<div class="form-row" id="f-window_wrap" style="display:'+(win?'flex':'none')+'">'+
'<div class="form-group"><label>允许运行·开始</label><input type="time" class="form-control" id="f-window_start" value="'+esc(winStart)+'"></div>'+
'<div class="form-group"><label>允许运行·结束</label><input type="time" class="form-control" id="f-window_end" value="'+esc(winEnd)+'"></div>'+
'</div>'+
'<div class="help" id="f-window_help" style="display:'+(win?'block':'none')+'">窗口只约束定时触发:窗口外定时不会启动,手动执行不受限制。跨午夜示例:21:00-09:00 = 晚 9 点到次日早 9 点允许运行</div>'+
'<div class="form-row">'+
'<div class="form-group"><label>重试次数</label><input type="number" class="form-control" id="f-retry_attempts" value="'+j.retry.max_attempts+'" min="1" max="10"></div>'+
'<div class="form-group"><label>重试间隔(秒)</label><input type="number" class="form-control" id="f-retry_delay" value="'+j.retry.delay+'" min="10" max="3600"></div>'+
'</div>'+
'<div class="form-group" id="f-params_wrap"><label>任务参数 (JSON)</label><textarea class="form-control" id="f-params" rows="6">'+esc(JSON.stringify(j.params||{},null,2))+'</textarea><div class="help">可留空使用默认参数。抖音任务可设 max_duration(秒,如 1800=30分钟)限制单设备运行时长</div></div>'+
'<div class="step-editor" id="step-editor">'+
'<div class="se-head"><span>步骤编辑器</span><span style="font-size:11px;color:var(--text-light);flex:1;margin-left:12px">用步骤卡片上的 ☑ 选中多个步骤,可打包成自定义动作</span>'+
(_can('tasks')?'<button class="btn btn-xs btn-primary" id="se-pack-btn" disabled onclick="_stepEditor.saveAsCustomAction()">打包选中步骤</button>':'')+
'</div>'+
'<div class="se-body">'+
'<div class="se-lib" id="se-lib"><div class="lib-title">操作库</div></div>'+
'<div class="se-canvas" id="se-canvas"></div>'+
'</div>'+
'</div>';
// 初始化步骤编辑器(generic_steps 时显示,其他隐藏);jobId 作为草稿 key
_stepEditor.init(j.params&&j.params.steps?j.params.steps:[],j.params&&j.params.max_duration?j.params.max_duration:0,jobId||'');
document.getElementById('modal-footer').innerHTML=
'<button class="btn" onclick="closeModal()">取消</button>'+
'<button class="btn btn-primary" onclick="saveTask('+(isEdit?'\''+jobId+'\'':'null')+')">'+(isEdit?'保存':'创建')+'</button>';
toggleTargetFields();
toggleScheduleFields();
onTaskTypeChange();
showModal();
}
function toggleTargetFields(){
const mode=document.getElementById('f-target_mode').value;
document.getElementById('f-target_group_wrap').style.display=mode==='group'?'block':'none';
document.getElementById('f-target_serial_wrap').style.display=mode==='serial'?'block':'none';
}
// ===== 简单调度:cron(5段) ↔ 频率+时间 互转 =====
const WEEKDAY_NAMES=['周日','周一','周二','周三','周四','周五','周六'];
// cron → 简单设置 {freq: daily|hourly|everyN|weekly|custom, time:"HH:MM", interval, weekday(0-6)}
function parseCronToFreq(cron){
const r={freq:'custom',time:'',interval:2,weekday:1};
const p=(cron||'').trim().split(/\s+/);
if(p.length!==5)return r;
const [mi,h,d,m,w]=p;
if(mi==='0'&&h==='*'&&d==='*'&&m==='*'&&w==='*'){r.freq='hourly';return r;}
const n=(h.match(/^\*\/(\d+)$/)||[])[1]; // 每隔N小时:时位是 */N
if(n&&mi==='0'&&d==='*'&&m==='*'&&w==='*'){r.freq='everyN';r.interval=parseInt(n)||2;return r;}
if(/^\d{1,2}$/.test(mi)&&/^\d{1,2}$/.test(h)&&d==='*'&&m==='*'&&w==='*'){
r.freq='daily';r.time=h.padStart(2,'0')+':'+mi.padStart(2,'0');return r;
}
if(/^\d{1,2}$/.test(mi)&&/^\d{1,2}$/.test(h)&&d==='*'&&m==='*'&&/^[0-7]$/.test(w)){
r.freq='weekly';r.time=h.padStart(2,'0')+':'+mi.padStart(2,'0');r.weekday=parseInt(w)%7;return r;
}
return r; // 解析不了 → 落到自定义
}
// 表单 → cron(5段)
function buildCronFromFreq(){
const freq=document.getElementById('f-schedule_freq').value;
if(freq==='custom')return document.getElementById('f-schedule_cron').value.trim();
if(freq==='hourly')return '0 * * * *';
if(freq==='everyN'){
const n=parseInt(document.getElementById('f-schedule_interval').value)||2;
return '0 */'+Math.min(Math.max(n,1),12)+' * * *';
}
const t=(document.getElementById('f-schedule_time').value||'09:00').split(':');
const mi=parseInt(t[1])||0, h=parseInt(t[0])||0;
if(freq==='weekly'){
const w=parseInt(document.getElementById('f-schedule_weekday').value)||0;
return mi+' '+h+' * * '+w;
}
return mi+' '+h+' * * *'; // daily
}
// cron(5段) → 中文描述(任务列表显示)
function cronToText(cron){
const r=parseCronToFreq(cron||'');
if(r.freq==='hourly')return '每小时';
if(r.freq==='everyN')return '每'+r.interval+'小时';
if(r.freq==='daily')return '每天 '+r.time;
if(r.freq==='weekly')return WEEKDAY_NAMES[r.weekday]+' '+r.time;
return 'cron: '+(cron||'-');
}
function toggleScheduleFields(){
const mode=document.getElementById('f-schedule_mode').value;
const showSched=(mode==='cron'||mode==='cron_stop');
document.getElementById('f-freq_wrap').style.display=showSched?'block':'none';
document.getElementById('f-stop_wrap').style.display=mode==='cron_stop'?'block':'none';
if(showSched)showFreqFields();
}
function toggleWindowFields(){
const on=document.getElementById('f-window_enable').checked;
document.getElementById('f-window_wrap').style.display=on?'flex':'none';
document.getElementById('f-window_help').style.display=on?'block':'none';
}
function showFreqFields(){
const freq=document.getElementById('f-schedule_freq').value;
document.getElementById('f-time_wrap').style.display=(freq==='daily'||freq==='weekly')?'block':'none';
document.getElementById('f-interval_wrap').style.display=freq==='everyN'?'block':'none';
document.getElementById('f-weekday_wrap').style.display=freq==='weekly'?'block':'none';
document.getElementById('f-cron_wrap').style.display=freq==='custom'?'block':'none';
}
function onTaskTypeChange(){
const tt=document.getElementById('f-task_type').value;
const isGeneric=tt==='generic_steps';
document.getElementById('step-editor').classList.toggle('show',isGeneric);
document.getElementById('f-params_wrap').style.display=isGeneric?'none':'block';
}
// ================== 自定义动作管理 ==================
async function loadCustomActions(){
const r=await apiGet('/api/custom_actions'); if(!r||!r.ok)return;
_customActions=r.actions||[];
setListPager('actions',{
data:()=>_customActions,
filter:(a,kw)=>((a.name||'').toLowerCase().includes(kw)),
sortValue:(a,k)=>{ if(k==='steps')return (a.steps||[]).length; if(k==='created_at')return a.created_at||''; return a[k]||''; },
render:renderActionRow,
tbody:'tb-actions',pager:'pager-actions',pageSize:10,
empty:'<tr><td colspan="5" class="empty">暂无自定义动作,可点击"新建动作"或用任务编辑器☑选中步骤打包</td></tr>'
});
applyListPager('actions');
initListSearch('actions','search-actions');
_stepEditor._renderLib(); // 刷新步骤编辑器左侧动作库
}
function renderActionRow(a){
const n=(a.steps||[]).length;
return '<tr>'+
'<td style="font-size:20px">'+esc(a.icon||'📦')+'</td>'+
'<td><strong>'+esc(a.name)+'</strong></td>'+
'<td>'+n+'</td>'+
'<td style="font-size:12px;color:#94a3b8">'+esc(a.created_at||'-')+'</td>'+
'<td>'+
(_can('tasks')?(
'<button class="btn btn-xs" onclick="openActionModal(\''+a.id+'\')">编辑</button> '+
'<button class="btn btn-danger btn-xs" onclick="deleteAction(\''+a.id+'\')">删除</button>'
):'<span class="text-muted">只读</span>')+
'</td>'+
'</tr>';
}
function openActionModal(actionId){
const action=actionId?_customActions.find(a=>a.id===actionId):null;
const isEdit=!!action;
document.getElementById('action-modal-title').textContent=isEdit?'编辑动作':'新建动作';
document.getElementById('action-modal-body').innerHTML=
'<div class="form-row">'+
'<div class="form-group"><label>动作名称</label><input type="text" class="form-control" id="a-name" value="'+esc(action?action.name:'')+'" placeholder="如:点赞动作"></div>'+
'<div class="form-group"><label>图标</label><input type="text" class="form-control" id="a-icon" value="'+esc(action?(action.icon&&action.icon!=='📦'?action.icon:''):'')+'" placeholder="📦" style="width:80px"></div>'+
'</div>'+
'<div class="help" style="margin-bottom:8px">用下方步骤编辑器编排动作内容,保存后可拖入任务步骤编辑器复用</div>'+
'<div class="step-editor" style="display:block">'+
'<div class="se-head"><span>动作步骤</span><span style="font-size:11px;color:var(--text-light);margin-left:12px">支持循环/分组/概率触发/拖拽排序</span>'+
(_can('tasks')?'<button class="btn btn-xs btn-primary" id="se-pack-btn2" disabled onclick="_stepEditor.saveAsCustomAction()">打包选中步骤</button>':'')+'</div>'+
'<div class="se-body">'+
'<div class="se-lib" id="action-lib"><div class="lib-title">操作库</div></div>'+
'<div class="se-canvas" id="action-canvas"></div>'+
'</div>'+
'</div>';
document.getElementById('action-modal-footer').innerHTML=
'<button class="btn" onclick="closeActionModal()">取消</button>'+
'<button class="btn btn-primary" onclick="saveAction('+(isEdit?'\''+actionId+'\'':'null')+')">保存动作</button>';
// 用独立画布初始化步骤编辑器(不与任务编辑器冲突)
_stepEditor.init(action&&action.steps?action.steps:[], 0, 'action:'+(actionId||'new'), 'action-canvas', 'action-lib');
document.getElementById('action-modal-overlay').classList.add('show');
}
async function saveAction(actionId){
const name=document.getElementById('a-name').value.trim();
if(!name){showToast('动作名不能为空','error');return;}
const icon=document.getElementById('a-icon').value.trim()||'📦';
const steps=_stepEditor.collect();
_stripIds(steps); // 入库前去掉 id,避免重复使用后 id 冲突
if(!steps.length){showToast('至少需要1个步骤','error');return;}
const data={name:name,icon:icon,steps:steps};
if(actionId){
const r=await apiPut('/api/custom_actions/'+actionId,data);
handleResult(r,()=>{_stepEditor.clearDraft();closeActionModal();loadCustomActions();});
}else{
const r=await apiPost('/api/custom_actions',data);
handleResult(r,()=>{_stepEditor.clearDraft();closeActionModal();loadCustomActions();});
}
}
async function deleteAction(id){
if(!confirm('确认删除此动作?'))return;
const r=await apiDelete('/api/custom_actions/'+id);
handleResult(r,()=>loadCustomActions());
}
function closeActionModal(){
document.getElementById('action-modal-overlay').classList.remove('show');
}
+494
View File
@@ -0,0 +1,494 @@
// 工具 Tab:剪贴板注入/应用版本/设备池管理 + adb终端 + Tailscale管理 + 远程看屏
// ================== 工具:剪贴板注入 ==================
let _clipDevices = []; // [{serial, state}],来源 /api/adb/devices(本地 adb + 设备池)
let _clipSelected = new Set();
// 本地 adb 设备列表共享缓存(剪贴板/终端两个面板同源,10 秒内复用避免重复请求;force 强制刷新)
let _adbDevicesCache = null; // {ts, data}
async function _fetchAdbDevices(force){
const now = Date.now();
if(!force && _adbDevicesCache && now - _adbDevicesCache.ts < 10000){
return _adbDevicesCache.data;
}
const r = await apiGet('/api/adb/devices');
if(!r || !r.ok) return null;
_adbDevicesCache = {ts: now, data: r.devices || []};
return _adbDevicesCache.data;
}
async function loadToolsDevices(force){
const list = await _fetchAdbDevices(force);
const status = document.getElementById('clip-status');
if(list === null){status.textContent='加载失败';showToast('加载设备失败','error');return;}
// 剪贴板注入只对在线设备有意义:过滤掉设备池未连接(pool)/离线条目;
// adb 终端面板保留全部(重连设备需要),它用自己的渲染逻辑不受影响
_clipDevices = list.filter(d=>d.state==='device');
const wrap = document.getElementById('clip-devices');
if(!_clipDevices.length){
wrap.innerHTML='<div class="empty" style="padding:20px">无可用设备(本地 adb 或设备池均无)</div>';
status.textContent='';
return;
}
wrap.innerHTML = _clipDevices.map(d=>{
const st = d.state==='device' ? '已连接' : (d.state==='pool' ? '设备池' : d.state);
const stCls = d.state==='device' ? 'label-success' : 'label-warning';
return '<label data-serial="'+esc(d.serial)+'">'
+'<input type="checkbox" value="'+esc(d.serial)+'"'+( _clipSelected.has(d.serial)?' checked':'')+' onchange="toggleClipDevice(this)">'
+'<span style="font-family:monospace;flex:1;overflow:hidden;text-overflow:ellipsis">'+esc(d.serial)+'</span>'
+'<span class="label '+stCls+'">'+st+'</span></label>';
}).join('');
status.textContent = '共 '+_clipDevices.length+' 台设备';
}
// ================== 设备自动发现(扫描 → 待连接池 → 确认) ==================
function loadDiscovery(){
apiGet('/api/devices/discovery').then(r=>{
if(!r||!r.ok)return;
const status=document.getElementById('discovery-status');
const enabled=document.getElementById('discovery-enabled');
const interval=document.getElementById('discovery-interval');
const subnets=document.getElementById('discovery-subnets');
if(!enabled)return; // 面板未渲染(离开子分栏)
enabled.checked=!!r.enabled;
interval.value=r.interval||60;
subnets.value=(r.subnets||[]).join(', ');
// 状态行
if(r.scanning){status.textContent='扫描中...';}
else if(r.last_scan){
let txt='上次扫描 '+esc(r.last_scan)+' · 开放 '+r.last_result.found+' · 可连 '
+(r.last_result.verified||0)+' · 新增 '+(r.last_result.new||0);
if(r.last_error)txt+=' · ⚠ '+esc(r.last_error);
status.textContent=txt;
}else{status.textContent='尚未扫描';}
// 待连接表格:只显示在线设备(离线候选不可确认连接,隐藏;恢复在线后自动出现)
const rows=(r.pending||[]).filter(x=>x.online);
const tb=document.getElementById('tb-discovery');
if(!tb)return;
if(!rows.length){
tb.innerHTML='<tr><td colspan="6" class="empty">待连接池为空(扫描到的设备会出现在这里)</td></tr>';
}else{
tb.innerHTML=rows.map(x=>{
const src=x.source==='tailscale'
?'<span class="label label-info">Tailscale</span>'
:'<span class="label label-warning">局域网</span>';
const onl=x.online
?'<span class="label label-success">在线</span>'
:'<span class="label label-warning">离线</span>';
return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'
+'<td>'+src+'</td><td>'+esc(x.first_seen)+'</td><td>'+esc(x.last_seen)+'</td><td>'+onl+'</td>'
+'<td><button class="btn btn-primary btn-xs" data-serial="'+esc(x.serial)+'" onclick="confirmDiscoveryDev(this.dataset.serial)">确认添加</button> '
+'<button class="btn btn-xs" data-serial="'+esc(x.serial)+'" onclick="ignoreDiscoveryDev(this.dataset.serial)">忽略</button></td></tr>';
}).join('');
}
// 正式池断联设备:仍在设备池(不删除),扫描线程自动重连,可手动立即重连
const offTb=document.getElementById('tb-pool-offline');
if(offTb){
const off=(r.pool_offline||[]);
if(!off.length){
offTb.innerHTML='<tr><td colspan="5" class="empty">全部在线</td></tr>';
}else{
offTb.innerHTML=off.map(x=>{
return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'
+'<td>'+esc(x.model||'-')+'</td><td>'+esc(x.name||'')+'</td>'
+'<td><span class="label label-danger">断联·自动重连中</span></td>'
+'<td><button class="btn btn-xs" data-serial="'+esc(x.serial)+'" onclick="reconnectPoolDev(this.dataset.serial)">立即重连</button></td></tr>';
}).join('');
}
}
});
}
function reconnectPoolDev(serial){
apiPost('/api/devices/discovery/reconnect',{serial}).then(r=>{
if(r&&r.ok){
showToast('重连已启动(约 5-15 秒生效)','success');
setTimeout(loadDiscovery,8000);
}else showToast('重连失败: '+((r&&r.error)||''),'error');
});
}
function toggleDiscovery(checked){
apiPost('/api/devices/discovery/settings',{enabled:checked}).then(r=>{
if(r&&r.ok)showToast('定时扫描已'+(checked?'开启':'关闭'),'success');
else showToast('设置失败: '+((r&&r.error)||''),'error');
});
}
function saveDiscoverySettings(){
const subnets=(document.getElementById('discovery-subnets').value||'')
.split(',').map(s=>s.trim()).filter(Boolean);
const interval=parseInt(document.getElementById('discovery-interval').value)||60;
apiPost('/api/devices/discovery/settings',{subnets,interval}).then(r=>{
if(r&&r.ok)showToast('设置已保存','success');
else showToast('保存失败: '+((r&&r.error)||''),'error');
});
}
function runDiscoveryScan(){
apiPost('/api/devices/discovery/scan',{}).then(r=>{
if(r&&r.ok){showToast(r.msg||'扫描已启动','success');setTimeout(loadDiscovery,3000);}
else showToast('扫描失败: '+((r&&r.error)||''),'error');
});
}
function confirmDiscoveryDev(serial){
const name=prompt('备注名(可选,留空直接添加)','');
if(name===null)return;
apiPost('/api/devices/discovery/confirm',{serial,name}).then(r=>{
if(r&&r.ok){
showToast('已加入设备池'+(r.is_new?'':'(已存在,已更新)'),'success');
loadDevPool();loadDiscovery();
}else showToast('确认失败: '+((r&&r.error)||''),'error');
});
}
function ignoreDiscoveryDev(serial){
if(!confirm('忽略 '+serial+'?下轮扫描可能再次发现'))return;
apiPost('/api/devices/discovery/ignore',{serial}).then(r=>{
if(r&&r.ok){showToast('已忽略','success');loadDiscovery();}
else showToast('操作失败: '+((r&&r.error)||''),'error');
});
}
function toggleClipDevice(cb){
if(cb.checked)_clipSelected.add(cb.value);
else _clipSelected.delete(cb.value);
}
// 注入剪贴板(ClipInject APK 通道,Android 10+ 可用):
// am start 透明 Activity 写入 + u2 读回比对验证;结果逐台渲染 + toast 汇总
function injectClipboard(){
const text = document.getElementById('clip-text').value;
const serials = [..._clipSelected];
if(!serials.length){showToast('请先勾选目标设备','error');return;}
if(!text.trim()){showToast('请输入注入内容','error');return;}
const btn = Array.from(document.querySelectorAll('#tools-sub-clipboard .btn-primary'))
.find(b=>b.textContent.includes('注入剪贴板'));
if(btn){btn.disabled = true; const old = btn.textContent; btn.textContent = '注入中...'; btn._old = old;}
apiPost('/api/tools/clipboard/set', {serials, text}).then(r=>{
if(!r){showToast('请求失败','error');return;}
const rows = Object.entries(r.results||{}).map(([s,v])=>{
const ok = !!v.ok;
return '<tr><td style="font-family:monospace">'+esc(s)+'</td>'
+'<td><span class="label '+(ok?'label-success':'label-danger')+'">'
+(ok?'成功':'失败')+'</span> '+esc(v.msg||'')+'</td></tr>';
}).join('');
document.getElementById('clip-result').innerHTML = rows;
document.getElementById('clip-result-wrap').style.display='block';
const okc = r.ok_count||0, fail = r.fail_count||0;
if(fail>0) showToast(okc+' 台成功,'+fail+' 台失败','error');
else showToast(okc+' 台设备注入成功','success');
}).catch(e=>{showToast('注入异常: '+e,'error');})
.finally(()=>{ if(btn){btn.disabled=false; btn.textContent=btn._old;} });
}
// ================== 工具:应用版本管理 ==================
let _appVerRows = []; // [{serial, installed, version_name, version_code, error}]
async function queryAppVers(){
const pkg = document.getElementById('appver-pkg').value.trim();
if(!pkg){showToast('请输入包名','error');return;}
const btn = document.querySelector('#tools-sub-appver .btn-primary');
btn.disabled = true; const old = btn.textContent; btn.textContent = '查询中...';
try{
const r = await apiPost('/api/tools/appver', {pkg});
if(!r || !r.ok){showToast('查询失败: '+(r&&r.error||''),'error');return;}
_appVerRows = Object.entries(r.results||{}).map(([serial,v])=>({serial, ...v}));
setListPager('appver',{
data:()=>_appVerRows,
filter:(x,kw)=>((x.serial||'').toLowerCase().includes(kw)||(x.version_name||'').toLowerCase().includes(kw)||(x.version_code||'').toLowerCase().includes(kw)),
sortValue:(x,k)=>{
if(k==='state'){ if(x.error)return 2; return x.installed?0:1; }
if(k==='code')return parseInt(x.version_code)||0;
if(k==='version')return x.version_name||'';
return x.serial||'';
},
render:renderAppVerRow,
tbody:'tb-appver',pager:'pager-appver',pageSize:10,
empty:'<tr><td colspan="4" class="empty">无可用设备</td></tr>'
});
applyListPager('appver');
initListSearch('appver','search-appver');
document.getElementById('appver-status').textContent = '共 '+(r.total||0)+' 台设备'+(r.fail?','+r.fail+' 台查询失败':'');
if(r.fail)showToast(r.fail+' 台设备查询失败,详见表格','error');
} finally {
btn.disabled = false; btn.textContent = old;
}
}
function renderAppVerRow(x){
if(x.error)return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'+
'<td><span class="label label-warning">查询失败</span></td><td colspan="2">'+esc(x.error)+'</td></tr>';
if(x.installed)return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'+
'<td><span class="label label-success">已安装</span></td>'+
'<td>'+esc(x.version_name||'')+'</td><td style="font-family:monospace">'+esc(x.version_code||'')+'</td></tr>';
return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'+
'<td><span class="label label-default">未安装</span></td><td>-</td><td>-</td></tr>';
}
// ================== Tab: 维护(仅管理员) ==================
// 服务器类命令(不需要 -s 目标设备)
const _NO_DEVICE_CMDS=['devices','version','help','start-server','connect','kill-server','disconnect'];
function _selectedSerial(){
const sel=document.getElementById('adb-device');
return sel?sel.value:'';
}
// 选中目标设备时自动附加 -s <serial>;服务器类命令保持原样
function _attachDevice(cmd){
const serial=_selectedSerial();
if(!serial)return cmd;
if(/^-s\s/.test(cmd))return cmd; // 已手写 -s 不再重复附加
const first=(cmd.split(/\s+/)[0]||'').toLowerCase();
if(_NO_DEVICE_CMDS.indexOf(first)>=0)return cmd;
return '-s '+serial+' '+cmd;
}
async function loadAdbDevices(force){
const sel=document.getElementById('adb-device');
if(!sel)return;
const list=await _fetchAdbDevices(force);
if(list===null)return;
const cur=sel.value;
const stateTxt={device:'在线',offline:'离线',pool:'设备池(未连接)'};
const opts=['<option value="">(不指定设备)</option>'].concat(
list.map(d=>
'<option value="'+esc(d.serial)+'">'+esc(d.serial)+' ('+(stateTxt[d.state]||d.state)+')</option>'
)
);
sel.innerHTML=opts.join('');
if(cur)sel.value=cur;
}
function fillAdbCmd(cmd){
document.getElementById('adb-cmd-input').value=cmd;
}
async function _runAdbFull(full){
const out=document.getElementById('adb-output');
out.textContent='$ '+full+'\n执行中...';
const r=await apiPost('/api/adb/cmd',{cmd:full});
if(!r)return;
if(!r.ok){out.textContent='$ '+full+'\n⚠ '+r.error;return;}
out.textContent='$ '+full+'\n\n'+(r.stdout||'')+(r.stderr?'\n[stderr] '+r.stderr:'')+'\n\n[exit '+r.code+']';
out.scrollTop=out.scrollHeight;
}
async function runAdbCmd(){
const input=document.getElementById('adb-cmd-input');
let cmd=(input.value||'').trim();
if(!cmd){showToast('请输入 adb 命令','error');return;}
cmd=cmd.replace(/^adb\s+/i,''); // 兼容手输/快捷按钮带的 "adb " 前缀,避免拼成 "adb adb devices"
cmd=_attachDevice(cmd);
await _runAdbFull('adb '+cmd);
}
function reconnectAdbDevice(){
const serial=_selectedSerial();
if(!serial){showToast('请先选择目标设备','error');return;}
_runAdbFull('adb connect '+serial);
}
// ================== Tab: 维护 - Tailscale 管理 ==================
// 与其他表格共用 setListPager/applyListPager 统一逻辑(搜索 + 分页)
let _tsDevices=[];
async function loadTailscaleDevices(){
const tb=document.getElementById('tb-tailscale');
const st=document.getElementById('ts-status');
const hint=document.getElementById('ts-hint');
if(!tb)return;
tb.innerHTML='<tr><td colspan="8" class="empty">加载中...</td></tr>';
const r=await apiGet('/api/tailscale/devices');
if(!r)return;
if(!r.ok){
tb.innerHTML='<tr><td colspan="8" class="empty">'+esc(r.error||'获取失败')+'</td></tr>';
if(st)st.textContent='';
const s=await apiGet('/api/tailscale/status');
if(s&&s.ok&&!s.configured&&hint)hint.textContent='⚠ 未配置:'+esc(s.hint||'')+'(写入 .env 后重启服务生效)';
return;
}
if(hint)hint.textContent='';
_tsDevices=r.devices||[];
setListPager('tailscale',{
data:()=>_tsDevices,
filter:(d,kw)=>((d.hostname||'').toLowerCase().includes(kw)||(d.name||'').toLowerCase().includes(kw)||(d.addresses||[]).join(',').includes(kw)),
sortValue:(d,k)=>{
if(k==='ip')return (d.addresses||[])[0]||'';
if(k==='online'){ if(d.online===true)return 0; if(d.online===false)return 2; return 1; } // null=最近12h在线
if(k==='authorized')return d.authorized?1:0;
if(k==='key_expiry')return d.key_expiry_disabled?1:0;
return d[k]||'';
},
render:renderTsRow,
tbody:'tb-tailscale',pager:'pager-tailscale',pageSize:10,
empty:'<tr><td colspan="8" class="empty">tailnet 暂无设备</td></tr>'
});
applyListPager('tailscale');
initListSearch('tailscale','search-tailscale');
if(st)st.textContent='共 '+_tsDevices.length+' 台设备';
}
function renderTsRow(d){
const ip=(d.addresses||[])[0]||'-';
return '<tr>'+
'<td><code>'+esc(d.hostname||'-')+'</code></td>'+
'<td>'+esc(d.name||'-')+'</td>'+
'<td style="font-family:monospace">'+esc(ip)+'</td>'+
'<td>'+esc(d.os||'-')+'</td>'+
'<td>'+((d.online===true)?'<span class="label label-success">在线</span>':(d.online===false?'<span class="label label-default">离线</span>':'<span class="label label-warning" title="12 小时内见过但当前未上报">最近在线</span>'))+'</td>'+
'<td>'+(d.authorized?'<span class="label label-success">已授权</span>':'<span class="label label-danger">未授权</span>')+'</td>'+
'<td>'+(d.key_expiry_disabled?'<span class="label label-warning">永不过期</span>':'<span class="label label-default">会过期</span>')+'</td>'+
'<td style="white-space:nowrap">'+
'<button class="btn btn-xs" onclick="renameTailscaleDevice(\''+esc(d.id)+'\',\''+esc(d.name||'')+'\')">改名</button> '+
'<button class="btn btn-xs" onclick="setTsIp(\''+esc(d.id)+'\',\''+esc((d.addresses||[])[0]||'')+'\')" title="修改设备的 Tailscale IPv4(会断开当前连接,平台 serial 随之变化)">设置IP</button> '+
'<button class="btn btn-xs" onclick="toggleTsAuth(\''+esc(d.id)+'\','+(!d.authorized)+')">'+(d.authorized?'取消授权':'授权')+'</button> '+
'<button class="btn btn-xs" onclick="toggleTsKeyExpiry(\''+esc(d.id)+'\','+(!d.key_expiry_disabled)+')">'+(d.key_expiry_disabled?'恢复过期':'密钥不过期')+'</button> '+
'<button class="btn btn-danger btn-xs" onclick="deleteTailscaleDevice(\''+esc(d.id)+'\')">移除</button>'+
'</td></tr>';
}
function renameTailscaleDevice(id, current){
const name=prompt('修改显示名(只改名称,不影响主机名):', current||'');
if(name===null)return;
apiPost('/api/tailscale/devices/'+id,{name:name}).then(r=>{
if(!r)return;
if(r.ok){showToast(r.msg,'success');loadTailscaleDevices();}
else showToast(r.error,'error');
});
}
function setTsIp(id, current){
const ip=prompt('设置新的 Tailscale IPv4 地址(如 100.100.10.16):\n\n⚠ 改 IP 会断开设备当前 tailscale 连接,且平台设备池(serial)随之变化,改完记得同步设备池/分组/任务目标。', (current||'').split(':')[0]||'');
if(ip===null)return;
const v=ip.trim();
if(!v){showToast('IP 不能为空','error');return;}
apiPost('/api/tailscale/devices/'+id+'/ip',{ipv4:v}).then(r=>{
if(!r)return;
if(r.ok){showToast(r.msg,'success');loadTailscaleDevices();}
else showToast(r.error,'error');
});
}
function toggleTsAuth(id, authorized){
apiPost('/api/tailscale/devices/'+id,{authorized:authorized}).then(r=>{
if(!r)return;
if(r.ok){showToast(authorized?'已授权':'已取消授权','success');loadTailscaleDevices();}
else showToast(r.error,'error');
});
}
function toggleTsKeyExpiry(id, disabled){
apiPost('/api/tailscale/devices/'+id,{key_expiry_disabled:disabled}).then(r=>{
if(!r)return;
if(r.ok){showToast(disabled?'已设置密钥永不过期(设备不再被踢下线)':'已恢复密钥定期过期','success');loadTailscaleDevices();}
else showToast(r.error,'error');
});
}
function deleteTailscaleDevice(id){
if(!confirm('确定从 tailnet 移除该设备?\n设备下次上线需重新授权。'))return;
apiDelete('/api/tailscale/devices/'+id).then(r=>{
if(!r)return;
if(r.ok){showToast(r.msg,'success');loadTailscaleDevices();}
else showToast(r.error,'error');
});
}
function openTailscaleAuthKey(){
const desc=prompt('auth key 用途说明(可空,仅支持英文/数字):','auto_control');
if(desc===null)return;
const reusable=confirm('允许复用(同一 key 给多台设备用)?\n确定=可复用,取消=一次性');
const secs=prompt('有效期(秒,默认 3600=1 小时):','3600');
if(secs===null)return;
const st=document.getElementById('ts-status');
if(st)st.textContent='正在生成 auth key...';
apiPost('/api/tailscale/authkey',{description:desc||'',reusable:reusable,expiry_seconds:parseInt(secs)||3600}).then(r=>{
if(!r)return;
if(st)st.textContent='';
if(r.ok){
const ov=document.getElementById('test-overlay');
document.getElementById('test-modal-title').textContent='Tailscale Auth Key(只显示一次)';
document.getElementById('test-modal-body').innerHTML=
'<div style="margin-bottom:10px">新设备执行 <code>tailscale up --authkey=...</code> 接入(默认已预授权)</div>'+
'<textarea id="ts-key-box" class="form-control" rows="4" style="font-family:monospace" readonly>'+esc(r.key||'')+'</textarea>'+
'<button class="btn btn-primary" style="margin-top:10px" onclick="copyTsKey()">复制</button>'+
' <span id="ts-key-status" class="text-muted"></span>';
ov.classList.add('show');
showToast('Auth Key 已生成','success');
}else{
showToast(r.error,'error');
}
});
}
function copyTsKey(){
const box=document.getElementById('ts-key-box');
if(!box)return;
box.select();
try{document.execCommand('copy');}catch(e){}
const s=document.getElementById('ts-key-status');
if(s)s.textContent='已复制';
}
// ================== Tab: 工具 - 设备池管理(本地 SQLite 清单) ==================
async function loadDevPool(){
const tb=document.getElementById('tb-devpool');
const status=document.getElementById('devpool-status');
if(!tb)return;
tb.innerHTML='<tr><td colspan="7" class="empty">加载中...</td></tr>';
const r=await apiGet('/api/devices/pool');
if(!r||!r.ok){tb.innerHTML='<tr><td colspan="7" class="empty">加载失败</td></tr>';return;}
let rows=r.devices||[];
// 在线优先排序;「只看在线」勾选时过滤掉离线(IP 换了/长期离线的旧条目不再碍眼)
const onlyOnline=document.getElementById('devpool-filter-online')
? document.getElementById('devpool-filter-online').checked : false;
rows.sort((a,b)=>(b.online?1:0)-(a.online?1:0));
if(onlyOnline)rows=rows.filter(x=>x.online);
const onlineCnt=rows.filter(x=>x.online).length;
status.textContent=onlyOnline
? '在线 '+rows.length+' 台(共 '+(r.devices||[]).length+' 台配置)'
: '共 '+rows.length+' 台,在线 '+onlineCnt+' 台';
tb.innerHTML=rows.map(d=>{
const st= d.enabled
? '<span class="label label-success">启用</span>'
: '<span class="label label-default">停用</span>';
const online= d.online
? '<span class="label label-success">在线</span>'
: '<span class="label label-warning">离线</span>';
return '<tr>'+
'<td style="font-family:monospace">'+esc(d.serial)+'</td>'+
'<td>'+esc(d.name||'')+'</td>'+
'<td>'+esc(d.model||'<span style="color:var(--text-light)">-</span>')+'</td>'+
'<td>'+online+'</td>'+
'<td>'+st+'</td>'+
'<td style="color:var(--text-light)">'+esc(d.created_at||'')+'</td>'+
'<td style="white-space:nowrap">'+
'<button class="btn btn-xs" onclick="togglePoolDev(\''+esc(d.serial)+'\','+(!d.enabled)+')">'+(d.enabled?'停用':'启用')+'</button> '+
'<button class="btn btn-xs btn-danger" onclick="removePoolDev(\''+esc(d.serial)+'\')">删除</button>'+
'</td></tr>';
}).join('')||'<tr><td colspan="7" class="empty">设备池为空,添加第一台设备开始</td></tr>';
}
async function addPoolDev(){
const serial=document.getElementById('devpool-serial').value.trim();
const name=document.getElementById('devpool-name').value.trim();
if(!serial){showToast('请输入 serial','error');return;}
const r=await apiPost('/api/devices/pool/add',{serial,name});
if(!r)return;
if(r.ok){
showToast(r.msg+(serial.includes(':')?'(已尝试连接)':''),'success');
document.getElementById('devpool-serial').value='';
document.getElementById('devpool-name').value='';
loadDevPool();
}else showToast(r.error,'error');
}
async function removePoolDev(serial){
if(!confirm('从设备池删除 '+serial+'?\n\n删除后不再参与任务调度(不影响设备本身与其他功能)。'))return;
const r=await apiPost('/api/devices/pool/remove',{serial});
if(!r)return;
if(r.ok){showToast(r.msg,'success');loadDevPool();}
else showToast(r.error,'error');
}
async function togglePoolDev(serial,enabled){
const r=await apiPost('/api/devices/pool/toggle',{serial,enabled});
if(!r)return;
if(r.ok){showToast(r.msg,'success');loadDevPool();}
else showToast(r.error,'error');
}
async function reconnectPoolDevices(){
if(!confirm('并发 adb connect 设备池内全部设备?\n\n已连接的自动跳过。'))return;
const r=await apiPost('/api/devices/pool/reconnect',{});
if(!r)return;
if(r.ok){showToast(r.msg,'success');setTimeout(loadDevPool,12000);}
else showToast(r.error,'error');
}
async function refreshPoolModels(){
const r=await apiPost('/api/devices/pool/refresh_models',{});
if(!r)return;
if(r.ok){showToast(r.msg,'success');setTimeout(loadDevPool,12000);}
else showToast(r.error,'error');
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3 -3
View File
@@ -49,8 +49,8 @@
from .actions import get_action_class from .actions import get_action_class
return get_action_class(action_type) return get_action_class(action_type)
def create_worker(self, stf, serial, params): def create_worker(self, serial, params):
return MyWorker(stf, serial, params=params) return MyWorker(serial, params=params)
5. 在 my_task/__init__.py 加:from . import task (触发注册) 5. 在 my_task/__init__.py 加:from . import task (触发注册)
6. 在 tasks/__init__.py 加:from .my_task import task (触发注册) 6. 在 tasks/__init__.py 加:from .my_task import task (触发注册)
@@ -85,7 +85,7 @@ class BaseTask:
description = "" description = ""
default_params = {} default_params = {}
def create_worker(self, stf, serial, params): def create_worker(self, serial, params):
"""返回一个 threading.Thread(已启动或待启动),执行实际任务。""" """返回一个 threading.Thread(已启动或待启动),执行实际任务。"""
raise NotImplementedError raise NotImplementedError
+4 -4
View File
@@ -52,8 +52,8 @@ class DouyinNurtureWorker(BaseWorker):
操作参数优先级:任务 params.actions > 本文件 DEFAULT_PARAMS.actions。 操作参数优先级:任务 params.actions > 本文件 DEFAULT_PARAMS.actions。
""" """
def __init__(self, stf_client, serial, params=None): def __init__(self, serial, params=None):
super().__init__(stf_client, serial, params) super().__init__(serial, params)
p = {**DEFAULT_PARAMS, **(self.params or {})} p = {**DEFAULT_PARAMS, **(self.params or {})}
self.watch_count = int(p["watch_count"]) self.watch_count = int(p["watch_count"])
self.watch_min = float(p["watch_min"]) self.watch_min = float(p["watch_min"])
@@ -244,7 +244,7 @@ class DouyinNurtureTask(BaseTask):
"""按 action_type 取抖音操作类。""" """按 action_type 取抖音操作类。"""
return get_action_class(action_type) return get_action_class(action_type)
def create_worker(self, stf, serial, params): def create_worker(self, serial, params):
merged = {**DEFAULT_PARAMS, **(params or {})} merged = {**DEFAULT_PARAMS, **(params or {})}
# actions 字段深合并(参数级,保留前端没传的操作默认值) # actions 字段深合并(参数级,保留前端没传的操作默认值)
# 确保即使前端只传 actions.like.enabled,like.params.rate 也能拿到默认 0.3 # 确保即使前端只传 actions.like.enabled,like.params.rate 也能拿到默认 0.3
@@ -266,4 +266,4 @@ class DouyinNurtureTask(BaseTask):
merged_cfg["params"] = merged_params merged_cfg["params"] = merged_params
merged_actions[atype] = merged_cfg merged_actions[atype] = merged_cfg
merged["actions"] = merged_actions merged["actions"] = merged_actions
return DouyinNurtureWorker(stf, serial, params=merged) return DouyinNurtureWorker(serial, params=merged)
+58 -7
View File
@@ -69,6 +69,8 @@ STEP_TYPES = [
"params": {"selector_type": "xpath", "selector_value": "", "timeout": 10}}, "params": {"selector_type": "xpath", "selector_value": "", "timeout": 10}},
{"type": "input_text", "label": "输入文字", "icon": "⌨", {"type": "input_text", "label": "输入文字", "icon": "⌨",
"params": {"mode": "random", "texts": "你好\n有趣\n支持", "fixed_text": "", "clear_first": True}}, "params": {"mode": "random", "texts": "你好\n有趣\n支持", "fixed_text": "", "clear_first": True}},
{"type": "clipboard", "label": "剪贴板注入", "icon": "📋",
"params": {"text": "", "paste": True}},
{"type": "wait", "label": "等待", "icon": "⏱", {"type": "wait", "label": "等待", "icon": "⏱",
"params": {"min": 1.0, "max": 3.0}}, "params": {"min": 1.0, "max": 3.0}},
{"type": "loop", "label": "循环块", "icon": "↻", {"type": "loop", "label": "循环块", "icon": "↻",
@@ -108,8 +110,8 @@ class GenericStepsWorker(BaseWorker):
只实现 run_task,按步骤类型分发到 _exec_<type> 方法。 只实现 run_task,按步骤类型分发到 _exec_<type> 方法。
""" """
def __init__(self, stf_client, serial, params=None): def __init__(self, serial, params=None):
super().__init__(stf_client, serial, params) super().__init__(serial, params)
p = {**DEFAULT_PARAMS, **(self.params or {})} p = {**DEFAULT_PARAMS, **(self.params or {})}
self.max_duration = int(p.get("max_duration", 0)) self.max_duration = int(p.get("max_duration", 0))
self.steps = p.get("steps", []) self.steps = p.get("steps", [])
@@ -475,17 +477,66 @@ class GenericStepsWorker(BaseWorker):
_log.warning(f"[{self.serial}] click 异常: {e}") _log.warning(f"[{self.serial}] click 异常: {e}")
return found # 命中结果,"测试此步骤"用 return found # 命中结果,"测试此步骤"用
def _exec_clipboard(self, d, params, depth=0):
"""剪贴板注入(ClipInject 通道,Android 10+ 可用),可选立即粘贴。
批量发链接场景:先聚焦评论区输入框 → 注入+粘贴链接1 → 换链接 → 注入+粘贴链接2……
粘贴模式:ClipInject 写入剪贴板后调 atx-agent pasteClipboard 粘贴到当前
焦点输入框;动作失败时兜底 u2 set_text 直接设置聚焦输入框文本(不依赖
剪贴板,与"输入文字"兜底同机制)。
"""
text = str(params.get("text", "") or "")
if not text:
_log.warning(f"[{self.serial}] clipboard 内容为空,跳过")
return False
from core.clipboard_helper import inject_clipboard
ok, msg = inject_clipboard(self.serial, text, d=d)
if not ok:
_log.warning(f"[{self.serial}] 剪贴板注入失败: {msg}")
return False
_log.info(f"[{self.serial}] 剪贴板注入: {text[:60]}({msg})")
if not params.get("paste"):
return True
try:
d.jsonrpc.pasteClipboard()
_log.info(f"[{self.serial}] 已粘贴: {text[:60]}")
return True
except Exception as e:
try:
d(focused=True).set_text(text)
_log.info(f"[{self.serial}] 粘贴兜底 set_text: {text[:60]}")
return True
except Exception as e2:
_log.warning(f"[{self.serial}] 粘贴失败: {e} / {e2}")
return True # 剪贴板已写入,粘贴失败不致命
def _exec_wait(self, d, params, depth=0): def _exec_wait(self, d, params, depth=0):
min_s = float(params.get("min", 1.0)) min_s = float(params.get("min", 1.0))
max_s = float(params.get("max", 3.0)) max_s = float(params.get("max", 3.0))
random_sleep(min_s, max_s) # 分片 sleep:每 0.5s 检查停止/超时信号,抢占任务能及时接管(纯 sleep 无法中断)
total = random.uniform(min_s, max_s)
end = time.time() + total
while time.time() < end:
if self.stopped() or self.is_time_up():
return
time.sleep(min(0.5, end - time.time()))
def _exec_loop(self, d, params, depth=0): def _exec_loop(self, d, params, depth=0):
children = params.get("children", []) children = params.get("children", [])
if not children: if not children:
return return
mode = params.get("loop_mode", "rounds") mode = params.get("loop_mode", "rounds")
if mode == "time": if mode == "forever":
# 一直循环:无限跑直到任务被外部停止(定时停止 cron / 运行时长上限 / 手动停止)。
# 配合"定时启动+停止"调度(如 9:00 启动 / 18:00 停止)实现全天候运行。
i = 0
while not self.stopped() and not self.is_time_up():
i += 1
self.set_action(f"循环第 {i} 轮(持续)")
if i % 10 == 0 or i <= 2:
_log.info(f"[{self.serial}] loop 第 {i} 轮(持续运行)")
self._exec_steps(d, children, depth + 1)
elif mode == "time":
# 按时间循环:跑满 loop_duration 秒,同时受全局 stopped/max_duration 约束 # 按时间循环:跑满 loop_duration 秒,同时受全局 stopped/max_duration 约束
duration = float(params.get("loop_duration", 0) or 0) duration = float(params.get("loop_duration", 0) or 0)
if duration <= 0: if duration <= 0:
@@ -574,9 +625,9 @@ class GenericStepsTask(BaseTask):
def get_action_class(cls, action_type): def get_action_class(cls, action_type):
return None return None
def create_worker(self, stf, serial, params): def create_worker(self, serial, params):
merged = {**DEFAULT_PARAMS, **(params or {})} merged = {**DEFAULT_PARAMS, **(params or {})}
return GenericStepsWorker(stf, serial, params=merged) return GenericStepsWorker(serial, params=merged)
def test_step(serial, step): def test_step(serial, step):
@@ -605,7 +656,7 @@ def test_step(serial, step):
if d is None: if d is None:
return False, "u2 连接超时", None return False, "u2 连接超时", None
try: try:
w = GenericStepsWorker(None, serial, {}) w = GenericStepsWorker(serial, {})
result = w._exec_one(d, step) result = w._exec_one(d, step)
return True, f"步骤已执行({step.get('label') or step.get('type')})", result return True, f"步骤已执行({step.get('label') or step.get('type')})", result
except Exception as e: except Exception as e:
+439 -237
View File
@@ -5,210 +5,246 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>设备自动化后台</title> <title>设备自动化后台</title>
<style> <style>
/* ========== 仪表控制台主题(Instrument Console) ==========
自托管字体:Bricolage Grotesque(展示/数字)+ IBM Plex Mono(数据/串号)
配色:暖纸底 + 墨色文字 + 深青主色;状态色 = 绿/红/琥珀/靛 */
@font-face{font-family:'Bricolage Grotesque';font-style:normal;font-weight:500;font-display:swap;src:url('/static/fonts/bricolage-500.woff2') format('woff2')}
@font-face{font-family:'Bricolage Grotesque';font-style:normal;font-weight:700;font-display:swap;src:url('/static/fonts/bricolage-700.woff2') format('woff2')}
@font-face{font-family:'Bricolage Grotesque';font-style:normal;font-weight:800;font-display:swap;src:url('/static/fonts/bricolage-800.woff2') format('woff2')}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url('/static/fonts/plexmono-400.woff2') format('woff2')}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url('/static/fonts/plexmono-500.woff2') format('woff2')}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:600;font-display:swap;src:url('/static/fonts/plexmono-600.woff2') format('woff2')}
:root{ :root{
--primary:#3b82f6; --primary-dark:#2563eb; --success:#10b981; --danger:#ef4444; --primary:#0f766e; --primary-dark:#115e59; --primary-soft:#e6f4f1;
--warning:#f59e0b; --bg:#f8fafc; --card:#ffffff; --border:#e2e8f0; --success:#16a34a; --success-soft:#e8f7ee;
--text:#1e293b; --text-muted:#64748b; --text-light:#94a3b8; --danger:#dc2626; --danger-soft:#fdecec;
--warning:#d97706; --warning-soft:#fdf3e3;
--info:#4f46e5; --info-soft:#ecebfb;
--bg:#f5f4ef; --card:#ffffff; --border:#e6e3da; --border-strong:#d8d4c8;
--text:#1b1e24; --text-muted:#69707c; --text-light:#9aa1ab;
--ink:#14161a; --ink-2:#1e2127; --ink-line:#2a2e37;
--display:'Bricolage Grotesque','PingFang SC','Microsoft YaHei',sans-serif;
--mono:'IBM Plex Mono','SF Mono',Menlo,Consolas,monospace;
--body:-apple-system,'PingFang SC','Microsoft YaHei','Segoe UI',sans-serif;
--shadow-sm:0 1px 2px rgba(27,30,36,.05),0 1px 3px rgba(27,30,36,.06);
--shadow-md:0 4px 14px rgba(27,30,36,.08),0 2px 4px rgba(27,30,36,.05);
--shadow-lg:0 20px 50px rgba(20,22,26,.18);
--radius:10px;
} }
*{box-sizing:border-box;margin:0;padding:0} *{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);font-family:-apple-system,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;color:var(--text);font-size:14px} html{-webkit-font-smoothing:antialiased}
body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14px;line-height:1.5}
::-webkit-scrollbar{width:9px;height:9px}
::-webkit-scrollbar-thumb{background:#c9c5b9;border-radius:5px;border:2px solid var(--bg)}
::-webkit-scrollbar-thumb:hover{background:#b0ab9d}
::-webkit-scrollbar-track{background:transparent}
/* 顶部导航 */ /* 顶部导航:墨色 + 顶部青线 + 品牌脉冲点 */
.navbar{background:linear-gradient(135deg,#1e293b 0%,#334155 100%);padding:0 28px;display:flex;align-items:center;height:56px;box-shadow:0 2px 8px rgba(0,0,0,.15)} .navbar{background:linear-gradient(180deg,#1a1d23 0%,#14161a 100%);border-top:3px solid var(--primary);padding:0 28px;display:flex;align-items:center;height:58px;box-shadow:0 2px 10px rgba(0,0,0,.2);position:sticky;top:0;z-index:500}
.navbar .brand{color:#fff;font-weight:700;font-size:16px;margin-right:32px;white-space:nowrap} .navbar .brand{color:#f3f4f6;font-family:var(--display);font-weight:700;font-size:16px;letter-spacing:.3px;margin-right:34px;white-space:nowrap;display:flex;align-items:center;gap:9px}
.navbar .tabs{display:flex;gap:4px;flex:1} .brand-dot{width:9px;height:9px;border-radius:50%;background:var(--primary);box-shadow:0 0 0 0 rgba(15,118,110,.6);animation:brandPulse 2.4s infinite}
.navbar .tab{padding:8px 18px;color:#cbd5e1;font-size:14px;font-weight:500;cursor:pointer;border-radius:8px;transition:all .15s;border:none;background:none} @keyframes brandPulse{0%{box-shadow:0 0 0 0 rgba(15,118,110,.45)}70%{box-shadow:0 0 0 8px rgba(15,118,110,0)}100%{box-shadow:0 0 0 0 rgba(15,118,110,0)}}
.navbar .tab:hover{color:#fff;background:rgba(255,255,255,.08)} .navbar .tabs{display:flex;gap:2px;flex:1;align-self:stretch}
.navbar .tab.active{background:rgba(59,130,246,.25);color:#fff} .navbar .tab{position:relative;padding:0 18px;color:#9aa3af;font-size:14px;font-weight:500;cursor:pointer;border:none;background:none;transition:color .15s;letter-spacing:.2px}
.navbar .user-info{color:#94a3b8;font-size:13px;display:flex;align-items:center;gap:12px} .navbar .tab::after{content:"";position:absolute;left:14px;right:14px;bottom:0;height:2px;border-radius:2px;background:var(--primary);transform:scaleX(0);transition:transform .18s}
.navbar .user-info a{color:#cbd5e1;text-decoration:none;font-size:13px} .navbar .tab:hover{color:#e5e7eb}
.navbar .tab.active{color:#fff}
.navbar .tab.active::after{transform:scaleX(1)}
.navbar .user-info{color:#8a93a0;font-size:13px;display:flex;align-items:center;gap:14px}
.navbar .user-info a{color:#c2c9d3;text-decoration:none;font-size:13px;transition:color .15s}
.navbar .user-info a:hover{color:#fff} .navbar .user-info a:hover{color:#fff}
/* 内容区 */ /* 内容区 */
.content{max-width:1600px;margin:0 auto;padding:24px 28px} .content{max-width:1600px;margin:0 auto;padding:26px 30px 60px}
.tab-panel{display:none} .tab-panel{display:none}
.tab-panel.active{display:block} .tab-panel.active{display:block;animation:panelIn .22s ease-out}
.page-title{font-size:20px;font-weight:700;color:var(--text);margin:0 0 4px} @keyframes panelIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
.page-sub{font-size:13px;color:var(--text-light);margin-bottom:20px} .page-title{font-family:var(--display);font-size:21px;font-weight:800;letter-spacing:.2px;color:var(--text);margin:0 0 4px}
.page-sub{font-size:13px;color:var(--text-light);margin-bottom:22px}
/* 统计卡片 */ /* 统计卡片:左侧色条 + 等宽数字 */
.stats-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:14px;margin-bottom:24px} .stats-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:14px;margin-bottom:26px}
.stat-card{background:var(--card);border-radius:12px;padding:14px 16px;box-shadow:0 1px 3px rgba(0,0,0,.06);border:1px solid var(--border);position:relative;overflow:hidden} .stat-card{background:var(--card);border-radius:var(--radius);padding:15px 18px;box-shadow:var(--shadow-sm);border:1px solid var(--border);position:relative;overflow:hidden;transition:transform .15s,box-shadow .15s}
.stat-card::before{content:"";position:absolute;left:0;top:0;bottom:0;width:4px;background:var(--accent,#3b82f6)} .stat-card:hover{transform:translateY(-2px);box-shadow:var(--shadow-md)}
.stat-card .stat-num{font-size:24px;font-weight:700;line-height:1;margin-bottom:4px;color:var(--accent,#3b82f6)} .stat-card::before{content:"";position:absolute;left:0;top:0;bottom:0;width:4px;background:var(--accent,#0f766e);border-radius:0 3px 3px 0}
.stat-card .stat-lbl{font-size:12px;color:var(--text-muted);font-weight:500} .stat-card .stat-num{font-family:var(--mono);font-size:25px;font-weight:600;line-height:1.1;margin-bottom:5px;color:var(--accent,#0f766e);font-variant-numeric:tabular-nums}
.stat-card .stat-lbl{font-size:12px;color:var(--text-muted);font-weight:500;letter-spacing:.2px}
/* 工具栏 */ /* 工具栏 */
.toolbar{display:flex;gap:10px;margin-bottom:16px;align-items:center;flex-wrap:wrap} .toolbar{display:flex;gap:10px;margin-bottom:16px;align-items:center;flex-wrap:wrap}
.toolbar .hint{color:var(--text-light);font-size:12px;margin-left:auto} .toolbar .hint{color:var(--text-light);font-size:12px;margin-left:auto}
/* 按钮 */ /* 按钮 */
.btn{border-radius:8px;font-weight:500;border:1px solid var(--border);padding:7px 16px;font-size:13px;transition:all .15s;cursor:pointer;background:var(--card);color:var(--text)} .btn{border-radius:8px;font-weight:500;border:1px solid var(--border-strong);padding:7px 16px;font-size:13px;transition:all .15s;cursor:pointer;background:var(--card);color:var(--text);box-shadow:var(--shadow-sm)}
.btn:hover{background:#f1f5f9;border-color:#cbd5e1} .btn:hover{background:#faf9f5;border-color:#c4bfb2;transform:translateY(-1px)}
.btn-primary{background:var(--primary);border-color:var(--primary);color:#fff} .btn:active{transform:none}
.btn-primary:hover{background:var(--primary-dark)} .btn:disabled{opacity:.5;cursor:not-allowed;transform:none}
.btn-danger{background:var(--danger);border-color:var(--danger);color:#fff} .btn-primary{background:var(--primary);border-color:var(--primary);color:#fff;box-shadow:0 1px 4px rgba(15,118,110,.35)}
.btn-danger:hover{background:#dc2626} .btn-primary:hover{background:var(--primary-dark);border-color:var(--primary-dark)}
.btn-danger{background:var(--danger);border-color:var(--danger);color:#fff;box-shadow:0 1px 4px rgba(220,38,38,.3)}
.btn-danger:hover{background:#b91c1c;border-color:#b91c1c}
.btn-success{background:var(--success);border-color:var(--success);color:#fff} .btn-success{background:var(--success);border-color:var(--success);color:#fff}
.btn-success:hover{background:#059669} .btn-success:hover{background:#15803d;border-color:#15803d}
.btn-sm{padding:4px 12px;font-size:12px} .btn-sm{padding:4px 12px;font-size:12px}
.btn-xs{padding:3px 10px;font-size:12px} .btn-xs{padding:3px 10px;font-size:12px;border-radius:6px}
/* 表单 */
.form-control{background:#fff;border:1px solid var(--border-strong);border-radius:8px;padding:7px 12px;font-size:13px;color:var(--text);transition:border-color .15s,box-shadow .15s;outline:none;font-family:var(--body)}
.form-control:focus{border-color:var(--primary);box-shadow:0 0 0 3px rgba(15,118,110,.12)}
.form-control::placeholder{color:var(--text-light)}
.form-group{margin-bottom:12px}
.form-group label{display:block;font-size:12px;font-weight:600;color:var(--text-muted);margin-bottom:5px}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.form-check{display:flex;align-items:center;gap:7px;cursor:pointer;font-size:13px}
.form-check input{width:15px;height:15px;accent-color:var(--primary);cursor:pointer}
/* 通用列表分页栏 */ /* 通用列表分页栏 */
.list-pager{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin:8px 0 20px} .list-pager{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin:10px 0 22px}
.list-pager .lp-total{font-size:12px;color:var(--text-light);margin-right:auto} .list-pager .lp-total{font-size:12px;color:var(--text-light);margin-right:auto}
.list-search{width:240px} .list-search{width:240px}
.adb-console{background:#0f172a;color:#e2e8f0;font-family:monospace;font-size:12px;padding:12px;border-radius:8px;min-height:200px;max-height:420px;overflow:auto;white-space:pre-wrap;word-break:break-all}
/* 控制台/日志(墨色终端) */
.adb-console{background:var(--ink);color:#d5dae1;font-family:var(--mono);font-size:12.5px;padding:14px;border-radius:10px;min-height:200px;max-height:420px;overflow:auto;white-space:pre-wrap;word-break:break-all;box-shadow:inset 0 1px 6px rgba(0,0,0,.4);border:1px solid var(--ink-line)}
.log-content{background:var(--ink);color:#d5dae1;border-radius:10px;padding:16px;font-family:var(--mono);font-size:12.5px;line-height:1.65;max-height:600px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;border:1px solid var(--ink-line);box-shadow:inset 0 1px 6px rgba(0,0,0,.4)}
.log-content .log-err{color:#f87171}
.log-content .log-warn{color:#fbbf24}
.log-content .log-info{color:#67e8f9}
.log-toolbar{display:flex;gap:10px;margin-bottom:12px;align-items:center}
/* 区块标题 */ /* 区块标题 */
.section-title{font-size:15px;font-weight:600;color:var(--text);margin:0 0 12px;padding-bottom:8px;border-bottom:2px solid var(--primary);display:inline-block} .section-title{font-family:var(--display);font-size:15px;font-weight:700;color:var(--text);margin:0 0 12px;padding-bottom:8px;border-bottom:2px solid var(--primary);display:inline-block;letter-spacing:.2px}
/* 页内子分栏(维护页用) */ /* 页内子分栏 */
.sub-tabs{display:flex;gap:4px;margin-bottom:16px;border-bottom:2px solid var(--border)} .sub-tabs{display:flex;gap:2px;margin-bottom:18px;border-bottom:1px solid var(--border-strong)}
.sub-tab{padding:9px 16px;font-size:13px;font-weight:600;color:var(--text-muted);background:none;border:none;cursor:pointer;border-radius:8px 8px 0 0;transition:all .15s;position:relative} .sub-tab{padding:10px 16px;font-size:13px;font-weight:600;color:var(--text-muted);background:none;border:none;cursor:pointer;border-radius:8px 8px 0 0;transition:all .15s;position:relative}
.sub-tab:hover{color:var(--text);background:#f1f5f9} .sub-tab:hover{color:var(--text);background:#efede6}
.sub-tab.active{color:var(--primary);background:#eff6ff} .sub-tab.active{color:var(--primary)}
.sub-tab.active::after{content:"";position:absolute;left:10px;right:10px;bottom:-2px;height:2px;background:var(--primary);border-radius:1px} .sub-tab.active::after{content:"";position:absolute;left:10px;right:10px;bottom:-1px;height:2px;background:var(--primary);border-radius:2px}
.sub-panel{display:none} .sub-panel{display:none}
.sub-panel.active{display:block} .sub-panel.active{display:block;animation:panelIn .2s ease-out}
/* 表格 */ /* 表格 */
.table{background:var(--card);border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.06);border:1px solid var(--border);width:100%;border-collapse:collapse;margin-bottom:24px} .table{background:var(--card);border-radius:var(--radius);overflow:hidden;box-shadow:var(--shadow-sm);border:1px solid var(--border);width:100%;border-collapse:collapse;margin-bottom:26px}
.table thead th{background:#f1f5f9;border-bottom:2px solid var(--border);font-weight:600;color:var(--text-muted);font-size:12px;letter-spacing:.3px;padding:12px 14px;text-align:left} .table thead th{background:#faf9f5;border-bottom:1px solid var(--border-strong);font-weight:600;color:var(--text-muted);font-size:12px;letter-spacing:.4px;padding:12px 14px;text-align:left;white-space:nowrap}
.table tbody td{padding:10px 14px;border-bottom:1px solid var(--border);vertical-align:middle;font-size:13px} .table tbody td{padding:11px 14px;border-bottom:1px solid var(--border);vertical-align:middle;font-size:13px}
.table tbody tr:last-child td{border-bottom:none} .table tbody tr:last-child td{border-bottom:none}
.table tbody tr:hover td{background:#f8fafc} .table tbody tr{transition:background .1s}
.table tbody tr:hover td{background:#faf9f4}
.table thead th.sortable{cursor:pointer;user-select:none}
.table thead th.sortable:hover{background:#f3f1e9}
.table thead th.sortable::after{content:" ⇅";color:#c6c1b4;font-weight:400;font-size:11px}
.table thead th.sortable.sort-asc::after{content:" ↑";color:var(--primary);font-weight:700}
.table thead th.sortable.sort-desc::after{content:" ↓";color:var(--primary);font-weight:700}
.table tbody tr.row-checked td{background:var(--primary-soft)}
.table .col-cb{width:38px;text-align:center}
.table .col-cb input,.form-check input[type=checkbox],.device-list input{width:15px;height:15px;accent-color:var(--primary);cursor:pointer}
/* 徽章 */ /* 徽章(柔和底 + 深色字) */
.label{padding:3px 10px;border-radius:6px;font-size:11px;font-weight:600;display:inline-block} .label{padding:3px 10px;border-radius:999px;font-size:11px;font-weight:600;display:inline-block;line-height:1.5;letter-spacing:.2px}
.label-success{background:#dcfce7;color:#166534} .label-success{background:var(--success-soft);color:#15803d}
.label-default{background:#f1f5f9;color:#64748b} .label-default{background:#f1efe8;color:var(--text-muted)}
.label-warning{background:#fef3c7;color:#92400e} .label-warning{background:var(--warning-soft);color:#b45309}
.label-primary{background:#dbeafe;color:#1e40af} .label-primary{background:var(--primary-soft);color:#0f766e}
.label-info{background:#e0e7ff;color:#3730a3} .label-info{background:var(--info-soft);color:#4338ca}
.label-danger{background:#fee2e2;color:#991b1b} .label-danger{background:var(--danger-soft);color:#b91c1c}
/* 进度条 */ /* 进度条 */
.progress{background:#e2e8f0;border-radius:4px;height:6px;overflow:hidden;display:inline-block;vertical-align:middle;width:70px} .progress{background:#e9e6dd;border-radius:99px;height:6px;overflow:hidden;display:inline-block;vertical-align:middle;width:70px}
.progress-bar{background:var(--primary);height:100%;border-radius:4px;transition:width .3s} .progress-bar{background:var(--primary);height:100%;border-radius:99px;transition:width .3s}
/* 圆点 */ /* 圆点 */
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:4px} .dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:5px}
.dot-on{background:var(--success);box-shadow:0 0 6px rgba(16,185,129,.5)} .dot-on{background:var(--success);box-shadow:0 0 6px rgba(22,163,74,.55)}
.dot-off{background:var(--danger);opacity:.5} .dot-off{background:var(--danger);opacity:.55}
/* 任务卡片 */ /* 任务卡片 */
.jobs-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:14px} .jobs-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(350px,1fr));gap:14px}
.job-card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:14px;box-shadow:0 1px 3px rgba(0,0,0,.06);transition:all .15s} .job-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:15px 16px;box-shadow:var(--shadow-sm);transition:all .18s;position:relative}
.job-card:hover{box-shadow:0 4px 12px rgba(0,0,0,.08);transform:translateY(-2px)} .job-card:hover{box-shadow:var(--shadow-md);transform:translateY(-2px)}
.job-card.running{border-color:var(--success);box-shadow:0 0 0 1px var(--success)} .job-card.running{border-color:rgba(22,163,74,.55);box-shadow:0 0 0 1px rgba(22,163,74,.35),var(--shadow-sm)}
.job-card .job-head{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px} .job-card .job-head{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:9px}
.job-card .job-name{font-size:14px;font-weight:600;color:var(--text)} .job-card .job-name{font-size:14px;font-weight:600;color:var(--text)}
.job-card .job-type{font-size:11px;color:var(--text-light);background:#f1f5f9;padding:2px 8px;border-radius:4px;margin-top:2px;display:inline-block} .job-card .job-type{font-size:11px;color:var(--text-muted);background:#f1efe8;padding:2px 8px;border-radius:99px;margin-top:3px;display:inline-block;font-weight:500}
.job-card .job-body{font-size:12px;color:var(--text-muted);margin-bottom:10px;line-height:1.6} .job-card .job-body{font-size:12px;color:var(--text-muted);margin-bottom:11px;line-height:1.65}
.job-card .job-body .kv{display:inline-block;background:#f8fafc;border:1px solid var(--border);border-radius:4px;padding:1px 6px;margin:2px 2px 0 0} .job-card .job-body .kv{display:inline-block;background:#faf9f5;border:1px solid var(--border);border-radius:6px;padding:1px 7px;margin:2px 3px 0 0;font-family:var(--mono);font-size:11px}
.job-card .job-actions{display:flex;gap:6px;flex-wrap:wrap} .job-card .job-actions{display:flex;gap:6px;flex-wrap:wrap}
/* 空状态 */ /* 空状态 / 辅助文本 */
.empty{text-align:center;padding:40px;color:var(--text-light)} .empty{text-align:center;padding:42px;color:var(--text-light);font-size:13px}
.text-muted{color:var(--text-light)!important} .text-muted{color:var(--text-light)!important}
.err-text{color:var(--danger);font-size:12px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .err-text{color:var(--danger);font-size:12px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pulse{animation:pulse 2s infinite} .pulse{animation:pulse 2s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}} @keyframes pulse{0%,100%{opacity:1}50%{opacity:.45}}
.help{font-size:12px;color:var(--text-light);line-height:1.7;background:#faf9f4;border:1px dashed var(--border-strong);border-radius:8px;padding:10px 12px;margin:10px 0}
.help code{font-family:var(--mono);background:#f1efe8;padding:1px 5px;border-radius:4px;font-size:11px;color:var(--primary-dark)}
/* 模态框 */ /* 模态框 */
.modal-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.4);z-index:1000;justify-content:center;align-items:flex-start;padding:40px 20px;overflow-y:auto} .modal-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(20,22,26,.5);backdrop-filter:blur(2px);z-index:1000;justify-content:center;align-items:flex-start;padding:40px 20px;overflow-y:auto}
.modal-overlay.show{display:flex} .modal-overlay.show{display:flex}
.modal{background:var(--card);border-radius:14px;box-shadow:0 20px 60px rgba(0,0,0,.2);width:100%;max-width:640px;margin-bottom:40px} .modal{background:var(--card);border-radius:14px;box-shadow:var(--shadow-lg);width:100%;max-width:640px;margin-bottom:40px;border:1px solid var(--border);animation:modalIn .18s ease-out}
@keyframes modalIn{from{opacity:0;transform:translateY(10px) scale(.985)}to{opacity:1;transform:none}}
.modal-header{padding:18px 24px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center} .modal-header{padding:18px 24px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
.modal-header h3{font-size:17px;font-weight:700;color:var(--text)} .modal-header h3{font-size:17px;font-weight:700;color:var(--text);font-family:var(--display)}
.modal-header .close{background:none;border:none;font-size:22px;color:var(--text-light);cursor:pointer;padding:0;width:28px;height:28px;line-height:1} .modal-header .btn{background:none;border:none;font-size:20px;color:var(--text-light);padding:0 6px;box-shadow:none}
.modal-header .close:hover{color:var(--text)} .modal-header .btn:hover{color:var(--text)}
.modal-body{padding:24px;max-height:60vh;overflow-y:auto} .modal-body{padding:20px 24px}
.modal-footer{padding:16px 24px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:10px} .modal-footer{padding:14px 24px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:8px}
/* 表单 */
.form-group{margin-bottom:16px}
.form-group label{display:block;font-size:13px;font-weight:600;color:var(--text);margin-bottom:6px}
.form-group .help{font-size:12px;color:var(--text-light);margin-top:4px}
.form-control{width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;font-size:13px;font-family:inherit;background:#fff;color:var(--text);transition:all .15s}
.form-control:focus{outline:none;border-color:var(--primary);box-shadow:0 0 0 3px rgba(59,130,246,.1)}
textarea.form-control{font-family:monospace;resize:vertical;min-height:100px}
select.form-control{cursor:pointer}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.form-check{display:flex;align-items:center;gap:8px}
.form-check input{width:16px;height:16px;cursor:pointer}
.form-check label{margin:0;cursor:pointer}
/* 设备多选列表 */
.device-list{max-height:200px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:8px}
.device-list label{display:flex;align-items:center;gap:6px;padding:4px 8px;font-size:13px;font-weight:400;cursor:pointer;border-radius:4px}
.device-list label:hover{background:#f1f5f9}
.device-list input{width:14px;height:14px}
/* 日志 */
.log-toolbar{display:flex;gap:10px;margin-bottom:12px;align-items:center}
.log-content{background:#1e293b;color:#e2e8f0;border-radius:10px;padding:16px;font-family:"Cascadia Code",Consolas,monospace;font-size:12px;line-height:1.6;max-height:600px;overflow-y:auto;white-space:pre-wrap;word-break:break-all}
.log-content .log-err{color:#fca5a5}
.log-content .log-warn{color:#fcd34d}
.log-content .log-info{color:#93c5fd}
/* Toast */
.toast{position:fixed;top:20px;right:20px;z-index:2000;padding:12px 20px;border-radius:8px;font-size:14px;font-weight:500;box-shadow:0 4px 12px rgba(0,0,0,.15);display:none;max-width:400px}
.toast.show{display:block}
.toast-success{background:#dcfce7;color:#166534;border:1px solid #86efac}
.toast-error{background:#fee2e2;color:#991b1b;border:1px solid #fca5a5}
/* 截图弹窗 */
.shot-modal{max-width:none;width:min(440px,95vw)} .shot-modal{max-width:none;width:min(440px,95vw)}
/* 宽弹窗(任务编辑器:步骤编辑需要横向空间) */
.wide-modal{max-width:1000px} .wide-modal{max-width:1000px}
.shot-modal .modal-body{text-align:center;padding:14px} .shot-modal .modal-body{text-align:center;padding:14px}
.shot-img{max-width:100%;max-height:calc(100vh - 240px);width:auto;height:auto;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,.15);background:#1e293b;min-height:200px;display:block;margin:0 auto} .shot-img{max-width:100%;max-height:calc(100vh - 240px);width:auto;height:auto;border-radius:8px;box-shadow:0 2px 10px rgba(0,0,0,.2);background:var(--ink);min-height:200px;display:block;margin:0 auto}
.shot-loading{color:var(--text-light);padding:60px 0;font-size:13px} .shot-loading{color:var(--text-light);padding:60px 0;font-size:13px}
.shot-err{color:var(--danger);padding:40px 0;font-size:13px;white-space:pre-wrap;word-break:break-all} .shot-err{color:var(--danger);padding:40px 0;font-size:13px;white-space:pre-wrap;word-break:break-all}
.shot-info{font-size:12px;color:var(--text-muted);margin-top:10px} .shot-info{font-size:12px;color:var(--text-muted);margin-top:10px}
.shot-toolbar{display:flex;gap:8px;justify-content:center;align-items:center;margin-top:12px;flex-wrap:wrap} .shot-toolbar{display:flex;gap:8px;justify-content:center;align-items:center;margin-top:12px;flex-wrap:wrap}
.shot-toolbar .auto-label{font-size:12px;color:var(--text-muted);display:flex;align-items:center;gap:4px} .shot-toolbar .auto-label{font-size:12px;color:var(--text-muted);display:flex;align-items:center;gap:4px}
.shot-toolbar .auto-label input{width:14px;height:14px}
/* 表头排序 */ /* Toast */
.table thead th.sortable{cursor:pointer;user-select:none;white-space:nowrap} .toast{position:fixed;top:20px;right:20px;z-index:2000;padding:12px 20px;border-radius:10px;font-size:14px;font-weight:500;box-shadow:var(--shadow-lg);display:none;max-width:400px;border-left:4px solid;animation:toastIn .18s ease-out}
.table thead th.sortable:hover{background:#e2e8f0} .toast.show{display:block}
.table thead th.sortable::after{content:" ⇅";color:#cbd5e1;font-weight:400;font-size:11px} @keyframes toastIn{from{opacity:0;transform:translateX(14px)}to{opacity:1;transform:none}}
.table thead th.sortable.sort-asc::after{content:" ↑";color:var(--primary);font-weight:700} .toast-success{background:#f0faf4;color:#166534;border-color:var(--success)}
.table thead th.sortable.sort-desc::after{content:" ↓";color:var(--primary);font-weight:700} .toast-error{background:#fef2f2;color:#991b1b;border-color:var(--danger)}
.table tbody tr.row-checked td{background:#eff6ff}
.table .col-cb{width:36px;text-align:center} /* 设备多选列表 */
.table .col-cb input{width:14px;height:14px;cursor:pointer;vertical-align:middle} .device-list{max-height:200px;overflow-y:auto;border:1px solid var(--border-strong);border-radius:8px;padding:8px;background:#fff}
.device-list label{display:flex;align-items:center;gap:6px;padding:4px 8px;font-size:13px;font-weight:400;cursor:pointer;border-radius:6px}
.device-list label:hover{background:#f5f3ec}
/* 步骤编辑器 */ /* 步骤编辑器 */
.step-editor{display:none;border:1px solid var(--border);border-radius:10px;overflow:hidden;margin-top:8px} .step-editor{display:none;border:1px solid var(--border);border-radius:12px;overflow:hidden;margin-top:8px;background:#fff;box-shadow:var(--shadow-sm)}
.step-editor.show{display:block} .step-editor.show{display:block}
.step-editor .se-head{background:#f1f5f9;padding:10px 14px;font-weight:600;font-size:13px;display:flex;justify-content:space-between;align-items:center} .step-editor .se-head{background:#faf9f5;padding:10px 14px;font-weight:600;font-size:13px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--border)}
.step-editor .se-body{display:grid;grid-template-columns:200px 1fr;min-height:280px} .step-editor .se-body{display:grid;grid-template-columns:200px 1fr;min-height:280px}
.step-editor .se-lib{border-right:1px solid var(--border);padding:8px;background:#f8fafc;overflow-y:auto;max-height:520px} .step-editor .se-lib{border-right:1px solid var(--border);padding:8px;background:#faf9f4;overflow-y:auto;max-height:520px}
.step-editor .se-lib .lib-title{font-size:11px;color:var(--text-light);font-weight:600;margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px} .step-editor .se-lib .lib-title{font-size:11px;color:var(--text-light);font-weight:600;margin-bottom:6px;text-transform:uppercase;letter-spacing:.6px}
.step-editor .se-lib .lib-item{padding:6px 8px;border-radius:6px;cursor:grab;font-size:12px;color:var(--text);display:flex;align-items:center;gap:6px;margin-bottom:2px;transition:background .1s;white-space:nowrap;overflow:hidden} .step-editor .se-lib .lib-item{padding:6px 8px;border-radius:6px;cursor:grab;font-size:12px;color:var(--text);display:flex;align-items:center;gap:6px;margin-bottom:2px;transition:background .1s;white-space:nowrap;overflow:hidden}
.step-editor .se-lib .lib-item span:last-child{overflow:hidden;text-overflow:ellipsis} .step-editor .se-lib .lib-item span:last-child{overflow:hidden;text-overflow:ellipsis}
.step-editor .se-lib .lib-item:hover{background:#e2e8f0} .step-editor .se-lib .lib-item:hover{background:#ece9e0}
.step-editor .se-lib .lib-item:active{cursor:grabbing} .step-editor .se-lib .lib-item:active{cursor:grabbing}
.step-editor .se-lib .lib-item .ico{width:18px;height:18px;display:inline-flex;align-items:center;justify-content:center;background:var(--primary);color:#fff;border-radius:4px;font-size:11px;flex-shrink:0} .step-editor .se-lib .lib-item .ico{width:18px;height:18px;display:inline-flex;align-items:center;justify-content:center;background:var(--primary);color:#fff;border-radius:5px;font-size:11px;flex-shrink:0}
.step-editor .se-lib .lib-section{margin-top:8px;padding-top:6px;border-top:1px solid var(--border)}
.step-editor .se-lib .lib-item.custom .ico{background:#7c3aed}
.step-editor .se-lib .lib-item.custom .del-btn{margin-left:auto;background:none;border:none;color:var(--text-light);cursor:pointer;font-size:13px;padding:0 2px}
.step-editor .se-lib .lib-item.custom .del-btn:hover{color:var(--danger)}
.step-editor .se-canvas{padding:8px;overflow-y:auto;max-height:480px} .step-editor .se-canvas{padding:8px;overflow-y:auto;max-height:480px}
.step-editor .se-empty{text-align:center;color:var(--text-light);padding:40px 20px;font-size:13px} .step-editor .se-empty{text-align:center;color:var(--text-light);padding:40px 20px;font-size:13px}
.step-editor .se-empty .hint{font-size:11px;margin-top:4px} .step-editor .se-empty .hint{font-size:11px;margin-top:4px}
.se-toolbar{display:flex;gap:6px;padding:6px 8px;border-bottom:1px solid var(--border);background:#faf9f5}
.se-toolbar .btn{padding:4px 10px;font-size:12px}
.step-card{background:#fff;border:1px solid var(--border);border-radius:8px;margin-bottom:6px;transition:box-shadow .15s} .step-card{background:#fff;border:1px solid var(--border);border-radius:8px;margin-bottom:6px;transition:box-shadow .15s}
.step-card.drag-over{border-color:var(--primary);box-shadow:0 0 0 2px rgba(59,130,246,.15)} .step-card.drag-over{border-color:var(--primary);box-shadow:0 0 0 2px rgba(15,118,110,.18)}
.step-card.dragging{opacity:.5} .step-card.dragging{opacity:.5}
.step-card .sc-head{display:flex;align-items:center;gap:8px;padding:7px 10px;cursor:grab} .step-card .sc-head{display:flex;align-items:center;gap:8px;padding:7px 10px;cursor:grab}
.step-card .sc-head:active{cursor:grabbing} .step-card .sc-head:active{cursor:grabbing}
.step-card .sc-ico{width:22px;height:22px;display:inline-flex;align-items:center;justify-content:center;background:var(--primary);color:#fff;border-radius:5px;font-size:12px;flex-shrink:0} .step-card .sc-ico{width:22px;height:22px;display:inline-flex;align-items:center;justify-content:center;background:var(--primary);color:#fff;border-radius:6px;font-size:12px;flex-shrink:0}
.step-card .sc-label{flex:1;font-size:13px;font-weight:500;color:var(--text)} .step-card .sc-label{flex:1;font-size:13px;font-weight:500;color:var(--text)}
.step-card .sc-type{font-size:11px;color:var(--text-light);background:#f1f5f9;padding:1px 6px;border-radius:3px} .step-card .sc-type{font-size:11px;color:var(--text-light);background:#f1efe8;padding:1px 6px;border-radius:4px}
.step-card .sc-actions{display:flex;gap:2px} .step-card .sc-actions{display:flex;gap:2px}
.step-card .sc-actions button{background:none;border:none;color:var(--text-light);cursor:pointer;padding:2px 5px;font-size:14px;border-radius:3px;line-height:1} .step-card .sc-actions button{background:none;border:none;color:var(--text-light);cursor:pointer;padding:2px 5px;font-size:14px;border-radius:4px;line-height:1}
.step-card .sc-actions button:hover{background:#f1f5f9;color:var(--text)} .step-card .sc-actions button:hover{background:#f1efe8;color:var(--text)}
.step-card .sc-body{display:none;padding:8px 10px;border-top:1px solid var(--border);background:#f8fafc} .step-card .sc-body{display:none;padding:8px 10px;border-top:1px solid var(--border);background:#faf9f4}
.step-card.expanded .sc-body{display:block} .step-card.expanded .sc-body{display:block}
.step-card .sc-body .form-row{grid-template-columns:1fr 1fr 1fr;gap:8px} .step-card .sc-body .form-row{grid-template-columns:1fr 1fr 1fr;gap:8px}
.step-card .sc-body .form-group{margin-bottom:8px} .step-card .sc-body .form-group{margin-bottom:8px}
@@ -216,84 +252,123 @@ select.form-control{cursor:pointer}
.step-card .sc-body .form-control{padding:5px 8px;font-size:12px} .step-card .sc-body .form-control{padding:5px 8px;font-size:12px}
.step-card.loop-block{border-left:3px solid var(--success)} .step-card.loop-block{border-left:3px solid var(--success)}
.step-card.loop-block .sc-ico{background:var(--success)} .step-card.loop-block .sc-ico{background:var(--success)}
.step-card.group-block{border-left:3px solid #8b5cf6} .step-card.group-block{border-left:3px solid #7c3aed}
.step-card.group-block .sc-ico{background:#8b5cf6} .step-card.group-block .sc-ico{background:#7c3aed}
.step-card.if-block{border-left:3px solid #f59e0b} .step-card.if-block{border-left:3px solid var(--warning)}
.step-card.if-block .sc-ico{background:#f59e0b} .step-card.if-block .sc-ico{background:var(--warning)}
.step-card.selected{border-color:var(--primary);box-shadow:0 0 0 2px rgba(59,130,246,.2)} .step-card.selected{border-color:var(--primary);box-shadow:0 0 0 2px rgba(15,118,110,.22)}
.step-children{margin-left:20px;margin-top:6px;border-left:2px dashed var(--border);padding-left:8px} .step-children{margin-left:20px;margin-top:6px;border-left:2px dashed var(--border-strong);padding-left:8px}
.if-branch{margin-top:6px} .if-branch{margin-top:6px}
.if-branch .branch-title{font-size:11px;font-weight:600;padding:3px 10px;border-radius:4px;margin-bottom:4px;display:inline-block} .if-branch .branch-title{font-size:11px;font-weight:600;padding:3px 10px;border-radius:4px;margin-bottom:4px;display:inline-block}
.if-branch .branch-title.then{color:#166534;background:#dcfce7} .if-branch .branch-title.then{color:#15803d;background:var(--success-soft)}
.if-branch .branch-title.else{color:#991b1b;background:#fee2e2} .if-branch .branch-title.else{color:#b91c1c;background:var(--danger-soft)}
.step-children.drag-over{border-left-color:var(--primary);background:rgba(59,130,246,.06);border-radius:6px} .step-children.drag-over{border-left-color:var(--primary);background:rgba(15,118,110,.06);border-radius:6px}
#se-canvas.drag-over{outline:2px dashed var(--primary);outline-offset:2px;border-radius:8px} #se-canvas.drag-over{outline:2px dashed var(--primary);outline-offset:2px;border-radius:8px}
.sel-picker{display:flex;gap:4px;align-items:center} .sel-picker{display:flex;gap:4px;align-items:center}
.sel-picker input{flex:1} .sel-picker input{flex:1}
.sel-picker .btn-pick{padding:5px 8px;font-size:11px;white-space:nowrap} .sel-picker .btn-pick{padding:5px 8px;font-size:11px;white-space:nowrap}
.se-lib .lib-section{margin-top:8px;padding-top:6px;border-top:1px solid var(--border)}
.se-lib .lib-item.custom .ico{background:#8b5cf6}
.se-lib .lib-item.custom .del-btn{margin-left:auto;background:none;border:none;color:var(--text-light);cursor:pointer;font-size:13px;padding:0 2px}
.se-lib .lib-item.custom .del-btn:hover{color:var(--danger)}
.se-toolbar{display:flex;gap:6px;padding:6px 8px;border-bottom:1px solid var(--border);background:#f8fafc}
.se-toolbar .btn{padding:4px 10px;font-size:12px}
/* 元素抓取弹窗(独立模态框,样式已内联在 HTML 中)*/ /* 元素抓取弹窗 */
/* 设备选择页 */
.el-device-grid{padding:16px;overflow-y:auto;flex:1} .el-device-grid{padding:16px;overflow-y:auto;flex:1}
.el-device-grid .egd-title{font-size:14px;font-weight:600;margin-bottom:12px;color:var(--text)} .el-device-grid .egd-title{font-size:14px;font-weight:600;margin-bottom:12px;color:var(--text)}
.el-device-card{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:14px;cursor:pointer;transition:all .15s;display:flex;align-items:center;gap:12px;margin-bottom:8px} .el-device-card{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:14px;cursor:pointer;transition:all .15s;display:flex;align-items:center;gap:12px;margin-bottom:8px;box-shadow:var(--shadow-sm)}
.el-device-card:hover{border-color:var(--primary);box-shadow:0 2px 8px rgba(59,130,246,.12)} .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:#f1f5f9;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0} .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-info{flex:1}
.el-device-card .edc-serial{font-size:13px;font-weight:600;color:var(--text)} .el-device-card .edc-serial{font-size:13px;font-weight:600;color:var(--text);font-family:var(--mono)}
.el-device-card .edc-model{font-size:12px;color:var(--text-light);margin-top:2px} .el-device-card .edc-model{font-size:12px;color:var(--text-light);margin-top:2px}
.el-device-card .edc-status{font-size:11px;padding:2px 8px;border-radius:4px;background:#dcfce7;color:#166534;flex-shrink:0} .el-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} .el-picker-main{display:flex;flex:1;overflow:hidden}
.el-shot-wrap{flex:0 0 320px;border-right:1px solid var(--border);background:#1e293b;display:flex;flex-direction:column} .el-shot-wrap{flex:0 0 320px;border-right:1px solid var(--border);background:var(--ink);display:flex;flex-direction:column}
.el-shot-toolbar{padding:6px 10px;background:#334155;display:flex;gap:6px;align-items:center} .el-shot-toolbar{padding:6px 10px;background:var(--ink-2);display:flex;gap:6px;align-items:center;border-bottom:1px solid var(--ink-line)}
.el-shot-toolbar .btn{padding:3px 10px;font-size:12px;background:#475569;color:#fff;border:none} .el-shot-toolbar .btn{padding:3px 10px;font-size:12px;background:#39414d;color:#e5e7eb;border:none;box-shadow:none}
.el-shot-toolbar .btn:hover{background:#64748b} .el-shot-toolbar .btn:hover{background:#4b5563}
.el-shot-toolbar .el-shot-info{color:#cbd5e1;font-size:11px;flex:1} .el-shot-toolbar .el-shot-info{color:#9aa3af;font-size:11px;flex:1}
.el-shot-container{flex:1;overflow:auto;display:flex;align-items:flex-start;justify-content:center;padding:8px;position:relative} .el-shot-container{flex:1;overflow:auto;display:flex;align-items:flex-start;justify-content:center;padding:8px;position:relative}
.el-shot-container img{max-width:100%;height:auto;border-radius:4px;display:block} .el-shot-container img{max-width:100%;height:auto;border-radius:4px;display:block}
.el-shot-container .el-shot-loading{color:#94a3b8;font-size:13px;padding:40px} .el-shot-container .el-shot-loading{color:#8a93a0;font-size:13px;padding:40px}
.el-shot-overlay{position:absolute;border:1px solid rgba(59,130,246,.3);background:transparent;cursor:pointer;border-radius:2px;transition:all .1s} .el-shot-overlay{position:absolute;border:1px solid rgba(103,232,249,.35);background:transparent;cursor:pointer;border-radius:2px;transition:all .1s}
.el-shot-overlay:hover{border-color:#3b82f6;background:rgba(59,130,246,.2)} .el-shot-overlay:hover{border-color:#67e8f9;background:rgba(103,232,249,.18)}
.el-shot-overlay.active{border:2px solid #ef4444;background:rgba(239,68,68,.2)} .el-shot-overlay.active{border:2px solid #f87171;background:rgba(248,113,113,.22)}
/* 右侧元素列表 */
.el-list-wrap{flex:1;display:flex;flex-direction:column;overflow:hidden} .el-list-wrap{flex:1;display:flex;flex-direction:column;overflow:hidden}
.el-picker-search{width:100%;padding:8px 12px;border:none;border-bottom:1px solid var(--border);font-size:13px;outline:none} .el-picker-search{width:100%;padding:8px 12px;border:none;border-bottom:1px solid var(--border);font-size:13px;outline:none;font-family:var(--body)}
.el-picker-list{flex:1;overflow-y:auto} .el-picker-list{flex:1;overflow-y:auto}
.el-picker-item{padding:8px 12px;border-bottom:1px solid var(--border);cursor:pointer;font-size:12px;display:flex;gap:8px;align-items:flex-start} .el-picker-item{padding:8px 12px;border-bottom:1px solid var(--border);cursor:pointer;font-size:12px;display:flex;gap:8px;align-items:flex-start}
.el-picker-item:hover{background:#eff6ff} .el-picker-item:hover{background:var(--primary-soft)}
.el-picker-item.active{background:#dbeafe} .el-picker-item.active{background:var(--primary-soft);border-left:3px solid var(--primary)}
.el-picker-item:last-child{border-bottom:none} .el-picker-item:last-child{border-bottom:none}
.el-picker-item .ep-depth{color:var(--text-light);font-family:monospace;flex-shrink:0;width:28px} .el-picker-item .ep-depth{color:var(--text-light);font-family:var(--mono);flex-shrink:0;width:28px}
.el-picker-item .ep-info{flex:1;min-width:0} .el-picker-item .ep-info{flex:1;min-width:0}
.el-picker-item .ep-info .ep-name{color:var(--text);font-weight:500;word-break:break-all} .el-picker-item .ep-info .ep-name{color:var(--text);font-weight:500;word-break:break-all}
.el-picker-item .ep-info .ep-attrs{color:var(--text-light);font-size:11px;margin-top:2px;word-break:break-all} .el-picker-item .ep-info .ep-attrs{color:var(--text-light);font-size:11px;margin-top:2px;word-break:break-all}
.el-picker-item .ep-info .ep-attrs span{margin-right:8px} .el-picker-item .ep-info .ep-attrs span{margin-right:8px}
.el-picker-item .ep-sel{font-size:10px;color:var(--primary);background:#dbeafe;padding:1px 6px;border-radius:3px;flex-shrink:0} .el-picker-item .ep-sel{font-size:10px;color:#fff;background:var(--primary);padding:1px 7px;border-radius:99px;flex-shrink:0;font-weight:600}
/* ===== AI 控制台(聊天界面,浅色内容区——DeepSeek 风格,图片文字清晰) ===== */
.agent-body{display:flex;flex:1;min-height:0}
.agent-col-sessions{width:236px;border-right:1px solid #e5e7eb;background:#f6f7f9;display:flex;flex-direction:column;min-width:0}
.agent-sess-head{padding:10px;border-bottom:1px solid #e5e7eb}
.agent-sess-list{flex:1;overflow-y:auto;padding:6px}
.agent-sess-item{padding:8px 10px;border-radius:9px;cursor:pointer;margin-bottom:2px;border:1px solid transparent;position:relative}
.agent-sess-item:hover{background:#eceff3}
.agent-sess-item.active{background:#e0f2fe;border-color:#bae6fd}
.agent-sess-item .t{font-size:12.5px;color:#1f2937;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:20px}
.agent-sess-item .m{font-size:10.5px;color:#9ca3af;margin-top:2px}
.agent-sess-del{position:absolute;right:6px;top:6px;visibility:hidden;background:transparent;border:none;color:#9ca3af;cursor:pointer;font-size:12px;padding:2px 5px;border-radius:5px}
.agent-sess-item:hover .agent-sess-del{visibility:visible}
.agent-sess-del:hover{background:#fecaca;color:#b91c1c}
.agent-col-chat{flex:1;display:flex;flex-direction:column;min-width:0}
.agent-col-live{width:300px;border-left:1px solid #e5e7eb;background:#fff;display:flex;flex-direction:column;padding:10px}
.agent-live-head{font-size:12.5px;font-weight:600;color:#374151;display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
.agent-live-serial{font-family:var(--mono);font-size:10.5px;color:#6b7280}
.agent-live-body{flex:1;background:#0b0f14;border-radius:10px;display:flex;align-items:center;justify-content:center;overflow:hidden;min-height:200px}
.agent-live-body img{width:100%;height:100%;object-fit:contain}
.agent-live-body .agent-empty{color:#4b5563;padding:20px;font-size:12px;line-height:1.8}
.agent-shell{display:flex;flex-direction:column;height:calc(100vh - 150px);min-height:520px;border:1px solid #e5e7eb;border-radius:14px;overflow:hidden;background:#fff}
.agent-shell{display:flex;flex-direction:column;height:calc(100vh - 150px);min-height:480px;border:1px solid #e5e7eb;border-radius:14px;overflow:hidden;background:#fff}
.agent-topbar{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:1px solid #e5e7eb;background:#f9fafb}
.agent-title{font-size:14px;font-weight:700;color:#111827}
.agent-model-tag{margin-left:8px;font-size:11px;color:#0e7490;font-family:var(--mono);background:#e0f2fe;padding:2px 8px;border-radius:10px}
.agent-running-tag{color:#dc2626;font-size:12px;margin-right:8px}
.agent-top-actions{display:flex;align-items:center;gap:6px}
.agent-top-actions .btn{background:#fff;border:1px solid #d1d5db;color:#374151}
.agent-top-actions .btn:hover{border-color:#0e7490;color:#0e7490}
.agent-chat{flex:1;overflow-y:auto;padding:20px 26px;display:flex;flex-direction:column;gap:16px;background:#f7f8fa}
.agent-msg{max-width:80%;padding:11px 15px;border-radius:13px;font-size:13.5px;line-height:1.7;white-space:pre-wrap;word-break:break-word}
.agent-msg.user{align-self:flex-end;background:#0e7490;color:#fff;border-bottom-right-radius:3px}
.agent-msg.assistant{align-self:flex-start;background:#fff;border:1px solid #e5e7eb;border-bottom-left-radius:3px;color:#1f2937;box-shadow:0 1px 2px rgba(0,0,0,.04)}
.agent-msg.assistant .reasoning{color:#6b7280;font-size:12.5px;border-left:3px solid #d1d5db;padding-left:10px;margin:8px 0}
.agent-toolcards{display:flex;flex-direction:column;gap:5px;margin-bottom:9px}
.agent-toolcard{display:flex;align-items:flex-start;gap:8px;background:#f3f4f6;border:1px solid #e5e7eb;border-radius:9px;padding:6px 11px;font-size:12px;font-family:var(--mono);color:#374151;flex-wrap:wrap}
.agent-toolcard .dot{width:7px;height:7px;border-radius:50%;background:#10b981;flex:none;margin-top:5px}
.agent-toolcard code{color:#0e7490}
.agent-toolcard .text-muted{color:#6b7280}
.agent-toolcard img{max-width:190px;border-radius:7px;margin-top:4px;border:1px solid #e5e7eb;display:block;cursor:zoom-in}
.agent-inputbar{display:flex;gap:10px;padding:12px 14px;border-top:1px solid #e5e7eb;background:#fff}
.agent-inputbar textarea{flex:1;resize:none;background:#f9fafb;border:1px solid #d1d5db;border-radius:10px;padding:9px 12px;color:#111827;font-size:13.5px;outline:none;line-height:1.5}
.agent-inputbar textarea:focus{border-color:#0e7490;box-shadow:0 0 0 2px rgba(14,116,144,.12)}
.agent-inputbar .btn-primary{background:#0e7490;border:none}
.agent-inputbar .btn-primary:hover{background:#155e75}
.agent-inputbar .btn-primary:disabled{background:#9ca3af}
.agent-empty{color:#6b7280;font-size:13px;text-align:center;padding:44px 0}
</style> </style>
</head> </head>
<body> <body>
<!-- 顶部导航 --> <!-- 顶部导航 -->
<nav class="navbar"> <nav class="navbar">
<div class="brand">设备自动化后台</div> <div class="brand"><span class="brand-dot"></span>设备自动化后台</div>
<div class="tabs"> <div class="tabs">
<button class="tab active" onclick="showTab('monitor')">监控</button> <button class="tab active" data-tab="monitor" onclick="showTab('monitor')">监控</button>
<button class="tab" onclick="showTab('tasks')">任务</button> <button class="tab" data-tab="tasks" onclick="showTab('tasks')">任务</button>
<button class="tab" onclick="showTab('groups')">分组</button> <button class="tab" data-tab="logs" onclick="showTab('logs')" data-perm="logs">日志</button>
<button class="tab" onclick="showTab('logs')" data-perm="logs">日志</button> <button class="tab" data-tab="users" onclick="showTab('users')" data-perm="admin">用户</button>
<button class="tab" onclick="showTab('users')" data-perm="admin">用户</button> <button class="tab" data-tab="tools" onclick="showTab('tools')" data-perm="admin">工具</button>
<button class="tab" onclick="showTab('maintenance')" data-perm="admin">维护</button> <button class="tab" data-tab="agent" onclick="showTab('agent')" data-perm="admin">AI 控制台</button>
<button class="tab" onclick="showTab('tools')" data-perm="admin">工具</button>
</div> </div>
<div class="user-info"> <div class="user-info">
<span id="me-username">...</span> <span id="me-username">...</span>
<a href="/wall" target="_blank" title="监控大屏(全屏,挂墙/电视用)">📺 大屏</a>
<a href="/logout">退出</a> <a href="/logout">退出</a>
</div> </div>
</nav> </nav>
@@ -312,9 +387,13 @@ select.form-control{cursor:pointer}
<button class="btn btn-danger" onclick="apiAction('/api/stop_all')" data-perm="devices">停止全部</button> <button class="btn btn-danger" onclick="apiAction('/api/stop_all')" data-perm="devices">停止全部</button>
<button class="btn btn-danger" id="btn-stop-selected" onclick="stopSelectedDevices()" title="停止所有勾选的运行中设备" data-perm="devices">停止选中</button> <button class="btn btn-danger" id="btn-stop-selected" onclick="stopSelectedDevices()" title="停止所有勾选的运行中设备" data-perm="devices">停止选中</button>
<button class="btn btn-danger" onclick="clearAllErrors()" title="清除所有异常/失败设备的错误状态(运行中/等待重试的跳过)" data-perm="devices">清除全部异常</button> <button class="btn btn-danger" onclick="clearAllErrors()" title="清除所有异常/失败设备的错误状态(运行中/等待重试的跳过)" data-perm="devices">清除全部异常</button>
<button class="btn" onclick="apiAction('/api/release')" data-perm="devices">强制释放占用</button>
<button class="btn btn-primary" id="btn-scan-fg" onclick="scanForeground()" data-perm="devices">扫描前台App</button> <button class="btn btn-primary" id="btn-scan-fg" onclick="scanForeground()" data-perm="devices">扫描前台App</button>
<button class="btn" onclick="screenAllDevices('on')" data-perm="devices" title="所有在线设备亮屏并解锁">一键亮屏</button>
<button class="btn" onclick="screenAllDevices('off')" data-perm="devices" title="所有在线设备息屏(运行中任务会中断)">一键息屏</button>
<button class="btn" onclick="loadMonitor()">刷新</button> <button class="btn" onclick="loadMonitor()">刷新</button>
<label style="display:inline-flex;align-items:center;gap:4px;margin:0 6px">
<input type="checkbox" id="monitor-filter-online" checked onchange="loadMonitor()"> 只看在线
</label>
<input class="form-control list-search" id="search-devices" placeholder="搜索设备/型号/App/任务..."> <input class="form-control list-search" id="search-devices" placeholder="搜索设备/型号/App/任务...">
<span class="hint"><span id="fg-status"></span> · 每 5 秒自动刷新 · <span id="srvTime"></span> · <span id="sel-count">未选中</span></span> <span class="hint"><span id="fg-status"></span> · 每 5 秒自动刷新 · <span id="srvTime"></span> · <span id="sel-count">未选中</span></span>
</div> </div>
@@ -323,7 +402,6 @@ select.form-control{cursor:pointer}
<th class="col-cb"><input type="checkbox" id="cb-all" onclick="toggleAllDevices(this.checked)" title="全选/取消全选"></th> <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="serial" onclick="_lpSort('devices','serial')">设备</th>
<th data-sort="model" onclick="_lpSort('devices','model')">型号</th> <th data-sort="model" onclick="_lpSort('devices','model')">型号</th>
<th data-sort="stf_occupied" onclick="_lpSort('devices','stf_occupied')">STF</th>
<th data-sort="worker_status" onclick="_lpSort('devices','worker_status')">任务状态</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="foreground_app" onclick="_lpSort('devices','foreground_app')">前台App</th>
<th data-sort="progress" onclick="_lpSort('devices','progress')">进度</th> <th data-sort="progress" onclick="_lpSort('devices','progress')">进度</th>
@@ -393,21 +471,95 @@ select.form-control{cursor:pointer}
</div> </div>
<!-- ========== Tab 3: 分组 ========== --> <!-- ========== Tab 3: 分组 ========== -->
<div id="tab-groups" class="tab-panel"> <!-- ========== Tab: AI 控制台(DeepSeek 风格聊天 + 实时 MCP 步骤) ========== -->
<div class="page-title">设备分组管理</div> <div id="tab-agent" class="tab-panel">
<div class="page-sub">管理设备分组,用于任务按分组执行</div> <div class="agent-shell">
<div class="toolbar"> <div class="agent-topbar">
<button class="btn btn-primary" onclick="openGroupModal()" data-perm="tasks">新建分组</button> <div class="agent-title">🤖 AI 控制台<span id="agent-model-tag" class="agent-model-tag"></span></div>
<button class="btn" onclick="loadGroups()">刷新</button> <div class="agent-top-actions">
<input class="form-control list-search" id="search-groups" placeholder="搜索分组名/描述..."> <span id="agent-running-tag" class="agent-running-tag" style="display:none">● 运行中</span>
<button class="btn btn-xs" onclick="openExpLib()" title="查看/管理自进化经验库(每日 AI 巡检建议,删除需确认)">🧠 经验库</button>
<button class="btn btn-xs" onclick="openAgentConfig()">⚙ 配置</button>
</div>
</div>
<div class="agent-body">
<div class="agent-col-sessions">
<div class="agent-sess-head">
<button class="btn btn-primary" style="width:100%" onclick="createAgentSession()">+ 新建会话</button>
</div>
<div id="agent-sess-list" class="agent-sess-list"></div>
</div>
<div class="agent-col-chat">
<div id="agent-chat" class="agent-chat"></div>
</div>
<div class="agent-col-live">
<div class="agent-live-head">📺 实时画面<span id="agent-live-serial" class="agent-live-serial"></span></div>
<div class="agent-live-body" id="agent-live-body">
<img id="agent-live-img" alt="" style="display:none;width:100%">
<div class="agent-empty" id="agent-live-empty">运行任务时自动跟随操作的设备;<br>也可手动选择设备观看</div>
</div>
<select id="agent-live-select" class="form-control" style="margin-top:8px"
onchange="watchDevice(this.value)"><option value="">选择设备观看…</option></select>
</div>
</div>
<div class="agent-inputbar" style="align-items:flex-end;flex-direction:column">
<div style="display:flex;width:100%;gap:8px;align-items:center;margin-bottom:8px">
<label style="font-size:12.5px;color:#374151;font-weight:600;white-space:nowrap">🎯 目标设备</label>
<select id="agent-target-select" class="form-control" style="width:320px;font-family:monospace"
title="AI 只操作你选定的设备;有任务运行中的设备不可选">
<option value="">请选择设备(AI 只操作选定设备)…</option>
</select>
<span class="text-muted" id="agent-target-hint" style="font-size:11.5px"></span>
<span style="flex:1"></span>
<button class="btn btn-xs btn-danger" id="btn-agent-stop" style="display:none" onclick="stopAgent()">■ 停止</button>
</div>
<div style="display:flex;width:100%;gap:10px">
<textarea id="agent-input" rows="2"
placeholder="给 AI 下达指令,例如:打开抖音搜索奚学东,把第一个视频标题告诉我…(Enter 发送,Shift+Enter 换行)"></textarea>
<button class="btn btn-primary" id="btn-agent-send" onclick="sendAgentMsg()">发送</button>
</div>
</div>
</div>
</div>
<!-- ========== 配置模态框 ========== -->
<div id="agent-cfg-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:1300;align-items:center;justify-content:center">
<div style="background:#11151c;border:1px solid var(--card-line);border-radius:14px;padding:22px;width:460px;max-width:92vw">
<div style="font-size:15px;font-weight:700;margin-bottom:14px">模型配置(OpenAI 兼容 API)</div>
<div class="form-group"><label>API Base</label>
<input type="text" id="acfg-base" class="form-control" placeholder="https://api.deepseek.com" style="font-family:monospace"></div>
<div class="form-group"><label>模型名</label>
<input type="text" id="acfg-model" class="form-control" placeholder="deepseek-v4-flash-vision-exp"></div>
<div class="form-group"><label>API Key <span class="text-muted" id="acfg-key-hint"></span></label>
<input type="password" id="acfg-key" class="form-control" placeholder="留空则不修改"></div>
<div class="form-group"><label>默认设备 serial(可选)</label>
<input type="text" id="acfg-serial" class="form-control" placeholder="192.168.20.66:5555" style="font-family:monospace"></div>
<div class="form-group"><label>最大执行步骤(1-200,默认 40)</label>
<input type="number" id="acfg-steps" class="form-control" min="1" max="200" placeholder="40"
title="单轮任务允许的最大工具调用轮数;超限会收尾总结(防死循环)"></div>
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:6px">
<button class="btn" onclick="closeAgentConfig()">取消</button>
<button class="btn btn-primary" onclick="saveAgentConfig()">保存</button>
</div>
</div>
</div>
<!-- ========== 经验库模态框(自进化记忆管理 + 每日 AI 巡检建议) ========== -->
<div id="agent-exp-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:1320;align-items:center;justify-content:center">
<div style="background:#11151c;border:1px solid var(--card-line);border-radius:14px;padding:20px 22px;width:760px;max-width:94vw;max-height:86vh;display:flex;flex-direction:column">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
<div style="font-size:15px;font-weight:700">🧠 经验库<span class="text-muted" style="font-size:11.5px;font-weight:400;margin-left:8px" id="exp-lib-hint"></span></div>
<div style="display:flex;gap:6px;align-items:center">
<button class="btn btn-xs" onclick="triggerExpAudit()">🔍 立即巡检</button>
<button class="btn btn-xs" onclick="closeExpLib()">✕ 关闭</button>
</div>
</div>
<div class="text-muted" style="font-size:11.5px;padding:0 2px 8px;border-bottom:1px solid var(--card-line);margin-bottom:10px">
每次任务成功后会提炼「操作配方」存入经验库,下次相似任务自动参考;每日 03:47 AI 巡检给出疑似问题建议(删除需人工确认)。
<span id="exp-lib-last" class="text-muted"></span>
</div>
<div id="exp-lib-list" style="flex:1;overflow-y:auto;min-height:200px;display:flex;flex-direction:column;gap:8px"></div>
</div> </div>
<table class="table">
<thead><tr>
<th data-sort="name" onclick="_lpSort('groups','name')">分组名</th><th>描述</th><th data-sort="count" onclick="_lpSort('groups','count')">设备数</th><th>设备列表</th><th>操作</th>
</tr></thead>
<tbody id="tb-groups"></tbody>
</table>
<div class="list-pager" id="pager-groups"></div>
</div> </div>
<!-- ========== Tab 4: 日志 ========== --> <!-- ========== Tab 4: 日志 ========== -->
@@ -453,19 +605,6 @@ select.form-control{cursor:pointer}
</div> </div>
<!-- ========== Tab 7: 维护(仅管理员) ========== --> <!-- ========== Tab 7: 维护(仅管理员) ========== -->
<div id="tab-maintenance" class="tab-panel">
<div class="page-title">维护</div>
<div class="page-sub">管理员专用:STF 服务管理(adb 远程终端与 Tailscale 管理已移至"工具")</div>
<div class="toolbar">
<button class="btn btn-danger" onclick="restartStf()">一键重启 STF 容器</button>
<span id="stf-restart-status" class="text-muted"></span>
</div>
<div class="help" style="margin-top:8px">
通过 SSH 到部署机执行 <code>docker restart</code>(目标与容器名可用环境变量
<code>STF_SSH_TARGET</code> / <code>STF_DOCKER_CONTAINER</code> 配置)。
影响:STF 约 30 秒不可用,期间设备占用状态重置;<b>有任务在运行时会被拒绝执行</b>。
</div>
</div>
<!-- ========== Tab 8: 工具(仅管理员) ========== --> <!-- ========== Tab 8: 工具(仅管理员) ========== -->
<div id="tab-tools" class="tab-panel"> <div id="tab-tools" class="tab-panel">
@@ -479,7 +618,8 @@ select.form-control{cursor:pointer}
<button class="sub-tab" data-sub="apks" onclick="showSubTab('tools','apks')">应用管理</button> <button class="sub-tab" data-sub="apks" onclick="showSubTab('tools','apks')">应用管理</button>
<button class="sub-tab" data-sub="appver" onclick="showSubTab('tools','appver')">应用版本管理</button> <button class="sub-tab" data-sub="appver" onclick="showSubTab('tools','appver')">应用版本管理</button>
<button class="sub-tab" data-sub="devapps" onclick="showSubTab('tools','devapps')">设备已装应用</button> <button class="sub-tab" data-sub="devapps" onclick="showSubTab('tools','devapps')">设备已装应用</button>
<button class="sub-tab" data-sub="stfdev" onclick="showSubTab('tools','stfdev')">STF 设备管理</button> <button class="sub-tab" data-sub="devpool" onclick="showSubTab('tools','devpool')">设备池管理</button>
<button class="sub-tab" data-sub="groups" onclick="showSubTab('tools','groups')">设备分组</button>
</div> </div>
<!-- 子分栏 1:剪贴板注入 --> <!-- 子分栏 1:剪贴板注入 -->
@@ -499,11 +639,11 @@ select.form-control{cursor:pointer}
<button class="btn btn-primary" onclick="injectClipboard()">注入剪贴板</button> <button class="btn btn-primary" onclick="injectClipboard()">注入剪贴板</button>
<span class="hint">注入后需在目标 App 的输入框长按选择"粘贴"</span> <span class="hint">注入后需在目标 App 的输入框长按选择"粘贴"</span>
</div> </div>
<div class="help">实现:u2 setClipboard(jsonrpc 直连 atx-agent),实测支持中文、引号、换行;USB 设备首次连接会自动推送 atx-agent。</div> <div class="help">实现:ClipInject APK 透明 Activity 写入剪贴板(Android 10+ 可用,u2 setClipboard 在 Android 12/13 会被系统丢弃);设备需先安装 com.example.clipinject,注入后自动读回比对验证。</div>
</div> </div>
<div style="flex:1;min-width:280px"> <div style="flex:1;min-width:280px">
<div class="form-group"> <div class="form-group">
<label>目标设备(本地 adb 含 USB + STF 池,可多选)</label> <label>目标设备(本地 adb 含 USB + 设备池,可多选)</label>
<div class="device-list" id="clip-devices" style="max-height:300px"> <div class="device-list" id="clip-devices" style="max-height:300px">
<div class="empty" style="padding:20px">点击"刷新设备"加载</div> <div class="empty" style="padding:20px">点击"刷新设备"加载</div>
</div> </div>
@@ -523,7 +663,7 @@ select.form-control{cursor:pointer}
<div class="sub-panel" id="tools-sub-adb"> <div class="sub-panel" id="tools-sub-adb">
<div class="help">选中目标设备后,命令里的 <code>-s &lt;设备&gt;</code> 自动附加,直接写 <div class="help">选中目标设备后,命令里的 <code>-s &lt;设备&gt;</code> 自动附加,直接写
<code>shell ls /sdcard</code> 即可。离线设备也可选,用"重连设备"恢复。 <code>shell ls /sdcard</code> 即可。离线设备也可选,用"重连设备"恢复。
<b>禁止 kill-server / disconnect</b>(会中断 STF provider 的设备监控)。</div> <b>禁止 kill-server / disconnect</b>(会中断共享的 adb transport,导致设备连接全部重建)。</div>
<div style="display:flex;gap:8px;margin:8px 0;align-items:center"> <div style="display:flex;gap:8px;margin:8px 0;align-items:center">
<label style="font-size:12px;color:var(--text-light)">目标设备</label> <label style="font-size:12px;color:var(--text-light)">目标设备</label>
<select id="adb-device" class="form-control" style="flex:1;max-width:340px;font-family:monospace"></select> <select id="adb-device" class="form-control" style="flex:1;max-width:340px;font-family:monospace"></select>
@@ -541,11 +681,10 @@ select.form-control{cursor:pointer}
<button class="btn btn-xs" onclick="fillAdbCmd('shell ls /sdcard')">列文件</button> <button class="btn btn-xs" onclick="fillAdbCmd('shell ls /sdcard')">列文件</button>
<button class="btn btn-xs" onclick="fillAdbCmd('shell screencap -p /sdcard/shot.png')">截屏</button> <button class="btn btn-xs" onclick="fillAdbCmd('shell screencap -p /sdcard/shot.png')">截屏</button>
<button class="btn btn-xs" onclick="fillAdbCmd('shell pm list packages -3')">已装应用</button> <button class="btn btn-xs" onclick="fillAdbCmd('shell pm list packages -3')">已装应用</button>
<button class="btn btn-xs" onclick="fillAdbCmd('tcpip 5555')" title="USB 设备开启网络调试(重启设备后失效,需重新执行)">tcpip 5555 转网络调试</button>
</div> </div>
<div style="display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap"> <div style="display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap">
<button class="btn btn-xs btn-danger" onclick="uninstallStfAgent()" title="卸载 jp.co.cyberagent.stf,STF 会在设备重连时重新推送">卸载 STF agent(卡死时用)</button>
<button class="btn btn-xs" onclick="reconnectAdbDevice()" title="重新 adb connect 该设备">重连设备</button> <button class="btn btn-xs" onclick="reconnectAdbDevice()" title="重新 adb connect 该设备">重连设备</button>
<button class="btn btn-xs" onclick="checkStfAgent()" title="查看 jp.co.cyberagent.stf 是否已安装">检查 agent</button>
</div> </div>
<pre id="adb-output" class="adb-console">(执行命令后输出显示在这里)</pre> <pre id="adb-output" class="adb-console">(执行命令后输出显示在这里)</pre>
</div> </div>
@@ -566,7 +705,7 @@ select.form-control{cursor:pointer}
<tbody id="tb-tailscale"><tr><td colspan="8" class="empty">点击"刷新设备"加载</td></tr></tbody> <tbody id="tb-tailscale"><tr><td colspan="8" class="empty">点击"刷新设备"加载</td></tr></tbody>
</table> </table>
<div class="list-pager" id="pager-tailscale"></div> <div class="list-pager" id="pager-tailscale"></div>
<div class="help">"设置IP"可修改设备 IPv4(会断开当前连接,平台 serial 随之变化,需同步 STF 设备池);"密钥不过期"= 关闭设备密钥验证,设备不会被定期踢下线;auth key 生成后只显示一次,请立即复制。</div> <div class="help">"设置IP"可修改设备 IPv4(会断开当前连接,平台 serial 随之变化,需同步设备池);"密钥不过期"= 关闭设备密钥验证,设备不会被定期踢下线;auth key 生成后只显示一次,请立即复制。</div>
</div> </div>
<!-- 子分栏 4:应用管理 --> <!-- 子分栏 4:应用管理 -->
@@ -608,7 +747,7 @@ select.form-control{cursor:pointer}
<tbody id="tb-appver"><tr><td colspan="4" class="empty">输入包名后点击"查询版本"</td></tr></tbody> <tbody id="tb-appver"><tr><td colspan="4" class="empty">输入包名后点击"查询版本"</td></tr></tbody>
</table> </table>
<div class="list-pager" id="pager-appver"></div> <div class="list-pager" id="pager-appver"></div>
<div class="help">查询所有设备(本地 adb 含 USB + STF 池)上该包名的安装情况与版本号,用于对比各设备 App 版本是否一致;未连接的 STF 设备会自动连接(绝不断开,不影响任务)。</div> <div class="help">查询所有设备(本地 adb 含 USB + 设备池)上该包名的安装情况与版本号,用于对比各设备 App 版本是否一致;未连接的设备会自动连接(绝不断开,不影响任务)。</div>
</div> </div>
<!-- 子分栏 6:设备已装应用 --> <!-- 子分栏 6:设备已装应用 -->
@@ -634,38 +773,92 @@ select.form-control{cursor:pointer}
<div class="list-pager" id="pager-devapps"></div> <div class="list-pager" id="pager-devapps"></div>
</div> </div>
<!-- 子分栏 6:STF 设备管理 --> <!-- 子分栏 6:设备池管理(本地 SQLite 清单,任务调度的数据源) -->
<div class="sub-panel" id="tools-sub-stfdev"> <div class="sub-panel" id="tools-sub-devpool">
<div class="toolbar"> <!-- 自动发现:扫描网段 → 待连接池,确认后才加入正式池 -->
<input type="text" id="stfdev-ip" class="form-control" style="width:220px;font-family:monospace" <div style="border-bottom:1px solid var(--border);margin-bottom:10px;padding-bottom:10px">
placeholder="设备 IP,如 100.100.10.20" onkeydown="if(event.key==='Enter')addStfDev()"> <div class="toolbar" style="flex-wrap:wrap">
<button class="btn btn-primary" onclick="addStfDev()">添加设备并连接</button> <label style="display:inline-flex;align-items:center;gap:4px">
<button class="btn" onclick="loadStfDevMgmt()">刷新</button> <input type="checkbox" id="discovery-enabled" onchange="toggleDiscovery(this.checked)"> 定时扫描
<input class="form-control list-search" id="search-stfdev" placeholder="搜索 IP..."> </label>
<span id="stfdev-status" class="text-muted"></span> <span class="text-muted" style="margin-left:8px">周期</span>
<input type="number" id="discovery-interval" style="width:70px" min="10">
<span class="text-muted">秒 · 网段</span>
<input type="text" id="discovery-subnets" class="form-control" style="width:300px;font-family:monospace"
placeholder="192.168.20.0/24, 100.100.10.0/24" title="逗号分隔的 CIDR 网段">
<button class="btn" onclick="saveDiscoverySettings()">保存设置</button>
<button class="btn btn-primary" onclick="runDiscoveryScan()">立即扫描</button>
<span id="discovery-status" class="text-muted"></span>
</div> </div>
<table class="table"> <table class="table table-hover table-sm" style="margin-top:8px">
<thead><tr><th>serial</th><th>来源</th><th>首次发现</th><th>最近发现</th><th>在线</th><th>操作</th></tr></thead>
<tbody id="tb-discovery"><tr><td colspan="6" class="empty">尚未扫描</td></tr></tbody>
</table>
<div class="help">扫描局域网/Tailscale 网段中开放 adb 5555 的设备进入<b>待连接池</b>;
<b>确认后才加入正式设备池</b>(不自动连接、不参与任务调度)。未授权设备(手机未接受 RSA 弹窗)不会出现。</div>
<!-- 正式池断联设备:仍在设备池(不删除、不进待连接池),自动重连中 -->
<div style="margin-top:10px">
<div style="font-size:12.5px;font-weight:600;margin-bottom:6px">设备池断联设备
<span class="text-muted" style="font-weight:400">(仍在设备池内,扫描线程每轮自动重连;也可手动立即重连)</span></div>
<table class="table table-hover table-sm">
<thead><tr><th>serial</th><th>型号</th><th>备注</th><th>状态</th><th>操作</th></tr></thead>
<tbody id="tb-pool-offline"><tr><td colspan="5" class="empty">全部在线</td></tr></tbody>
</table>
</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()">
<input type="text" id="devpool-name" class="form-control" style="width:150px"
placeholder="备注名(可选)" onkeydown="if(event.key==='Enter')addPoolDev()">
<button class="btn btn-primary" onclick="addPoolDev()">添加设备</button>
<button class="btn" onclick="reconnectPoolDevices()" title="并发 adb connect 池内全部设备">一键重连</button>
<button class="btn" onclick="refreshPoolModels()" title="采集池内在线设备的型号(getprop)">采集型号</button>
<button class="btn" onclick="loadDevPool()">刷新</button>
<label style="display:inline-flex;align-items:center;gap:4px;margin:0 6px">
<input type="checkbox" id="devpool-filter-online" checked onchange="loadDevPool()"> 只看在线
</label>
<span id="devpool-status" class="text-muted"></span>
</div>
<table class="table table-hover table-sm" style="margin-top:8px">
<thead><tr> <thead><tr>
<th data-sort="ip" onclick="_lpSort('stfdev','ip')">设备</th> <th>serial</th><th>名称</th><th>型号</th><th>在线</th><th>状态</th><th>添加时间</th><th>操作</th>
<th data-sort="cfg" onclick="_lpSort('stfdev','cfg')">脚本配置</th>
<th data-sort="state" onclick="_lpSort('stfdev','state')">连接状态(220 adb)</th>
<th>操作</th>
</tr></thead> </tr></thead>
<tbody id="tb-stfdev"><tr><td colspan="4" class="empty">点击"刷新"加载</td></tr></tbody> <tbody id="tb-devpool"></tbody>
</table> </table>
<div class="list-pager" id="pager-stfdev"></div>
<div class="help"> <div class="help">
管理 220 上 OpenSTF 设备池:添加 = 写入 connect_devices.sh 的 DEVICES 列表并立即 adb connect; 新增设备流程:手机开 USB 调试 → 连上后「adb 终端」执行 <code>tcpip 5555</code> 转网络调试 →
移除 = 从脚本删除并 adb disconnect(设备从 STF 池下线,任务将不再分配到它)。 Tailscale 上线(同一账号)→ 在此添加 <code>IP:5555</code>(自动尝试连接)→
脚本的 cron 每 5 分钟自动补连掉线设备。adb 操作发生在 220(STF provider 侧),不影响本机任务直连。 任务运行时 u2 自动推送 atx-agent。<b>停用 = 不参与任务调度</b>,删除 = 从池中移除(不影响其他系统)。
</div> </div>
</div> </div>
<!-- 子分栏:设备分组(原顶级 tab,移到工具页) -->
<div class="sub-panel" id="tools-sub-groups">
<div class="toolbar">
<button class="btn btn-primary" onclick="openGroupModal()" data-perm="tasks">新建分组</button>
<button class="btn" onclick="loadGroups()">刷新</button>
<input class="form-control list-search" id="search-groups" placeholder="搜索分组名/描述...">
<span id="groups-status" class="text-muted"></span>
</div>
<table class="table table-hover table-sm" style="margin-top:8px">
<thead><tr>
<th data-sort="name" onclick="_lpSort('groups','name')">分组名</th><th>描述</th><th data-sort="count" onclick="_lpSort('groups','count')">设备数</th><th>设备列表</th><th>操作</th>
</tr></thead>
<tbody id="tb-groups"></tbody>
</table>
<div class="list-pager" id="pager-groups"></div>
<div class="help">管理设备分组,用于任务按分组执行。分组内的设备需先在<b>设备池管理</b>中确认(不在池内的设备不参与调度)。</div>
</div>
</div> </div>
</div><!-- /.content --> </div><!-- /.content -->
<!-- ========== 测试此步骤弹窗 ========== --> <!-- ========== 测试此步骤弹窗 ========== -->
<div id="test-overlay" class="modal-overlay" onclick="if(event.target===this)closeTestModal()"> <div id="test-overlay" class="modal-overlay" style="z-index:1200" onclick="if(event.target===this)closeTestModal()">
<div class="modal" id="test-modal-box"> <div class="modal" id="test-modal-box">
<div class="modal-header"> <div class="modal-header">
<h3 id="test-modal-title">测试步骤</h3> <h3 id="test-modal-title">测试步骤</h3>
@@ -715,6 +908,15 @@ select.form-control{cursor:pointer}
<!-- ========== Toast ========== --> <!-- ========== Toast ========== -->
<div id="toast" class="toast"></div> <div id="toast" class="toast"></div>
<!-- 前端模块(按依赖顺序加载:base → list → 页面模块 → admin(含初始化)) -->
<script src="/static/admin/base.js"></script>
<script src="/static/admin/list.js"></script>
<script src="/static/admin/monitor.js"></script> <script src="/static/admin/monitor.js"></script>
<script src="/static/admin/editor.js"></script>
<script src="/static/admin/tasks.js"></script>
<script src="/static/admin/tools.js"></script>
<script src="/static/admin/apps.js"></script>
<script src="/static/admin/admin.js"></script>
<script src="/static/admin/agent.js"></script>
</body> </body>
</html> </html>
+481
View File
@@ -0,0 +1,481 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>设备监控大屏</title>
<style>
/* ========== 监控大屏:深色控制室风格 ========== */
@font-face{font-family:'Bricolage Grotesque';font-style:normal;font-weight:700;font-display:swap;src:url('/static/fonts/bricolage-700.woff2') format('woff2')}
@font-face{font-family:'Bricolage Grotesque';font-style:normal;font-weight:800;font-display:swap;src:url('/static/fonts/bricolage-800.woff2') format('woff2')}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url('/static/fonts/plexmono-400.woff2') format('woff2')}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:600;font-display:swap;src:url('/static/fonts/plexmono-600.woff2') format('woff2')}
:root{
--bg:#0d0f13; --card:#14181f; --card-line:#232936; --ink-line:#2a303c;
--text:#e8ecf2; --muted:#8b93a3; --faint:#5c6472;
--teal:#2dd4bf; --green:#34d399; --red:#f87171; --amber:#fbbf24; --indigo:#818cf8;
--display:'Bricolage Grotesque','PingFang SC','Microsoft YaHei',sans-serif;
--mono:'IBM Plex Mono',Menlo,Consolas,monospace;
--body:-apple-system,'PingFang SC','Microsoft YaHei',sans-serif;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{background:var(--bg);font-family:var(--body);color:var(--text);overflow:hidden}
body::before{content:"";position:fixed;inset:0;pointer-events:none;
background-image:linear-gradient(rgba(45,212,191,.035) 1px,transparent 1px),
linear-gradient(90deg,rgba(45,212,191,.035) 1px,transparent 1px);
background-size:42px 42px}
/* 顶部栏 */
header{display:flex;align-items:center;gap:24px;padding:14px 26px 12px;border-bottom:1px solid var(--ink-line);background:rgba(13,15,19,.85);backdrop-filter:blur(6px);position:relative;z-index:5}
header .title{font-family:var(--display);font-size:21px;font-weight:800;letter-spacing:1px;color:#fff;display:flex;align-items:center;gap:12px;white-space:nowrap}
header .title::before{content:"";width:10px;height:10px;border-radius:50%;background:var(--teal);box-shadow:0 0 0 0 rgba(45,212,191,.55);animation:pulse 2.2s infinite}
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(45,212,191,.5)}70%{box-shadow:0 0 0 9px rgba(45,212,191,0)}100%{box-shadow:0 0 0 0 rgba(45,212,191,0)}}
header .stats{display:flex;gap:10px;flex:1}
header .stat{background:var(--card);border:1px solid var(--card-line);border-radius:10px;padding:6px 16px;text-align:center;min-width:84px}
header .stat .n{font-family:var(--mono);font-size:20px;font-weight:600;line-height:1.15}
header .stat .l{font-size:11px;color:var(--faint);margin-top:2px;letter-spacing:.4px}
header .stat.online .n{color:var(--green)}
header .stat.running .n{color:var(--teal)}
header .stat.error .n{color:var(--red)}
header .wall-btn{background:var(--card);border:1px solid var(--card-line);color:var(--teal);border-radius:10px;padding:8px 18px;font-size:13px;font-weight:600;cursor:pointer;transition:all .15s;font-family:var(--body)}
header .wall-btn:hover{background:#1a2029;border-color:var(--teal);transform:translateY(-1px)}
header .wall-btn:disabled{opacity:.5;cursor:not-allowed}
header .clock{font-family:var(--mono);font-size:18px;color:var(--muted);text-align:right;line-height:1.2;white-space:nowrap}
header .clock .date{font-size:11px;color:var(--faint)}
/* 设备网格 */
main{position:relative;z-index:2;height:calc(100vh - 88px);overflow-y:auto;padding:16px 24px 30px;display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px;align-content:start}
/* 设备卡片(可点击进操作) */
.card{background:var(--card);border:1px solid var(--card-line);border-radius:14px;overflow:hidden;position:relative;animation:cardIn .4s ease-out backwards;cursor:pointer;transition:border-color .2s,box-shadow .2s,transform .15s}
.card:hover{transform:translateY(-3px);box-shadow:0 8px 22px rgba(0,0,0,.4)}
@keyframes cardIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
.card.running{border-color:rgba(45,212,191,.55);box-shadow:0 0 18px rgba(45,212,191,.12),inset 0 0 0 1px rgba(45,212,191,.2)}
.card.error{border-color:rgba(248,113,113,.5)}
.card.offline{opacity:.62}
.card .thumb{aspect-ratio:9/16;background:#0a0c10;position:relative;overflow:hidden}
.card .thumb img{width:100%;height:100%;object-fit:cover;display:block;transition:opacity .2s}
.card.offline .thumb img{display:none}
.card .thumb .ph{position:absolute;inset:0;display:none;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:var(--faint);font-size:12px;background:repeating-linear-gradient(45deg,#0c0e12,#0c0e12 12px,#0f1218 12px,#0f1218 24px)}
.card .thumb .ph .ico{font-size:26px;opacity:.6}
/* 状态角标 */
.card .badge{position:absolute;top:8px;left:8px;display:flex;align-items:center;gap:6px;background:rgba(13,15,19,.8);backdrop-filter:blur(4px);border-radius:99px;padding:3px 10px;font-size:11px;font-weight:600;color:var(--muted);border:1px solid var(--ink-line)}
.card.running .badge{color:var(--teal);border-color:rgba(45,212,191,.4)}
.card.error .badge{color:var(--red)}
.card .badge .dot{width:7px;height:7px;border-radius:50%;background:var(--faint)}
.card.running .badge .dot{background:var(--teal);box-shadow:0 0 7px rgba(45,212,191,.8);animation:pulse 1.6s infinite}
.card.error .badge .dot{background:var(--red);box-shadow:0 0 6px rgba(248,113,113,.7)}
.card.offline .badge .dot{background:var(--faint)}
/* 熄屏角标(右上) */
.card .screen-tag{position:absolute;top:8px;right:8px;background:rgba(13,15,19,.8);backdrop-filter:blur(4px);border-radius:99px;padding:3px 10px;font-size:11px;font-weight:600;border:1px solid var(--ink-line)}
.card .screen-tag.on{color:var(--green)}
.card .screen-tag.off{color:var(--faint)}
/* 信息区 */
.card .info{padding:9px 12px 11px}
.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 .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}
.card .info .prog i{display:block;height:100%;background:linear-gradient(90deg,var(--teal),#5eead4);border-radius:99px;transition:width .5s}
.card .info .prog-txt{font-size:10px;color:var(--faint);margin-top:3px;font-family:var(--mono)}
.card .info .hint{font-size:10px;color:var(--faint);margin-top:5px;text-align:center;opacity:0;transition:opacity .2s}
.card:hover .info .hint{opacity:.8}
/* 空状态 */
.empty-wall{grid-column:1/-1;text-align:center;color:var(--faint);padding:80px 0;font-size:14px}
/* 设备操作弹窗 */
.ctrl-overlay{display:none;position:fixed;inset:0;background:rgba(5,7,10,.78);backdrop-filter:blur(3px);z-index:100;justify-content:center;align-items:center;padding:26px}
.ctrl-overlay.show{display:flex}
.ctrl-box{background:var(--card);border:1px solid var(--card-line);border-radius:16px;width:min(420px,94vw);max-height:94vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 24px 70px rgba(0,0,0,.6);animation:modalIn .18s ease-out}
@keyframes modalIn{from{opacity:0;transform:translateY(12px) scale(.98)}to{opacity:1;transform:none}}
.ctrl-head{padding:12px 16px;border-bottom:1px solid var(--ink-line);display:flex;align-items:center;gap:12px;background:#11151c}
.ctrl-head .info{flex:1;min-width:0}
.ctrl-head .name{font-size:15px;font-weight:700;color:#f1f4f8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ctrl-head .sub{font-family:var(--mono);font-size:11.5px;color:var(--faint);margin-top:1px}
.ctrl-head .close{background:none;border:none;color:var(--faint);font-size:22px;cursor:pointer;padding:0 6px;line-height:1;border-radius:6px}
.ctrl-head .close:hover{color:#fff;background:#1d242e}
.ctrl-body{flex:1;overflow-y:auto;padding:12px;background:#0a0c10}
.ctrl-body .screen{position:relative;border-radius:10px;overflow:hidden;background:#000;cursor:crosshair;user-select:none;touch-action:none}
.ctrl-body .screen img{width:100%;display:block;max-height:62vh;object-fit:contain;background:#000}
.ctrl-body .screen .loading{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.55);color:var(--muted);font-size:12px;border-radius:10px}
.ctrl-tools{display:flex;gap:6px;flex-wrap:wrap;padding:10px 12px 12px;border-top:1px solid var(--ink-line);background:#11151c}
.ctrl-tools .btn{background:#1a2029;border:1px solid var(--card-line);color:var(--muted);border-radius:8px;padding:6px 12px;font-size:12px;cursor:pointer;transition:all .12s;font-family:var(--body)}
.ctrl-tools .btn:hover{color:var(--teal);border-color:var(--teal)}
.ctrl-tools .btn.wake{color:var(--green);border-color:rgba(52,211,153,.4)}
.ctrl-tools .btn.wake:hover{border-color:var(--green)}
.ctrl-tools .btn.sleep{color:var(--faint)}
.ctrl-tools .text-row{display:flex;gap:6px;flex:1 0 100%;margin-top:2px}
.ctrl-tools .text-row input{flex:1;background:#0d1117;border:1px solid var(--card-line);border-radius:8px;padding:6px 10px;font-size:12px;color:var(--text);outline:none;font-family:var(--body)}
.ctrl-tools .text-row input:focus{border-color:var(--teal)}
/* 滚动条 */
::-webkit-scrollbar{width:8px}
::-webkit-scrollbar-thumb{background:#232936;border-radius:4px}
::-webkit-scrollbar-track{background:transparent}
</style>
</head>
<body>
<header>
<div class="title">设备监控大屏</div>
<div class="stats" id="wall-stats"></div>
<label class="wall-btn" style="display:inline-flex;align-items:center;gap:4px;cursor:pointer">
<input type="checkbox" id="wall-filter-online" checked onchange="refreshStatus()"> 只看在线
</label>
<button class="wall-btn" id="btn-wake-all" onclick="wakeAll()">💡 一键亮屏</button>
<div class="clock"><span id="wall-clock">--:--:--</span><div class="date" id="wall-date"></div></div>
</header>
<main id="wall-grid"></main>
<!-- 设备操作弹窗(看屏 + 触控) -->
<div class="ctrl-overlay" id="ctrl-overlay">
<div class="ctrl-box">
<div class="ctrl-head">
<div class="info">
<div class="name" id="ctrl-name">设备</div>
<div class="sub" id="ctrl-sub">--</div>
</div>
<button class="close" onclick="closeCtrl()">×</button>
</div>
<div class="ctrl-body">
<div class="screen" id="ctrl-screen">
<img id="ctrl-img" alt="">
<div class="loading" id="ctrl-loading">连接中(首次需推送 atx-agent,最长约 30 秒)...</div>
</div>
</div>
<div class="ctrl-tools">
<button class="btn wake" onclick="ctrlWake()">💡 亮屏</button>
<button class="btn sleep" onclick="ctrlSleep()">🌙 息屏</button>
<button class="btn" onclick="ctrlKey('back')">◀ 返回</button>
<button class="btn" onclick="ctrlKey('home')">🏠 主页</button>
<button class="btn" onclick="ctrlKey('recent')">📋 最近</button>
<button class="btn" onclick="ctrlKey('menu')">☰ 菜单</button>
<button class="btn" onclick="ctrlKey('power')">⏻ 电源</button>
<div class="text-row">
<input type="text" id="ctrl-text" placeholder="输入文字(需光标在输入框)" onkeydown="if(event.key==='Enter')ctrlSendText()">
<button class="btn" onclick="ctrlSendText()">发送</button>
</div>
</div>
</div>
</div>
<script>
// ================== 监控大屏(深色控制室) ==================
// 状态每 5s 刷新;缩略图每 2.5s 轮询(页面不可见时暂停)
// 缩略图走 fetch 读取 X-Screen-State 头,卡片显示 亮屏中/熄屏中
const STATUS_MAP={idle:'空闲',connecting:'连接中',running:'运行中',done:'完成',error:'异常',released:'已释放',failed:'失败'};
const APP_NAMES={'com.ss.android.ugc.aweme':'抖音','com.ss.android.ugc.aweme.lite':'抖音极速版','com.smile.gifmaker':'快手','com.kuaishou.nebula':'快手极速版','com.tencent.mm':'微信','com.tencent.mobileqq':'QQ','com.miui.home':'桌面','com.android.launcher':'桌面','com.android.settings':'设置','com.android.systemui':'系统'};
let _devices=[];
let _ctrlSerial=''; // 操作弹窗当前设备
let _ctrlTimer=null; // 弹窗状态刷新定时器
let _screenState={}; // serial -> on/off(来自缩略图响应头)
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
// CSRF:独立页面需先取 token,POST 必须携带(与主后台 base.js 一致)
let _csrfToken='';
async function initCsrf(){
try{const r=await fetch('/api/csrf');const d=await r.json();_csrfToken=d.token||'';}catch(e){}
}
async function apiGet(url){
try{
const r=await fetch(url);
if(r.status===401){window.location='/login';return null;}
return await r.json();
}catch(e){return null;}
}
async function apiPost(url,body){
try{
const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':_csrfToken},body:JSON.stringify(body||{})});
if(r.status===401){window.location='/login';return null;}
return await r.json();
}catch(e){return null;}
}
// 时钟
function tickClock(){
const now=new Date();
document.getElementById('wall-clock').textContent=
String(now.getHours()).padStart(2,'0')+':'+String(now.getMinutes()).padStart(2,'0')+':'+String(now.getSeconds()).padStart(2,'0');
document.getElementById('wall-date').textContent=
now.getFullYear()+'-'+String(now.getMonth()+1).padStart(2,'0')+'-'+String(now.getDate()).padStart(2,'0')+' '+['周日','周一','周二','周三','周四','周五','周六'][now.getDay()];
}
// 状态刷新(5s)
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]));
_devices=devs;
renderStats(devs);
if(changed)renderGrid(visibleDevs(devs));
// 操作弹窗打开时同步刷新其标题状态
if(_ctrlSerial)updateCtrlHead();
}
// 「只看在线」勾选时过滤离线设备(默认勾选)
function visibleDevs(devs){
const onlyOnline=document.getElementById('wall-filter-online')
? document.getElementById('wall-filter-online').checked : true;
return onlyOnline? devs.filter(d=>d.present) : devs;
}
function renderStats(devs){
const online=devs.filter(d=>d.present).length;
const running=devs.filter(d=>['running','connecting'].includes(d.worker_status)).length;
const error=devs.filter(d=>['error','failed'].includes(d.worker_status)).length;
document.getElementById('wall-stats').innerHTML=
'<div class="stat"><div class="n">'+devs.length+'</div><div class="l">设备总数</div></div>'+
'<div class="stat online"><div class="n">'+online+'</div><div class="l">在线</div></div>'+
'<div class="stat running"><div class="n">'+running+'</div><div class="l">运行中</div></div>'+
'<div class="stat error"><div class="n">'+error+'</div><div class="l">异常</div></div>';
}
function cardCls(d){
if(['error','failed'].includes(d.worker_status))return 'error';
if(['running','connecting'].includes(d.worker_status))return 'running';
return d.present?'online':'offline';
}
// 状态角标文案:运行中/异常优先,其次熄屏/亮屏状态
function badgeText(d){
if(['running','connecting'].includes(d.worker_status))return STATUS_MAP[d.worker_status]||d.worker_status;
if(['error','failed'].includes(d.worker_status))return STATUS_MAP[d.worker_status]||d.worker_status;
if(!d.present)return '离线';
return _screenState[d.serial]==='off'?'熄屏中':'亮屏中';
}
function fmtProgress(p){
if(!p||!p.total)return '';
const pct=Math.min(100,Math.round((p.done||0)/p.total*100));
return '<div class="prog"><i style="width:'+pct+'%"></i></div>'+
'<div class="prog-txt">'+(p.done||0)+'/'+p.total+(p.unit?' '+esc(p.unit):'')+' · '+pct+'%</div>';
}
function shortIp(serial){return String(serial||'').replace(/:5555$/,'');}
function cardHtml(d, i){
const cls=cardCls(d);
const serial=esc(d.serial);
const name=esc(d.device_name||d.model||'设备');
const ip=esc(shortIp(d.serial));
const st=badgeText(d);
const stTag=_screenState[d.serial]==='off'?'<div class="screen-tag off">🌙</div>':'';
const action=d.current_action
?'<div class="action">'+(d.task_job?'<b>'+esc(d.task_job)+'</b> · ':'')+esc(d.current_action)+'</div>'
:(d.task_job?'<div class="action"><b>'+esc(d.task_job)+'</b></div>':'<div class="action"></div>');
return '<div class="card '+cls+'" data-serial="'+serial+'" style="animation-delay:'+(i*30)+'ms" onclick="openCtrl(\''+serial+'\')">'+
'<div class="thumb">'+
'<img data-thumb="'+serial+'" alt="">'+
'<div class="ph"><span class="ico">📱</span><span>'+serial+'</span></div>'+
'<div class="badge"><span class="dot"></span>'+st+'</div>'+stTag+
'</div>'+
'<div class="info">'+
'<div class="name">'+name+'</div>'+
'<div class="ip">'+ip+'</div>'+
'<div class="model">'+esc(d.model||'')+'</div>'+
action+fmtProgress(d.progress)+
'<div class="hint">点击进入设备操作</div>'+
'</div>'+
'</div>';
}
// 增量渲染:已有卡片只更新信息区(不重建 img,缩略图不闪不重载);增删设备才动 DOM
function renderGrid(devs){
const grid=document.getElementById('wall-grid');
if(!devs.length){
grid.innerHTML='<div class="empty-wall">设备池为空,请到「工具 → 设备池管理」添加设备</div>';
return;
}
const existing={};
grid.querySelectorAll('.card').forEach(c=>{existing[c.dataset.serial]=c;});
// 1. 移除已不存在的卡片
Object.keys(existing).forEach(s=>{
if(!devs.find(d=>d.serial===s))existing[s].remove();
});
// 2. 更新/新建
devs.forEach((d,i)=>{
const serial=d.serial;
const card=existing[serial];
if(card){
// 已有卡片:只更新状态类信息(badge/class/信息区),不动 img
card.className='card '+cardCls(d);
const badge=card.querySelector('.badge');
const bt=badgeText(d);
if(badge&&badge.textContent!==bt)badge.innerHTML='<span class="dot"></span>'+bt;
// 熄屏角标
const tag=card.querySelector('.screen-tag');
if(_screenState[serial]==='off'&&!tag)card.querySelector('.thumb').insertAdjacentHTML('beforeend','<div class="screen-tag off">🌙</div>');
else if(_screenState[serial]!=='off'&&tag)tag.remove();
// 信息区
card.querySelector('.name').textContent=d.device_name||d.model||'设备';
card.querySelector('.ip').textContent=shortIp(serial);
card.querySelector('.model').textContent=d.model||'';
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>':'');
card.querySelector('.action').innerHTML=action;
// 进度
const progWrap=card.querySelector('.prog-wrap');
if(progWrap)progWrap.innerHTML=fmtProgress(d.progress);
else if(d.progress&&d.progress.total)card.querySelector('.info').insertAdjacentHTML('beforeend','<div class="prog-wrap">'+fmtProgress(d.progress)+'</div>');
// 离线占位切换
const img=card.querySelector('img[data-thumb]');
const ph=card.querySelector('.ph');
if(d.present){if(img)img.style.display='block';if(ph)ph.style.display='none';}
else{if(img)img.style.display='none';if(ph)ph.style.display='flex';}
}else{
grid.insertAdjacentHTML('beforeend',cardHtml(d,i));
const nc=grid.lastElementChild;
if(!d.present)nc.querySelector('.ph').style.display='flex';
}
});
refreshThumbs();
}
// 缩略图轮询(2.5s):fetch 读取 X-Screen-State 头 → blob 喂 img
async function refreshThumbs(){
// 进入设备操作弹窗后:停止全部缩略图获取,资源全部给当前设备的高清流
if(_ctrlSerial)return;
if(document.visibilityState!=='visible')return;
const now=Date.now();
const imgs=[...document.querySelectorAll('img[data-thumb]')];
await Promise.all(imgs.map(async img=>{
const serial=img.dataset.thumb;
try{
const r=await fetch('/api/screen/thumb?serial='+encodeURIComponent(serial)+'&t='+now);
if(!r.ok)return;
_screenState[serial]=r.headers.get('X-Screen-State')||'unknown';
const blob=await r.blob();
if(blob.size>0)img.src=URL.createObjectURL(blob);
// 状态角标变化时更新(熄屏中/亮屏中)
const card=img.closest('.card');
if(card){
const d=_devices.find(x=>x.serial===serial);
if(d&&d.present&&!(['running','connecting','error','failed'].includes(d.worker_status))){
const badge=card.querySelector('.badge');
const txt=badgeText(d);
if(badge&&badge.textContent!==txt)badge.innerHTML='<span class="dot"></span>'+txt;
const tag=card.querySelector('.screen-tag');
if(_screenState[serial]==='off'&&!tag)card.querySelector('.thumb').insertAdjacentHTML('beforeend','<div class="screen-tag off">🌙</div>');
else if(_screenState[serial]!=='off'&&tag)tag.remove();
}
}
}catch(e){}
}));
}
// ================== 一键亮屏 ==================
async function wakeAll(){
const btn=document.getElementById('btn-wake-all');
btn.disabled=true;btn.textContent='亮屏中...';
const r=await apiPost('/api/device/screen_all',{mode:'on'});
btn.disabled=false;btn.textContent='💡 一键亮屏';
if(r&&r.ok){refreshStatus();setTimeout(refreshThumbs,800);}
}
// ================== 设备操作弹窗(看屏 + 触控) ==================
function openCtrl(serial){
const d=_devices.find(x=>x.serial===serial);
if(!d)return;
_ctrlSerial=serial;
document.getElementById('ctrl-name').textContent=d.device_name||d.model||shortIp(serial);
document.getElementById('ctrl-sub').textContent=serial;
document.getElementById('ctrl-overlay').classList.add('show');
const img=document.getElementById('ctrl-img');
img.src='';
document.getElementById('ctrl-loading').style.display='flex';
img.onload=()=>{document.getElementById('ctrl-loading').style.display='none';};
img.onerror=()=>{document.getElementById('ctrl-loading').style.display='none';};
startCtrlStream();
}
function closeCtrl(){
_ctrlSerial='';
document.getElementById('ctrl-overlay').classList.remove('show');
document.getElementById('ctrl-img').src='';
}
function startCtrlStream(){
if(!_ctrlSerial)return;
const img=document.getElementById('ctrl-img');
img.src='/api/screen/stream?serial='+encodeURIComponent(_ctrlSerial)+'&q=85&fps=10&t='+Date.now(); // 弹窗高清高帧
}
function updateCtrlHead(){
const d=_devices.find(x=>x.serial===_ctrlSerial);
if(!d)return;
document.getElementById('ctrl-name').textContent=d.device_name||d.model||shortIp(_ctrlSerial);
document.getElementById('ctrl-sub').textContent=_ctrlSerial+(d.present?'':'(离线)');
}
// 坐标映射:显示尺寸 -> 设备原生分辨率
// 图片 object-fit:contain 时实际绘制区域小于盒子(居中留黑边),
// 必须按绘制区域换算,否则黑边/偏移导致点击错位
function ctrlCoords(ev){
const img=document.getElementById('ctrl-img');
if(!img.naturalWidth||!img.naturalHeight)return[null,null];
const r=img.getBoundingClientRect();
const scale=Math.min(r.width/img.naturalWidth,r.height/img.naturalHeight);
const drawW=img.naturalWidth*scale, drawH=img.naturalHeight*scale;
const ox=(r.width-drawW)/2, oy=(r.height-drawH)/2; // contain 偏移
const px=ev.clientX-r.left, py=ev.clientY-r.top;
if(px<ox||px>ox+drawW||py<oy||py>oy+drawH)return[null,null]; // 黑边点击忽略
return[Math.round((px-ox)/scale),Math.round((py-oy)/scale)];
}
function ctrlTap(x,y){
if(x==null||!x)return;
apiPost('/api/screen/tap',{serial:_ctrlSerial,x,y});
}
function ctrlSwipe(x1,y1,x2,y2){
if(x1==null)return;
apiPost('/api/screen/swipe',{serial:_ctrlSerial,x1,y1,x2,y2});
}
function ctrlKey(key){
if(!_ctrlSerial)return;
apiPost('/api/screen/key',{serial:_ctrlSerial,key});
}
function ctrlWake(){if(_ctrlSerial)apiPost('/api/device/screen_all',{mode:'on',serials:[_ctrlSerial]});}
function ctrlSleep(){if(_ctrlSerial)apiPost('/api/device/screen_all',{mode:'off',serials:[_ctrlSerial]});}
function ctrlSendText(){
const inp=document.getElementById('ctrl-text');
const text=(inp.value||'').trim();
if(!_ctrlSerial||!text)return;
apiPost('/api/screen/text',{serial:_ctrlSerial,text}).then(r=>{if(r&&r.ok)inp.value='';});
}
// 鼠标/触控:拖拽=滑动,点按=轻触
(function(){
const screen=document.getElementById('ctrl-screen');
let dragging=false,sx=0,sy=0;
screen.addEventListener('mousedown',ev=>{
if(!_ctrlSerial)return;
ev.preventDefault();dragging=true;
const[x,y]=ctrlCoords(ev);sx=x;sy=y;
});
screen.addEventListener('mousemove',ev=>{if(dragging)ev.preventDefault();});
window.addEventListener('mouseup',ev=>{
if(!dragging)return;dragging=false;
const[x,y]=ctrlCoords(ev);
if(x==null)return;
if(Math.abs(x-sx)<8&&Math.abs(y-sy)<8)ctrlTap(x,y);
else ctrlSwipe(sx,sy,x,y);
});
screen.addEventListener('touchstart',ev=>{
if(!_ctrlSerial)return;
ev.preventDefault();const t=ev.touches[0];dragging=true;
const[x,y]=ctrlCoords(t);sx=x;sy=y;
},{passive:false});
screen.addEventListener('touchend',ev=>{
if(!dragging)return;dragging=false;
const t=ev.changedTouches[0];
const[x,y]=ctrlCoords(t);
if(x==null)return;
if(Math.abs(x-sx)<8&&Math.abs(y-sy)<8)ctrlTap(x,y);
else ctrlSwipe(sx,sy,x,y);
},{passive:false});
})();
// 定时器
setInterval(()=>{if(document.visibilityState==='visible')refreshThumbs();},5000); // 网格低帧率(省资源)
setInterval(()=>{if(document.visibilityState==='visible')refreshStatus();},5000);
// 初始化
initCsrf();tickClock();setInterval(tickClock,1000);
refreshStatus();
document.addEventListener('visibilitychange',()=>{
if(document.visibilityState==='visible'){refreshStatus();refreshThumbs();}
});
</script>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
"""Web 层蓝图包:web_server.py 只保留 app 初始化与装配,路由按功能域拆分。
模块划分(按功能定位问题):
auth — 登录/登出/CSRF/权限装饰器/页面路由(/、/wall)
monitor — 状态/运行控制/设备操作/远程看屏
tasks — 任务计划/分组/自定义动作/元素抓取/步骤测试
admin — 用户管理/日志
tools — adb 终端/剪贴板注入/应用版本
devices — 设备池管理
apks — 应用管理
tailscale — Tailscale 管理
"""
from flask import Blueprint
def register_blueprints(app):
"""注册全部蓝图(url_prefix 为空,路由路径保持原样)。"""
from .auth import bp as auth_bp
from .monitor import bp as monitor_bp
from .tasks_api import bp as tasks_bp
from .admin_api import bp as admin_bp
from .tools_api import bp as tools_bp
from .devices_api import bp as devices_bp
from .apks_api import bp as apks_bp
from .tailscale_api import bp as tailscale_bp
from . import agent_api as _agent_mod
from .agent_api import bp as agent_bp
_agent_mod.set_app(app)
for bp in (auth_bp, monitor_bp, tasks_bp, admin_bp, tools_bp,
devices_bp, apks_bp, tailscale_bp, agent_bp):
app.register_blueprint(bp)
+101
View File
@@ -0,0 +1,101 @@
"""管理域 API:用户管理/日志查看。"""
import os
import time
from flask import Blueprint, jsonify, request
from flask_login import current_user
from flask import session
from core.logger import _LOG_DIR, _MODULE_FILES
from core.models import db, User
from web import context
from web.auth import (admin_required, perm_required, _validate_perms,
PERM_LOGS)
bp = Blueprint("admin", __name__)
@bp.route("/api/users")
@admin_required
def api_users_list():
users = [{"id": u.id, "username": u.username,
"is_admin": u.is_admin, "perms": u.get_perms()} for u in User.query.all()]
return jsonify({"ok": True, "users": users})
@bp.route("/api/users", methods=["POST"])
@admin_required
def api_users_create():
data = request.json or {}
username = (data.get("username") or "").strip()
password = data.get("password", "")
if not username or not password:
return jsonify({"ok": False, "error": "用户名和密码不能为空"}), 400
if User.query.filter_by(username=username).first():
return jsonify({"ok": False, "error": "用户名已存在"}), 400
# 默认普通用户(最小权限);管理员由 is_admin 决定,权限位照常保存(降级后生效)
u = User(username=username, is_admin=bool(data.get("is_admin", False)))
u.set_perms(_validate_perms(data.get("perms") or []))
u.set_password(password)
db.session.add(u)
db.session.commit()
_log.info(f"创建用户 {username} (admin={u.is_admin}, perms={u.get_perms()})")
return jsonify({"ok": True, "msg": "用户已创建"})
@bp.route("/api/users/<int:uid>", methods=["PUT"])
@admin_required
def api_users_update(uid):
u = User.query.get(uid)
if not u:
return jsonify({"ok": False, "error": "用户不存在"}), 404
data = request.json or {}
if "password" in data and data["password"]:
u.set_password(data["password"])
if "is_admin" in data:
new_admin = bool(data["is_admin"])
if u.is_admin and not new_admin and User.query.filter_by(is_admin=True).count() <= 1:
return jsonify({"ok": False, "error": "不能取消最后一个管理员"}), 400
u.is_admin = new_admin
if "perms" in data:
u.set_perms(_validate_perms(data["perms"]))
db.session.commit()
_log.info(f"更新用户 {u.username} (admin={u.is_admin}, perms={u.get_perms()})")
return jsonify({"ok": True, "msg": "用户已更新"})
@bp.route("/api/users/<int:uid>", methods=["DELETE"])
@admin_required
def api_users_delete(uid):
u = User.query.get(uid)
if not u:
return jsonify({"ok": False, "error": "用户不存在"}), 404
if u.username == "admin":
return jsonify({"ok": False, "error": "不能删除默认管理员"}), 400
if u.id == current_user.id:
return jsonify({"ok": False, "error": "不能删除当前登录用户"}), 400
if u.is_admin and User.query.filter_by(is_admin=True).count() <= 1:
return jsonify({"ok": False, "error": "不能删除最后一个管理员"}), 400
db.session.delete(u)
db.session.commit()
_log.info(f"删除用户 {u.username}")
return jsonify({"ok": True, "msg": "用户已删除"})
# ================== API:日志查看 ==================
@bp.route("/api/logs")
@perm_required(PERM_LOGS)
def api_logs():
files = list(_MODULE_FILES.values())
current = request.args.get("file", "core.log")
lines = int(request.args.get("lines", 300))
content = ""
path = os.path.join(_LOG_DIR, current)
if os.path.exists(path):
try:
with open(path, encoding="utf-8") as f:
content = "".join(f.readlines()[-lines:])
except Exception as e:
content = f"读取失败: {e}"
return jsonify({"ok": True, "content": content, "file": current, "files": files})
# ================== API:运行控制 ==================
+943
View File
@@ -0,0 +1,943 @@
"""AI 控制台 API:模型/Key 前端配置 + Agent 流式执行(SSE 事件流)。
流程:
POST /api/agent/run {prompt} → 启动 Agent 线程,返回 run_id
GET /api/agent/stream?run_id= → SSE 事件流(EventSource 订阅):
event: delta {text, kind: content|reasoning} 流式文本增量
event: step {tool, args, image?} 工具调用完成(MCP 步骤)
event: done {answer} 完成
event: error {message} 失败
GET/POST /api/agent/config → 配置读写(key 打码回显)
单实例:同时只允许一个 Agent 运行。
"""
import asyncio
import base64
import io
import json
import os
import queue
import re
import sys
import threading
import uuid
from flask import Blueprint, Response, jsonify, request
from core.logger import get_logger
from core.models import db
from web.auth import admin_required
_log = get_logger("web.agent")
bp = Blueprint("agent", __name__)
# ---------- 配置键(app_meta) ----------
_CFG_KEYS = {"api_base": "agent_api_base",
"model": "agent_model",
"api_key": "agent_api_key",
"default_serial": "agent_default_serial",
"max_steps": "agent_max_steps"}
# ---------- 运行状态(单实例 + 事件队列) ----------
_run = {"id": None, "state": "idle", "prompt": "", "serial": "",
"answer": "", "error": "",
"history": []} # 多轮对话历史 [{role: user|assistant, content}]
_queues = {} # run_id -> queue.Queue(SSE 消费者读取)
_stop_events = {} # run_id -> threading.Event(用户中断)
_lock = threading.Lock()
_flask_app = None # web_server 注册时注入(后台线程 db 操作需 app context)
def set_app(app):
global _flask_app
_flask_app = app
def _meta_get(key):
return db.session.execute(
db.text("SELECT value FROM app_meta WHERE key=:k"), {"k": key}).scalar() or ""
def _meta_put(key, value):
db.session.execute(
db.text("INSERT OR REPLACE INTO app_meta(key,value) VALUES(:k,:v)"),
{"k": key, "v": str(value)})
def _read_cfg():
return {k: _meta_get(v) for k, v in _CFG_KEYS.items()}
# ================== 经验记忆(自进化) ==================
# agent_experience:任务成功后的操作配方,下次相似任务检索注入 system prompt。
# 原始 SQLite(CREATE IF NOT EXISTS 幂等),不进模型层迁移。
_EXP_TABLE = """
CREATE TABLE IF NOT EXISTS agent_experience (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_prompt TEXT DEFAULT '',
recipe TEXT DEFAULT '',
tool_seq TEXT DEFAULT '',
hits INTEGER DEFAULT 0,
created_at VARCHAR(20) DEFAULT '')"""
# 经验巡检(AI 质检):每日定时把经验交给模型评审,疑似问题标 pending 待人工确认。
# 删除只允许人工(前端 confirm / 本 API),巡检绝不自动删。
_AUDIT_TABLE = """
CREATE TABLE IF NOT EXISTS experience_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
exp_id INTEGER NOT NULL,
verdict TEXT DEFAULT '', -- keep 保留 / delete 建议删除
score REAL DEFAULT 0, -- 0-10 可用性评分
reason TEXT DEFAULT '', -- 模型给出的理由
hits INTEGER DEFAULT 0, -- 巡检时的引用次数(hits>0 被删要更谨慎)
action TEXT DEFAULT 'pending', -- pending 待人工确认 / kept 已保留 / deleted 已删除
audited_at VARCHAR(20) DEFAULT '')"""
# 巡检评审提示词:模型只判「保留/建议删除」+ 评分 + 理由,不直接删。
_AUDIT_PROMPT = """你是「手机自动化经验库」质检员。经验库存放 AI 成功操作手机后提炼的
操作套路,下次相似任务会自动注入参考。请判断下面这条经验是否值得继续保留。
判断标准(全部满足才保留):
1. 具体可执行:步骤是明确的工具操作(de_* 或清晰的中文步骤),不是「de_tap / de_input」
这类空泛占位,也没有编造不存在的工具名
2. 不依赖易过时信息:不含写死的像素坐标(x,y 数字)这类界面一变就失效的步骤
3. 是独立任务的操作套路(如「打开X搜索Y并点赞」),不是对话追问/抱怨/单次性内容
4. 配方与下方实际操作序列大致一致,不是模型自由发挥编造
经验内容:
任务描述:{prompt}
操作配方:{recipe}
实际操作序列:{tool_seq}
被引用次数:{hits}
只输出 JSON:{{"keep": true或false, "score": 0到10的整数, "reason": "一句话中文理由"}}"""
def _ensure_exp_table():
try:
db.session.execute(db.text(_EXP_TABLE))
db.session.commit()
except Exception:
pass
def _bigrams(text):
"""中文/英文文本 bigram 集合(无空格分词,粗粒度相似度)。"""
t = "".join(c for c in (text or "").lower() if c.isalnum() or "\u4e00" <= c <= "\u9fff")
return {t[i:i + 2] for i in range(len(t) - 1)}
# ---------- 经验质量门槛 ----------
# 自进化经验只应保存「独立操作任务」的成功套路。多轮对话里用户的短句
# (质疑/纠正/催促,如「继续啊」「你确定我是卡1吗」「还没有完成啊」)
# 不是新任务——把执行出错被纠正的轮次存成经验会教坏后续任务。
# 用启发式过滤(零成本,可解释),配方层再校验工具名真实性。
# 任务性动词:命中任一视为有明确操作诉求(疑问/纠错短句一般不含它们)
_TASK_VERBS = ("打开", "搜索", "查看", "找到", "截图", "输入", "点击", "点开",
"发送", "安装", "卸载", "下载", "启动", "停止", "关闭", "退出",
"登录", "切换", "设置", "删除", "清理", "复制", "粘贴", "读取",
"剪贴板", "长按", "滑动", "播放", "发布", "检查", "看看",
"帮我", "给我", "请", "拍张", "查一下")
# 对话续语开头:几乎只出现在承接上一轮(「继续啊」「还有吗」)
_CONTINUE_PREFIXES = ("继续", "还有", "然后呢", "再来", "快点", "刚才",
"接着", "上一步")
# 强质疑/纠错信号(不含「为什么」——「查一下为什么」是正当任务)
_DOUBT_MARKS = ("你确定", "是不是", "不是吧", "不是吗", "怎么都", "怎么还",
"还没有", "没看到", "我说的是", "你听我说", "不对吧", "你又",
"重新来", "错了", "你说得", "你回答")
# 全部真实 MCP 工具(配方里出现不存在的 de_* 说明蒸馏模型在编造,弃存)
_KNOWN_TOOLS = frozenset({
"de_list_devices", "de_screenshot", "de_tap", "de_swipe", "de_ui_tree",
"de_tap_element", "de_read_clipboard", "de_wake", "de_press_key",
"de_open_app", "de_stop_app", "de_foreground_app", "de_type_text",
"de_set_clipboard", "de_sleep", "de_ocr", "de_tap_text", "de_list_apps",
"de_list_tasks"})
def _qualify_experience(prompt, recipe):
"""经验入库前质量门槛,返回 True=值得保存。
1) prompt 太短 / 纯续语开头 / 质疑纠错 → 非独立任务,弃
2) 疑问短句(≤30 字、以 吗/? 结尾)且无任务动词 → 追问/反问,弃
3) 配方含不存在的 de_* 工具(蒸馏模型自由发挥)→ 弃
"""
t = "".join(c for c in (prompt or "") if not c.isspace())
if len(t) < 6:
_log.info("经验弃存:prompt 过短「%s」", t[:20])
return False
if any(t.startswith(p) for p in _CONTINUE_PREFIXES):
_log.info("经验弃存:对话续语开头「%s」", t[:20])
return False
if any(m in t for m in _DOUBT_MARKS):
_log.info("经验弃存:质疑/纠错语气「%s」", t[:20])
return False
if (t.endswith("吗") or t.endswith("?") or t.endswith("?")) \
and len(t) <= 30 and not any(v in t for v in _TASK_VERBS):
_log.info("经验弃存:无操作诉求的追问「%s」", t[:20])
return False
for name in re.findall(r"de_[a-z_]+", recipe or ""):
if name not in _KNOWN_TOOLS:
_log.info("经验弃存:配方含不存在的工具 %s", name)
return False
return True
def _find_experiences(prompt, limit=2, threshold=0.10):
"""按 bigram 重叠检索相似历史经验(prompt 与任务描述的字符相似度)。
命中的经验 hits+1(回写),让被反复参考的有效经验浮到前面。
"""
try:
if _flask_app is None:
return ""
with _flask_app.app_context():
_ensure_exp_table()
rows = db.session.execute(db.text(
"SELECT id, task_prompt, recipe, hits FROM agent_experience "
"WHERE recipe != '' ORDER BY hits DESC, id DESC LIMIT 50")).fetchall()
except Exception:
return ""
if not rows:
return ""
cur = _bigrams(prompt)
if not cur:
return ""
scored = []
for row_id, task_prompt, recipe, hits in rows:
sim = len(cur & _bigrams(task_prompt)) / len(cur)
if sim >= threshold:
scored.append((sim, hits or 0, recipe, row_id))
scored.sort(key=lambda x: (-x[0], -x[1]))
if scored:
# hits 回写(尽力而为,失败不影响检索)
try:
with _flask_app.app_context():
for _sim, _hits, _recipe, row_id in scored:
db.session.execute(db.text(
"UPDATE agent_experience SET hits=hits+1 WHERE id=:i"),
{"i": row_id})
db.session.commit()
except Exception:
pass
parts = []
for sim, _hits, recipe, _row_id in scored[:limit]:
parts.append(f"- {recipe[:600]}")
return "\n".join(parts)
def _distill_experience(cfg, prompt, tool_seq):
"""任务完成后用模型把操作序列提炼为可复用配方(失败静默,不阻塞)。"""
try:
import httpx
body = {
"model": cfg.get("model") or "deepseek-v4-flash-vision-exp",
"messages": [{"role": "user",
"content": "以下是一次成功的手机自动化操作记录。请提炼成简洁的"
"「操作配方」(2-6 步,每步:目标 → 用哪个工具),"
"供下次同类任务参考。不要解释,直接输出配方。\n"
f"任务:{prompt[:300]}\n操作序列:{tool_seq[:800]}"}],
"max_tokens": 600,
}
headers = {"Authorization": f"Bearer {cfg.get('api_key', '')}",
"Content-Type": "application/json"}
r = httpx.post(f"{(cfg.get('api_base') or 'https://api.deepseek.com').rstrip('/')}/chat/completions",
json=body, headers=headers, timeout=25)
if r.status_code != 200:
return ""
j = r.json()
recipe = ((j.get("choices") or [{}])[0].get("message") or {}).get("content") or ""
return recipe.strip()[:1500]
except Exception as e:
_log.warning(f"经验提炼失败: {e}")
return ""
def _save_experience(prompt, recipe, tool_seq):
"""保存经验(后台线程调用,包 app context)。返回是否保存成功。"""
try:
if _flask_app is None:
return False
with _flask_app.app_context():
_ensure_exp_table()
from datetime import datetime
db.session.execute(db.text(
"INSERT INTO agent_experience(task_prompt, recipe, tool_seq, hits, created_at) "
"VALUES(:p, :r, :t, 0, :c)"),
{"p": prompt[:500], "r": recipe, "t": tool_seq[:1000],
"c": datetime.now().strftime("%Y-%m-%d %H:%M")})
db.session.commit()
_log.info("经验已保存(配方 %d 字符)", len(recipe))
return True
except Exception as e:
_log.warning(f"经验保存失败: {e}")
return False
# ================== 经验巡检(AI 质检,删除需人工确认) ==================
_audit_state = {"running": False, "last": "", "last_summary": ""}
def _ensure_audit_table():
try:
db.session.execute(db.text(_AUDIT_TABLE))
db.session.commit()
except Exception:
pass
def _review_one_exp(cfg, prompt, recipe, tool_seq, hits):
"""调模型评审单条经验,返回 (verdict, score, reason)。
模型偶尔输出解释文字不带 JSON——重试一次(强调只输出 JSON),
仍失败则保守返回保留。
"""
import httpx
base_prompt = _AUDIT_PROMPT.format(prompt=(prompt or "")[:400],
recipe=(recipe or "")[:600],
tool_seq=(tool_seq or "")[:600],
hits=hits or 0)
for attempt in (1, 2):
try:
content = base_prompt if attempt == 1 else (
base_prompt + "\n注意:只输出一个 JSON 对象,不要输出任何其它文字、解释或代码块标记。")
body = {
"model": cfg.get("model") or "deepseek-v4-flash-vision-exp",
"messages": [{"role": "user", "content": content}],
"max_tokens": 200,
}
headers = {"Authorization": f"Bearer {cfg.get('api_key', '')}",
"Content-Type": "application/json"}
r = httpx.post(f"{(cfg.get('api_base') or 'https://api.deepseek.com').rstrip('/')}/chat/completions",
json=body, headers=headers, timeout=20)
if r.status_code != 200:
raise RuntimeError(f"HTTP {r.status_code}")
text = (((r.json() or {}).get("choices") or [{}])[0]
.get("message") or {}).get("content") or ""
m = re.search(r"\{.*\}", text, re.S)
if not m:
raise RuntimeError("响应无 JSON")
j = json.loads(m.group(0))
keep = bool(j.get("keep", True))
score = max(0, min(10, int(j.get("score", 5) or 5)))
reason = str(j.get("reason", ""))[:200]
return ("keep" if keep else "delete"), score, reason
except Exception as e:
_log.warning(f"经验评审第 {attempt} 次失败: {e}")
return "keep", 0, "评审失败自动保留(两次尝试均无有效 JSON)"
def run_experience_audit():
"""巡检一轮:逐条评审经验 → 写 experience_audit。
规则:
- 最新已 kept / deleted 的经验不再重复评审(人工拍板过的尊重)
- 评审 keep → 直接 action=kept;delete → action=pending(等人工确认)
- 绝不自动删除;无 api_key 配置时跳过并记日志
由 web_server 每日定时调度或 API 手动触发(后台线程)。
"""
if _audit_state["running"]:
_log.info("经验巡检已在运行,跳过本次触发")
return
_audit_state["running"] = True
try:
if _flask_app is None:
return
with _flask_app.app_context():
_ensure_exp_table()
_ensure_audit_table()
cfg = _read_cfg()
if not cfg.get("api_key"):
_log.info("经验巡检跳过:未配置 API Key")
return
rows = db.session.execute(db.text(
"SELECT id, task_prompt, recipe, tool_seq, hits FROM agent_experience "
"ORDER BY id")).fetchall()
if not rows:
_log.info("经验巡检:经验库为空")
return
from datetime import datetime
now = datetime.now().strftime("%Y-%m-%d %H:%M")
reviewed = suggested = 0
for exp_id, prompt, recipe, tool_seq, hits in rows:
# 最新一次人工/评审结论:kept / deleted 的不再打扰
last = db.session.execute(db.text(
"SELECT action FROM experience_audit WHERE exp_id=:e "
"ORDER BY id DESC LIMIT 1"), {"e": exp_id}).scalar()
if last in ("kept", "deleted"):
continue
verdict, score, reason = _review_one_exp(
cfg, prompt, recipe, tool_seq, hits)
action = "kept" if verdict == "keep" else "pending"
db.session.execute(db.text(
"INSERT INTO experience_audit"
"(exp_id, verdict, score, reason, hits, action, audited_at) "
"VALUES(:e, :v, :s, :r, :h, :a, :t)"),
{"e": exp_id, "v": verdict, "s": score, "r": reason,
"h": hits or 0, "a": action, "t": now})
reviewed += 1
suggested += 1 if action == "pending" else 0
db.session.commit()
summary = f"评审 {reviewed} 条,建议删除 {suggested} 条(待人工确认)"
_audit_state.update(last=now, last_summary=summary)
_log.info("经验巡检完成: %s", summary)
except Exception as e:
_log.warning(f"经验巡检异常: {e}")
finally:
_audit_state["running"] = False
# ================== 经验库管理 API ==================
@bp.route("/api/agent/experience")
@admin_required
def agent_experience_list():
"""经验库列表(含最近一次巡检结论),供 AI 控制台「🧠 经验库」面板管理。"""
_ensure_exp_table()
_ensure_audit_table()
rows = db.session.execute(db.text(
"SELECT e.id, e.task_prompt, e.recipe, e.tool_seq, e.hits, e.created_at, "
"a.verdict, a.score, a.reason, a.action, a.audited_at "
"FROM agent_experience e LEFT JOIN experience_audit a ON a.id = "
"(SELECT id FROM experience_audit WHERE exp_id = e.id ORDER BY id DESC LIMIT 1) "
"ORDER BY e.id DESC")).fetchall()
exps = []
for r in rows:
(eid, task_prompt, recipe, tool_seq, hits, created_at,
verdict, score, reason, action, audited_at) = r
audit = None
if action:
audit = {"verdict": verdict, "score": round(score or 0, 1),
"reason": reason, "action": action, "at": audited_at}
exps.append({"id": eid, "task_prompt": task_prompt or "",
"recipe": recipe or "", "tool_seq": tool_seq or "",
"hits": hits or 0, "created_at": created_at or "",
"audit": audit})
return jsonify({"ok": True, "running": _audit_state["running"],
"last": _audit_state["last"],
"last_summary": _audit_state["last_summary"],
"experiences": exps})
@bp.route("/api/agent/experience/delete", methods=["POST"])
@admin_required
def agent_experience_delete():
"""人工确认删除经验(真删;前端有二次确认,巡检绝不自动调它)。"""
data = request.json or {}
exp_id = int(data.get("id") or 0)
if exp_id <= 0:
return jsonify({"ok": False, "error": "缺少 id"}), 400
db.session.execute(db.text(
"DELETE FROM agent_experience WHERE id=:i"), {"i": exp_id})
db.session.execute(db.text(
"UPDATE experience_audit SET action='deleted' "
"WHERE exp_id=:i AND action='pending'"), {"i": exp_id})
db.session.commit()
_log.info("人工确认删除经验 #%d", exp_id)
return jsonify({"ok": True, "msg": f"经验 #{exp_id} 已删除"})
@bp.route("/api/agent/experience/audit", methods=["POST"])
@admin_required
def agent_experience_audit_now():
"""手动触发一轮经验巡检(后台线程;进行中返回 409)。"""
if _audit_state["running"]:
return jsonify({"ok": False, "error": "巡检正在进行中"}), 409
threading.Thread(target=run_experience_audit, daemon=True).start()
return jsonify({"ok": True, "msg": "巡检已启动,完成后刷新列表查看建议"})
@bp.route("/api/agent/experience/keep", methods=["POST"])
@admin_required
def agent_experience_keep():
"""人工保留:撤销建议删除(该条后续巡检不再重复建议)。"""
data = request.json or {}
exp_id = int(data.get("id") or 0)
if exp_id <= 0:
return jsonify({"ok": False, "error": "缺少 id"}), 400
db.session.execute(db.text(
"UPDATE experience_audit SET action='kept' "
"WHERE exp_id=:i AND action='pending'"), {"i": exp_id})
db.session.commit()
return jsonify({"ok": True, "msg": f"经验 #{exp_id} 已保留"})
# ================== 历史会话(DeepSeek 式:多会话持久化) ==================
# 会话 = 消息序列(JSON),一轮 run 的 user/assistant 文本完成后追加落库。
# 单表 JSON 存储(每会话几十 KB 内,无需独立消息表)。
_CONV_TABLE = """
CREATE TABLE IF NOT EXISTS agent_conversation (
id VARCHAR(20) PRIMARY KEY,
title VARCHAR(100) DEFAULT '',
messages TEXT DEFAULT '[]',
created_at VARCHAR(20) DEFAULT '',
updated_at VARCHAR(20) DEFAULT '')"""
def _ensure_conv_table():
try:
db.session.execute(db.text(_CONV_TABLE))
db.session.commit()
except Exception:
pass
def _conv_msgs(conv_id):
"""读会话消息列表 [{role, content}]。会话不存在返回 None。"""
try:
row = db.session.execute(db.text(
"SELECT messages FROM agent_conversation WHERE id=:i"),
{"i": conv_id}).fetchone()
except Exception:
return None
if not row:
return None
try:
return json.loads(row[0] or "[]")
except Exception:
return []
def _conv_save_messages(conv_id, messages, title=None):
"""整段写回会话消息 + 时间戳(尽力而为,失败不影响主流程)。"""
try:
from datetime import datetime
now = datetime.now().strftime("%Y-%m-%d %H:%M")
msgs_json = json.dumps(messages[-60:], ensure_ascii=False)
if title:
db.session.execute(db.text(
"UPDATE agent_conversation SET messages=:m, title=:t, "
"updated_at=:u WHERE id=:i"),
{"m": msgs_json, "t": title[:100], "u": now, "i": conv_id})
else:
db.session.execute(db.text(
"UPDATE agent_conversation SET messages=:m, updated_at=:u "
"WHERE id=:i"),
{"m": msgs_json, "u": now, "i": conv_id})
db.session.commit()
return True
except Exception as e:
_log.warning(f"会话保存失败: {e}")
return False
@bp.route("/api/agent/conversations", methods=["GET"])
@admin_required
def agent_conversations_list():
"""会话列表(按最近更新倒序):{id, title, updated_at, count}。"""
_ensure_conv_table()
rows = db.session.execute(db.text(
"SELECT id, title, messages, updated_at FROM agent_conversation "
"ORDER BY updated_at DESC, created_at DESC")).fetchall()
out = []
for cid, title, messages, updated_at in rows:
try:
count = len(json.loads(messages or "[]")) // 2
except Exception:
count = 0
out.append({"id": cid, "title": title or "新会话",
"updated_at": updated_at or "", "count": count})
return jsonify({"ok": True, "conversations": out})
@bp.route("/api/agent/conversations", methods=["POST"])
@admin_required
def agent_conversations_create():
"""新建会话(空消息)。返回 {id}。"""
_ensure_conv_table()
from datetime import datetime
cid = uuid.uuid4().hex[:10]
now = datetime.now().strftime("%Y-%m-%d %H:%M")
db.session.execute(db.text(
"INSERT INTO agent_conversation(id, title, messages, created_at, updated_at) "
"VALUES(:i, '', '[]', :c, :c)"), {"i": cid, "c": now})
db.session.commit()
return jsonify({"ok": True, "id": cid, "title": "新会话"})
@bp.route("/api/agent/conversations/<conv_id>", methods=["GET"])
@admin_required
def agent_conversations_detail(conv_id):
"""会话详情(全部消息文本)。"""
_ensure_conv_table()
msgs = _conv_msgs(conv_id)
if msgs is None:
return jsonify({"ok": False, "error": "会话不存在"}), 404
row = db.session.execute(db.text(
"SELECT title, created_at, updated_at FROM agent_conversation "
"WHERE id=:i"), {"i": conv_id}).fetchone()
return jsonify({"ok": True, "id": conv_id,
"title": (row[0] if row else "") or "新会话",
"messages": msgs,
"created_at": row[1] if row else "",
"updated_at": row[2] if row else ""})
@bp.route("/api/agent/conversations/<conv_id>", methods=["DELETE"])
@admin_required
def agent_conversations_delete(conv_id):
"""删除会话(消息一并删除,不可恢复)。"""
_ensure_conv_table()
db.session.execute(db.text(
"DELETE FROM agent_conversation WHERE id=:i"), {"i": conv_id})
db.session.commit()
_log.info(f"删除会话 {conv_id}")
return jsonify({"ok": True, "msg": "会话已删除"})
@bp.route("/api/agent/conversations/<conv_id>/rename", methods=["POST"])
@admin_required
def agent_conversations_rename(conv_id):
"""重命名会话。"""
data = request.json or {}
title = (data.get("title") or "").strip()[:100]
if not title:
return jsonify({"ok": False, "error": "标题不能为空"}), 400
_ensure_conv_table()
db.session.execute(db.text(
"UPDATE agent_conversation SET title=:t WHERE id=:i"),
{"t": title, "i": conv_id})
db.session.commit()
return jsonify({"ok": True, "msg": "已重命名"})
# ================== 运行 ==================
@bp.route("/api/agent/run", methods=["POST"])
@admin_required
def agent_run():
"""启动 Agent:{prompt, serial?, conversation_id?}。运行中返回 409。"""
data = request.json or {}
prompt = (data.get("prompt") or "").strip()
if not prompt:
return jsonify({"ok": False, "error": "请输入指令"}), 400
serial = (data.get("serial") or "").strip()
conv_id = (data.get("conversation_id") or "").strip()
cfg = _read_cfg()
if not cfg.get("api_key"):
return jsonify({"ok": False, "error": "请先在配置区填写 API Key"}), 400
if not cfg.get("model"):
return jsonify({"ok": False, "error": "请先填写模型名"}), 400
if not serial and not (cfg.get("default_serial") or "").strip():
return jsonify({"ok": False, "error": "请先选择目标设备(AI 只操作你指定的设备)"}), 400
# 目标设备校验:必须在池、在线、且无任务运行(AI 不与任务抢设备;
# MCP 层另有 busy 锁兜底外部客户端)
if serial:
try:
from web import context
devs, err = context.mgr.get_status()
dev = next((x for x in (devs or []) if x.get("serial") == serial), None)
if err or dev is None:
return jsonify({"ok": False, "error": f"设备 {serial} 不在设备池"}), 400
if not dev.get("present"):
return jsonify({"ok": False, "error": f"设备 {serial} 当前离线,请稍后再试"}), 400
if dev.get("worker_status") in ("running", "connecting"):
return jsonify({"ok": False, "error":
f"设备 {serial} 正在执行任务「{dev.get('task_job') or ''}」——"
f"AI 不与任务抢设备,任务结束后才能操作(或在任务页先停止)"}), 409
except Exception:
pass # 状态服务异常不阻塞(MCP busy 锁兜底)
# 会话校验:不存在则自动新建(标题=首条消息截断)
if conv_id:
_ensure_conv_table()
if _conv_msgs(conv_id) is None:
try:
from datetime import datetime as _dt2
now = _dt2.now().strftime("%Y-%m-%d %H:%M")
db.session.execute(db.text(
"INSERT OR IGNORE INTO agent_conversation(id, messages, "
"created_at, updated_at) VALUES(:i, '[]', :c, :c)"),
{"i": conv_id, "c": now})
db.session.commit()
except Exception:
pass
with _lock:
if _run["state"] == "running":
return jsonify({"ok": False, "error":
f"已有 Agent 运行中({_run.get('prompt', '')[:40]}…),"
f"请等待完成或先停止"}), 409
run_id = uuid.uuid4().hex[:8]
from datetime import datetime as _dt
_run.update(id=run_id, state="running", prompt=prompt,
serial=serial, conv_id=conv_id,
started=_dt.now().strftime("%H:%M:%S"),
answer="", error="")
# history 保留(多轮上下文),由会话/「新建会话」管理
_queues[run_id] = queue.Queue()
_stop_events[run_id] = threading.Event()
_log.info(f"Agent 启动: {prompt[:60]} @ {serial or 'default'} conv={conv_id or '-'}")
threading.Thread(target=_agent_thread,
args=(run_id, prompt, serial, cfg),
daemon=True).start()
return jsonify({"ok": True, "run_id": run_id})
@bp.route("/api/agent/config", methods=["GET"])
@admin_required
def agent_config_get():
"""读 Agent 配置(key 打码返回)。"""
cfg = _read_cfg()
if cfg["api_key"]:
k = cfg["api_key"]
cfg["api_key_masked"] = k[:6] + "***" + k[-4:]
return jsonify({"ok": True, **cfg})
@bp.route("/api/agent/config", methods=["POST"])
@admin_required
def agent_config_save():
"""保存 Agent 配置:{api_base?, model?, api_key?, default_serial?} 部分更新。"""
data = request.json or {}
for key, meta_key in _CFG_KEYS.items():
if key in data and data[key] is not None:
_meta_put(meta_key, str(data[key]).strip())
db.session.commit()
return jsonify({"ok": True, "msg": "已保存"})
@bp.route("/api/agent/run", methods=["GET"])
@admin_required
def agent_run_status():
"""当前 Agent 运行状态(多窗口/页面刷新恢复用):
idle/running/done + run_id + 最终答案 + 会话历史。
前端刷新后据此:渲染历史轮次、running 时重新订阅事件流(服务端队列
保留积压事件,重连后补发)、done 时展示最终答案。
"""
with _lock:
hist = [{"role": h.get("role"), "content": (h.get("content") or "")[:4000]}
for h in (_run.get("history") or [])]
return jsonify({"ok": True,
"state": _run.get("state", "idle"),
"run_id": _run.get("id") or "",
"prompt": _run.get("prompt", ""),
"serial": _run.get("serial", ""),
"started": _run.get("started", ""),
"answer": (_run.get("answer") or "")[:4000],
"error": (_run.get("error") or "")[:400],
"history": hist[-16:]})
@bp.route("/api/agent/devices")
@admin_required
def agent_devices():
"""AI 可用设备列表:在线状态 + 是否有任务运行(前端选择器 busy 设备禁选)。
worker 状态实时(内存);busy = 任务 running/connecting。
"""
try:
from web import context
devs, err = context.mgr.get_status()
except Exception as e:
return jsonify({"ok": False, "error": str(e)[:120]}), 503
out = []
for d in (devs or []):
busy = d.get("worker_status") in ("running", "connecting")
out.append({"serial": d.get("serial"),
"model": d.get("model") or "",
"online": bool(d.get("present")),
"busy": busy,
"worker_status": d.get("worker_status") or "idle",
"task_job": d.get("task_job") or ""})
return jsonify({"ok": True, "devices": out})
@bp.route("/api/agent/stream")
@admin_required
def agent_stream():
"""SSE 事件流(EventSource):delta/step/done/error。"""
run_id = request.args.get("run_id", "")
with _lock:
if run_id != _run["id"]:
return jsonify({"ok": False, "error": "run_id 不存在"}), 404
q = _queues.get(run_id)
def gen():
while True:
try:
evt = q.get(timeout=15)
except queue.Empty:
yield ": keepalive\n\n" # 心跳防超时
continue
if evt is None:
break
kind, payload = evt
yield f"event: {kind}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
if kind in ("done", "error"):
break
return Response(gen(), mimetype="text/event-stream",
headers={"Cache-Control": "no-cache",
"X-Accel-Buffering": "no"})
@bp.route("/api/agent/stop", methods=["POST"])
@admin_required
def agent_stop():
"""中断当前运行的 Agent(下一个检查点生效,通常在数秒内)。"""
with _lock:
if _run["state"] != "running":
return jsonify({"ok": False, "error": "当前没有运行中的任务"}), 400
evt = _stop_events.get(_run["id"])
if evt:
evt.set()
_log.info("用户请求中断 Agent")
return jsonify({"ok": True, "msg": "已请求停止"})
@bp.route("/api/agent/clear", methods=["POST"])
@admin_required
def agent_clear():
"""清空对话历史。"""
with _lock:
_run["history"] = []
return jsonify({"ok": True, "msg": "已清空"})
def _shrink_image(b64, width=220, quality=50):
"""截图降采样(SSE step 事件用,控制传输体积)。失败原样返回。"""
try:
from PIL import Image
img = Image.open(io.BytesIO(base64.b64decode(b64)))
if img.width > width:
img = img.resize((width, int(img.height * width / img.width)))
buf = io.BytesIO()
img.convert("RGB").save(buf, "JPEG", quality=quality)
return base64.b64encode(buf.getvalue()).decode()
except Exception:
return b64
def _agent_thread(run_id, prompt, serial, cfg):
"""后台线程:Agent 流式执行,事件推入队列供 SSE 消费。"""
q = _queues.get(run_id)
try:
# 平台进程 cwd=/app(含 mcp_agent 包),进程内 import
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from mcp_agent.agent import Agent
def on_delta(text, kind):
q.put(("delta", {"text": text, "kind": kind}))
tool_seq = [] # 本轮工具序列(经验提炼用)
def on_tool(step):
# args 保留对象(json.dumps 序列化)——前端要解析 serial 做画面跟随;
# 之前 str() 成 Python repr(单引号)导致前端 JSON.parse 失败、跟随失效
rec = {"tool": step.get("tool"),
"args": step.get("args") or {}}
if step.get("image"):
rec["image"] = _shrink_image(step["image"])
q.put(("step", rec))
# 记录精简工具序列
try:
args = step.get("args") or {}
brief = {k: v for k, v in args.items() if k != "serial"}
tool_seq.append(f"{step.get('tool')}({str(brief)[:60]})")
except Exception:
pass
agent = Agent()
agent.s.api_base = cfg.get("api_base") or agent.s.api_base
agent.s.model = cfg.get("model") or agent.s.model
agent.s.api_key = cfg.get("api_key") or agent.s.api_key
agent.s.default_serial = cfg.get("default_serial") or agent.s.default_serial
try:
agent.s.max_steps = max(1, min(200, int(cfg.get("max_steps") or 40)))
except (TypeError, ValueError):
pass # 非法值用默认 40
with _lock:
conv_id = _run.get("conv_id") or ""
# 历史来源:绑定了会话 → 从会话读(多轮上下文延续,DeepSeek 式);
# 无会话(CLI/兼容)→ 用运行态 history
if conv_id:
# 后台线程需自行推 app context(与 _find_experiences 同思路)
with _flask_app.app_context():
msgs = _conv_msgs(conv_id)
history = [{"role": m.get("role"), "content": m.get("content", "")[:4000]}
for m in (msgs or []) if m.get("role") in ("user", "assistant")]
history = history[-24:]
with _lock:
_run["history"] = list(history)
else:
with _lock:
history = list(_run.get("history") or [])
target = serial or cfg.get("default_serial") or ""
stop_evt = _stop_events.get(run_id)
# 经验检索:相似历史任务的操作配方注入 system(自进化记忆)
exp_ctx = _find_experiences(prompt)
if exp_ctx:
_log.info("命中历史经验,注入参考配方")
q.put(("step", {"tool": "🧠 经验记忆",
"args": f"命中 {exp_ctx.count(chr(10) + '- ')} 条同类历史经验,已注入参考",
"image": None}))
async def _execute():
await agent._load_tools()
return await agent.run_stream(prompt, target,
history=history,
on_delta=on_delta, on_tool=on_tool,
should_stop=lambda: bool(
stop_evt and stop_evt.is_set()),
extra_context=exp_ctx)
# 整体超时保护:卡死时结束,释放单实例
answer = asyncio.run(asyncio.wait_for(_execute(), timeout=900))
with _lock:
_run["state"] = "done"
_run["answer"] = answer
# 追加本轮进历史(多轮连续性;上限 12 轮防 token 膨胀)
hist = _run.setdefault("history", [])
hist.append({"role": "user", "content": prompt[:2000]})
hist.append({"role": "assistant", "content": (answer or "")[:4000]})
_run["history"] = hist[-24:]
# 会话落库:本轮追加写回(新会话自动用首条消息作标题)。
# 后台线程 db 访问需 app context。
if conv_id and _flask_app is not None:
try:
with _flask_app.app_context():
saved = list(_run["history"])
title = None
if not db.session.execute(db.text(
"SELECT title FROM agent_conversation WHERE id=:i"),
{"i": conv_id}).scalar():
title = "".join(prompt.split())[:30] or "新会话"
_conv_save_messages(conv_id, saved, title=title)
_log.info(f"会话已保存: {conv_id}({len(saved)} 条消息)")
except Exception as e:
_log.warning(f"会话落库失败: {e}")
# 自进化:成功执行过工具则提炼配方写入经验。必须在 done 之前完成——
# done 发出后 SSE 关流,用户就看不到「已写入经验」的提示了。
# 提炼/保存失败静默(不阻塞、不影响结果),只在成功时推送 🧠 卡片。
if tool_seq:
try:
recipe = _distill_experience(cfg, prompt, " -> ".join(tool_seq))
if recipe and "配方" not in recipe[:50]:
if _qualify_experience(prompt, recipe):
if _save_experience(prompt, recipe, " -> ".join(tool_seq)):
_log.info("经验已写入记忆库,随事件流提示")
q.put(("step", {"tool": "🧠 经验记忆",
"args": "本轮操作已提炼为经验并写入记忆库"
"(下次相似任务会自动参考)",
"image": None}))
except Exception as e:
_log.warning(f"经验保存异常: {e}")
q.put(("done", {"answer": answer}))
except Exception as e:
_log.warning(f"Agent 运行异常: {e}")
# 诊断:打印消息结构(tool_calls 与 tool 消息配对检查)
try:
roles = [m.get("role", "?") for m in agent.messages]
tcs = sum(1 for m in agent.messages
if m.get("tool_calls") and isinstance(m.get("tool_calls"), list))
tools_msg = sum(1 for m in agent.messages if m.get("role") == "tool")
_log.warning(f"诊断 messages: {len(agent.messages)} 条 roles={roles[-8:]} "
f"tool_calls消息={tcs} tool回应={tools_msg}")
except Exception:
pass
with _lock:
_run["state"] = "error"
_run["error"] = f"{type(e).__name__}: {str(e)[:200]}"
q.put(("error", {"message": str(e)[:200]}))
finally:
q.put(None) # 关闭 SSE
_queues.pop(run_id, None)
_stop_events.pop(run_id, None)
+123
View File
@@ -0,0 +1,123 @@
"""应用管理 API(APK 上传/安装/删除/设备已装应用)。"""
import re
import shlex
import subprocess
from flask import Blueprint, jsonify, request
from flask_login import login_required
from core import device_pool
from core.adb_helper import adb_connect_light, _adb
from web import context
from web.auth import perm_required, PERM_APKS
from web.common import _merged_device_list
bp = Blueprint("apks", __name__)
@bp.route("/api/apks")
@login_required
def api_apks_list():
"""列出所有已上传的 APK。"""
return jsonify({"ok": True, "apks": context.apk_mgr.list_all()})
@bp.route("/api/apks/upload", methods=["POST"])
@perm_required(PERM_APKS)
def api_apks_upload():
"""上传 APK 文件(multipart/form-data,字段名 file)。"""
file = request.files.get("file")
if not file or not file.filename:
return jsonify({"ok": False, "error": "未选择文件"}), 400
info = context.apk_mgr.upload(file)
if info:
return jsonify({"ok": True, "apk": info,
"msg": f"上传成功: {info['display_name']}"})
return jsonify({"ok": False, "error": "上传失败,请检查文件格式"}), 500
@bp.route("/api/apks/<apk_id>", methods=["DELETE"])
@perm_required(PERM_APKS)
def api_apks_delete(apk_id):
"""删除 APK 文件和记录。"""
ok, msg = context.apk_mgr.delete(apk_id)
if ok:
return jsonify({"ok": True, "msg": msg})
return jsonify({"ok": False, "error": msg}), 400
@bp.route("/api/apks/install/devices")
@perm_required(PERM_APKS)
def api_apks_install_devices():
"""可安装设备列表(应用管理):设备池在线设备 + 本机 adb 设备(含 USB 有线连接)。
source: pool=设备池 / usb=本机有线 adb / adb=本机网络 adb。
"""
devices, seen = [], set()
try:
online = set(device_pool.list_online())
for serial in device_pool.list_configured():
if serial in online:
devices.append({"serial": serial, "model": "", "source": "pool"})
seen.add(serial)
except Exception:
pass
out = _adb("devices")
for line in (out or "").splitlines()[1:]:
parts = line.split()
if len(parts) >= 2 and parts[0] and parts[0] not in seen:
devices.append({"serial": parts[0], "model": "",
"source": "usb" if ":" not in parts[0] else "adb"})
seen.add(parts[0])
return jsonify({"ok": True, "devices": devices})
@bp.route("/api/apks/install", methods=["POST"])
@perm_required(PERM_APKS)
def api_apks_install():
"""批量安装 APK 到指定设备。参数: {apk_id, serials:[]}"""
data = request.json or {}
apk_id = data.get("apk_id", "")
serials = data.get("serials", [])
ok, msg = context.apk_mgr.install(apk_id, serials)
if ok:
return jsonify({"ok": True, "msg": msg})
return jsonify({"ok": False, "error": msg}), 400
@bp.route("/api/apks/install/status")
@login_required
def api_apks_install_status():
"""获取安装任务实时状态。"""
status = context.apk_mgr.get_install_status()
return jsonify({"ok": True, "status": status})
# ================== API:维护(仅管理员) ==================
# 维护功能:STF 容器一键重启 + adb 远程终端。两个接口都仅管理员可用。
# adb 终端安全红线(技术约束):绝不 kill-server / 绝不 disconnect IP:5555——
# STF provider 共享该 adb transport,断开会让 STF 误判全部设备离线并触发重连。
_ADB_BLOCKED_PATTERNS = ("kill-server", "disconnect")
def _blocked_adb_cmd(cmd):
"""命中红线的 adb 命令(kill-server / disconnect)直接拒绝。"""
low = cmd.lower()
return any(p in low for p in _ADB_BLOCKED_PATTERNS)
def _merged_device_list():
"""本地 adb(含 USB)+ 设备池的合并列表([{serial, state}])。
供维护终端/剪贴板注入/应用版本管理等设备选择场景共用。
状态:device/offline=本机 adb 实际状态;pool=设备池已配置但本机未连接;
stf_not_ready=STF agent 未就绪(迁移期保留,阶段 3 摘除 STF 后删除)。
"""
states = {}
out = _adb("devices")
for line in (out or "").splitlines()[1:]:
parts = line.split()
if len(parts) >= 2 and parts[0] and not parts[0].startswith("*"):
states[parts[0]] = parts[1]
try:
for serial in device_pool.list_configured():
if serial not in states:
states[serial] = "pool"
except Exception:
pass
return [{"serial": s, "state": st} for s, st in sorted(states.items())]
+157
View File
@@ -0,0 +1,157 @@
"""认证与页面路由:登录/登出/CSRF/权限装饰器/页面(/、/wall)。
CSRF 的 before_request 由 web_server 注册(app 级),本模块提供实现。
"""
import functools
import secrets
from flask import (Blueprint, jsonify, request, redirect, url_for,
render_template, make_response, session)
from flask_login import (login_user, logout_user, login_required, current_user)
from core.logger import get_logger
from core.models import User
_log = get_logger("web")
bp = Blueprint("auth", __name__)
# ================== CSRF ==================
def _csrf_token():
"""获取或生成当前会话的 CSRF token(非 GET 请求需在 X-CSRF-Token 头携带)。"""
if "csrf_token" not in session:
session["csrf_token"] = secrets.token_hex(16)
return session["csrf_token"]
def _csrf_protect():
"""非安全方法(POST/PUT/DELETE/PATCH)校验 X-CSRF-Token 请求头。"""
if request.method not in ("POST", "PUT", "DELETE", "PATCH"):
return
# 登录表单(未登录,尚无 token)和静态文件跳过
if request.endpoint in ("login", "static"):
return
sess = session.get("csrf_token", "")
header = request.headers.get("X-CSRF-Token", "")
if not sess or header != sess:
return jsonify({"ok": False, "error": "CSRF 校验失败"}), 403
@bp.route("/api/csrf")
@login_required
def api_csrf():
"""获取 CSRF token(前端非 GET 请求需携带 X-CSRF-Token 头)。"""
return jsonify({"ok": True, "token": _csrf_token()})
# ================== 权限控制 ==================
PERM_TASKS = "tasks"
PERM_DEVICES = "devices"
PERM_APKS = "apks"
PERM_LOGS = "logs"
ALL_PERMS = (PERM_TASKS, PERM_DEVICES, PERM_APKS, PERM_LOGS)
_PERM_LABELS = {PERM_TASKS: "任务管理", PERM_DEVICES: "设备控制",
PERM_APKS: "应用管理", PERM_LOGS: "日志查看"}
def _has_perm(perm):
"""当前用户是否拥有指定权限。管理员恒为 True。"""
u = current_user
return bool(u and (u.is_admin or u.has_perm(perm)))
def _validate_perms(raw):
"""校验权限位列表:只保留合法值、去重。返回合法列表。"""
valid = set(ALL_PERMS)
out = []
for p in raw or []:
if p in valid and p not in out:
out.append(p)
return out
def perm_required(perm):
"""路由装饰器:要求登录且拥有指定业务权限,否则 403。"""
def deco(fn):
@functools.wraps(fn)
@login_required
def wrapper(*args, **kwargs):
if not _has_perm(perm):
return jsonify({"ok": False,
"error": f"无权限执行此操作(需要权限: {_PERM_LABELS.get(perm, perm)})"}), 403
return fn(*args, **kwargs)
return wrapper
return deco
def admin_required(fn):
"""路由装饰器:仅管理员可用(用户管理类接口),否则 403。"""
@functools.wraps(fn)
@login_required
def wrapper(*args, **kwargs):
if not current_user.is_admin:
return jsonify({"ok": False, "error": "仅管理员可执行此操作"}), 403
return fn(*args, **kwargs)
return wrapper
@bp.route("/api/me")
@login_required
def api_me():
"""当前登录用户信息(含权限位),前端据此隐藏无权限的功能入口。"""
u = current_user
return jsonify({"ok": True, "user": {
"id": u.id,
"username": u.username,
"is_admin": bool(u.is_admin),
# 管理员返回全部权限位,前端统一用 perms 判断
"perms": list(ALL_PERMS) if u.is_admin else u.get_perms(),
}})
# ================== 页面路由 ==================
@bp.route("/")
@login_required
def index():
"""单页应用首页。"""
resp = make_response(render_template("admin/monitor.html"))
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
resp.headers["Pragma"] = "no-cache"
return resp
@bp.route("/wall")
@login_required
def wall():
"""监控大屏(独立全屏页面,供挂墙/电视展示)。
深色控制室风格;设备缩略图按需轮询(每设备 ~5s 一帧),
20 台设备整体开销约 0.1 核 CPU + 50KB/s,普通电脑无压力。
"""
resp = make_response(render_template("admin/wall.html"))
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
return resp
@bp.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form.get("username", "")
password = request.form.get("password", "")
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
login_user(user)
_csrf_token() # 建立 CSRF token,前端通过 /api/csrf 获取
_log.info(f"用户 {username} 登录")
return redirect(request.args.get("next") or url_for("auth.index"))
return render_template("admin/login.html", error="用户名或密码错误")
return render_template("admin/login.html", error=None)
@bp.route("/logout")
@login_required
def logout():
_log.info(f"用户 {current_user.username} 登出")
logout_user()
return redirect(url_for("auth.login"))
+46
View File
@@ -0,0 +1,46 @@
"""跨模块共享工具(web 层)。"""
from core import device_pool
from core.adb_helper import _adb
from core.logger import get_logger
_log = get_logger("web")
def _merged_device_list():
"""本地 adb(含 USB)+ 设备池的合并列表([{serial, state}])。
供维护终端/剪贴板注入/应用版本管理等设备选择场景共用。
状态:device/offline=本机 adb 实际状态;pool=设备池已配置但本机未连接。
"""
states = {}
out = _adb("devices")
for line in (out or "").splitlines()[1:]:
parts = line.split()
if len(parts) >= 2 and parts[0] and not parts[0].startswith("*"):
states[parts[0]] = parts[1]
try:
for serial in device_pool.list_configured():
if serial not in states:
states[serial] = "pool"
except Exception:
pass
return [{"serial": s, "state": st} for s, st in sorted(states.items())]
def _device_screen_state(serial):
"""设备屏幕状态:on/off/unknown(dumpsys power 只读查询)。
用 mWakefulness 判断(Asleep=熄屏):MIUI 的 dumpsys display 没有
mScreenState 字段,power 字段全机型存在。USB 设备查询失败返回 unknown。
"""
try:
if ":" not in serial:
return "unknown"
out = _adb("-s", serial, "shell", "dumpsys", "power") or ""
import re
m = re.search(r"mWakefulness=(\w+)", out)
if m:
return "off" if m.group(1).lower() == "asleep" else "on"
except Exception:
pass
return "unknown"
+12
View File
@@ -0,0 +1,12 @@
"""共享上下文:web_server 启动时注入全局对象,各蓝图模块从这里取。
避免蓝图模块与 web_server 循环 import。
"""
mgr = None # core.task_manager.TaskManager
apk_mgr = None # core.apk_manager.ApkManager
device_pool = None # core.device_pool
def init(mgr_, apk_mgr_, device_pool_):
global mgr, apk_mgr, device_pool
mgr, apk_mgr, device_pool = mgr_, apk_mgr_, device_pool_
+237
View File
@@ -0,0 +1,237 @@
"""设备池管理 API(本地 SQLite 清单)。"""
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from flask import Blueprint, jsonify, request
from core import device_pool, device_discovery
from core.adb_helper import adb_connect
from core.logger import get_logger
from web.auth import perm_required, PERM_DEVICES
_log = get_logger("web")
bp = Blueprint("devices", __name__)
@bp.route("/api/devices/pool", methods=["GET"])
@perm_required(PERM_DEVICES)
def api_devices_pool_list():
"""设备池清单(SQLite,含实时在线状态)。"""
try:
online = set(device_pool.list_online())
except Exception:
online = set()
rows = device_pool.list_devices()
for r in rows:
r["online"] = r["serial"] in online
return jsonify({"ok": True, "devices": rows})
@bp.route("/api/devices/pool/add", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_pool_add():
"""添加/更新设备:{serial, name?, note?}。
IP:5555 设备添加后立即尝试 adb connect(不可达显示离线,不影响其他设备)。
"""
data = request.json or {}
serial = (data.get("serial") or "").strip()
if not serial:
return jsonify({"ok": False, "error": "请输入设备 serial(如 100.100.10.20:5555)"}), 400
is_new = device_pool.add_device(serial,
name=(data.get("name") or "").strip(),
note=(data.get("note") or "").strip())
msg = "已添加" if is_new else "已更新"
if ":" in serial:
try:
adb_connect(serial)
except Exception:
pass
# 后台采集型号(不阻塞添加响应)
import threading
threading.Thread(target=device_pool.refresh_model, args=(serial,),
daemon=True).start()
_log.info(f"设备池管理: {msg} {serial}")
return jsonify({"ok": True, "msg": msg, "is_new": is_new})
@bp.route("/api/devices/pool/remove", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_pool_remove():
"""从设备池删除(不再参与调度;不影响其他系统)。"""
serial = (request.json or {}).get("serial", "").strip()
if not serial:
return jsonify({"ok": False, "error": "缺少 serial"}), 400
ok = device_pool.remove_device(serial)
if not ok:
return jsonify({"ok": False, "error": "设备不存在"}), 404
return jsonify({"ok": True, "msg": "已删除"})
@bp.route("/api/devices/pool/toggle", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_pool_toggle():
"""启用/停用设备(停用后不参与调度)。"""
data = request.json or {}
serial = (data.get("serial") or "").strip()
enabled = data.get("enabled")
if not serial or enabled is None:
return jsonify({"ok": False, "error": "缺少参数"}), 400
ok = device_pool.set_enabled(serial, bool(enabled))
if not ok:
return jsonify({"ok": False, "error": "设备不存在"}), 404
return jsonify({"ok": True, "msg": "已" + ("启用" if enabled else "停用")})
@bp.route("/api/devices/pool/reconnect", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_pool_reconnect():
"""一键重连:并发 adb connect 池内全部 IP:5555 设备(后台执行,不阻塞)。"""
def _run():
try:
serials = [s for s in device_pool.list_configured() if ":" in s]
except Exception:
return
if not serials:
return
from concurrent.futures import ThreadPoolExecutor, as_completed
with ThreadPoolExecutor(max_workers=10) as pool:
futures = {pool.submit(adb_connect, s): s for s in serials}
for _ in as_completed(futures):
pass
_log.info(f"设备池一键重连完成({len(serials)} 台)")
# 重连后顺手刷新型号
try:
device_pool.refresh_all_models()
except Exception:
pass
import threading
threading.Thread(target=_run, daemon=True).start()
return jsonify({"ok": True, "msg": "重连已启动(后台并发,约 10-20 秒)"})
@bp.route("/api/devices/pool/refresh_models", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_pool_refresh_models():
"""批量采集池内在线设备的型号(后台执行,不阻塞)。"""
import threading
threading.Thread(target=device_pool.refresh_all_models, daemon=True).start()
return jsonify({"ok": True, "msg": "型号采集已启动(后台并发,约 10 秒)"})
# ================== API:设备自动发现(扫描 → 待连接池 → 确认) ==================
# 扫描只把设备放进待连接池(pending),用户确认后才加入正式设备池并连接。
@bp.route("/api/devices/discovery", methods=["GET"])
@perm_required(PERM_DEVICES)
def api_devices_discovery():
"""发现状态 + 待连接列表 + 正式池断联设备(前端 10s 轮询一次请求)。"""
try:
status = device_discovery.get_status()
pending = device_discovery.list_pending()
pool_offline = device_discovery.list_pool_offline()
except Exception as e:
_log.warning(f"discovery 状态获取失败: {e}")
return jsonify({"ok": False, "error": str(e)[:120]}), 503
status["pending"] = pending
status["pool_offline"] = pool_offline
return jsonify({"ok": True, **status})
@bp.route("/api/devices/discovery/scan", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_discovery_scan():
"""手动触发一轮扫描(后台执行,约 5-30 秒)。"""
ok, result = device_discovery.scan_once(manual=True)
if not ok:
return jsonify({"ok": False, "error": result.get("error", "扫描失败")}), 409
if "error" in result:
return jsonify({"ok": False, "error": result["error"]}), 400
return jsonify({"ok": True, "msg": "扫描已启动(后台执行,约 5-30 秒)",
"result": result})
@bp.route("/api/devices/discovery/confirm", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_discovery_confirm():
"""确认连接:待连接 → 正式设备池 + 后台 adb connect + 采型号。"""
data = request.json or {}
serial = (data.get("serial") or "").strip()
if not serial:
return jsonify({"ok": False, "error": "缺少 serial"}), 400
ok, msg, is_new = device_discovery.confirm_pending(
serial, name=(data.get("name") or "").strip())
if not ok:
return jsonify({"ok": False, "error": msg}), 404
if ":" in serial:
def _connect():
try:
adb_connect(serial)
except Exception:
pass
try:
device_pool.refresh_model(serial)
except Exception:
pass
threading.Thread(target=_connect, daemon=True).start()
_log.info(f"设备池管理: 发现确认 {serial}")
return jsonify({"ok": True, "msg": msg, "is_new": is_new})
@bp.route("/api/devices/discovery/ignore", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_discovery_ignore():
"""忽略:从待连接池删除(下轮扫描可能再次发现)。"""
serial = (request.json or {}).get("serial", "").strip()
if not serial:
return jsonify({"ok": False, "error": "缺少 serial"}), 400
ok, msg = device_discovery.ignore_pending(serial)
if not ok:
return jsonify({"ok": False, "error": msg}), 404
return jsonify({"ok": True, "msg": msg})
@bp.route("/api/devices/discovery/reconnect", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_discovery_reconnect():
"""手动立即重连正式池断联设备(后台 adb connect + 采型号)。
日常无需手动——发现线程每轮(默认 60s)自动重连断联设备;
本端点用于不想等下一轮扫描时立即拉起。
"""
serial = (request.json or {}).get("serial", "").strip()
if not serial:
return jsonify({"ok": False, "error": "缺少 serial"}), 400
from core import device_pool
if serial not in device_pool.list_configured():
return jsonify({"ok": False, "error": "设备不在设备池中"}), 404
def _reconnect():
try:
if ":" in serial:
adb_connect(serial)
# 无论是否连上,采一次型号(模型失败静默)
try:
device_pool.refresh_model(serial)
except Exception:
pass
except Exception as e:
_log.warning(f"手动重连 {serial} 异常: {e}")
threading.Thread(target=_reconnect, daemon=True).start()
_log.info(f"设备池管理: 手动重连 {serial}")
return jsonify({"ok": True, "msg": "重连已启动(约 5-15 秒生效)"})
@bp.route("/api/devices/discovery/settings", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_devices_discovery_settings():
"""保存发现配置(部分字段更新):{enabled?, subnets?, interval?, port?}。"""
data = request.json or {}
ok, msg = device_discovery.save_settings(
enabled=data.get("enabled"),
subnets=data.get("subnets"),
interval=data.get("interval"),
port=data.get("port"))
if not ok:
return jsonify({"ok": False, "error": msg}), 400
return jsonify({"ok": True, "msg": msg})
# ================== API:Tailscale 管理(仅管理员) ==================
# 通过 Tailscale 官方 API v2 管理 tailnet 设备(列表/改名/授权/密钥不过期/删除/生成 auth key)。
# 设备 IP 由 tailnet 自动分配,API 无法修改,列表只读展示。
-94
View File
@@ -1,94 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="UTF-8"><title>设备监控大屏</title>
<style>
*{box-sizing:border-box} body{font-family:-apple-system,"Segoe UI",Roboto,sans-serif;margin:0;background:#f0f2f5;color:#2c3e50}
.header{background:#fff;padding:14px 24px;box-shadow:0 1px 4px rgba(0,0,0,.08);display:flex;align-items:center;gap:16px}
.header h1{font-size:18px;margin:0} .header .ts{color:#95a5a6;font-size:12px;margin-left:auto}
.header a{color:#1890ff;text-decoration:none;font-size:13px}
.header a:hover{text-decoration:underline}
.content{padding:20px 24px;max-width:1400px;margin:0 auto}
.bar{display:flex;gap:8px;margin-bottom:16px;align-items:center;flex-wrap:wrap}
button{padding:7px 16px;border:1px solid #d9d9d9;border-radius:4px;cursor:pointer;font-size:13px;background:#fff;color:#595959}
button:hover{border-color:#40a9ff;color:#40a9ff} button.primary{background:#1890ff;color:#fff;border-color:#1890ff}
button.primary:hover{background:#40a9ff} button.danger{color:#ff4d4f;border-color:#ff4d4f}
button.danger:hover{background:#ff4d4f;color:#fff} button.sm{padding:3px 10px;font-size:12px}
table{width:100%;border-collapse:collapse;background:#fff;border-radius:6px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.08)}
th,td{padding:9px 10px;text-align:left;border-bottom:1px solid #f0f0f0;font-size:13px}
th{background:#fafafa;font-weight:600;color:#595959} tr:hover td{background:#fafafa}
.badge{padding:2px 8px;border-radius:10px;font-size:11px;color:#fff}
.b-running{background:#52c41a}.b-connecting{background:#faad14}.b-error{background:#ff4d4f}
.b-done{background:#1890ff}.b-idle{background:#bfbfbf;color:#595959}.b-released{background:#8c8c8c}.b-failed{background:#ff4d4f}
.ok{color:#52c41a}.no{color:#ff4d4f}.muted{color:#8c8c8c;font-size:12px}
.err{color:#ff4d4f;font-size:12px;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.progress{width:70px;height:5px;background:#f0f0f0;border-radius:3px;overflow:hidden;display:inline-block;vertical-align:middle}
.progress>div{height:100%;background:#1890ff}
.tag{display:inline-block;background:#e6f7ff;color:#1890ff;padding:1px 8px;border-radius:3px;font-size:11px;margin:1px}
.kv{display:inline-block;background:#f5f5f5;border-radius:3px;padding:0 6px;margin:1px;font-size:11px;color:#595959}
.stats{display:flex;gap:16px;margin-bottom:16px}
.stat-card{background:#fff;padding:14px 20px;border-radius:6px;box-shadow:0 1px 3px rgba(0,0,0,.08);min-width:120px}
.stat-card .num{font-size:24px;font-weight:600;color:#1890ff}
.stat-card .lbl{font-size:12px;color:#8c8c8c;margin-top:2px}
</style></head><body>
<div class="header">
<h1>设备监控大屏</h1>
<span class="muted" id="devCount"></span>
<span class="ts" id="srvTime"></span>
<a href="/admin" target="_blank">管理后台 →</a>
</div>
<div class="content">
<div class="stats" id="stats"></div>
<div class="bar">
<button class="primary" onclick="doPost('/api/jobs/_quick/run')" title="快速启动:对全部空闲设备跑默认抖音养号">快速启动全部</button>
<button class="danger" onclick="doPost('/api/stop_all')">停止全部</button>
<button onclick="doPost('/api/release')">强制释放占用</button>
<button onclick="loadStatus()">刷新</button>
<span class="muted">每 5 秒自动刷新 · 分组/任务管理请到 <a href="/admin" target="_blank">管理后台</a></span>
</div>
<table><thead><tr>
<th>设备</th><th>型号</th><th>STF</th><th>任务状态</th><th>抖音</th><th>进度</th>
<th>任务名称</th><th>当前动作</th><th>操作计数</th><th>最近错误</th><th>操作</th>
</tr></thead><tbody id="tb-devices"></tbody></table>
</div>
<script>
const ACTION_LABELS={like:'点赞',comment:'评论',follow:'关注',share:'分享'};
function doPost(url,body){return fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):'{}'}).then(r=>r.json()).then(d=>{alert(JSON.stringify(d,null,1));loadStatus();}).catch(e=>alert(e));}
function fmtActionCounts(c){
if(!c||!Object.keys(c).length)return '<span class="muted">-</span>';
const parts=Object.entries(c).filter(([_,v])=>v>0).map(([k,v])=>'<span class="kv">'+(ACTION_LABELS[k]||k)+v+'</span>');
return parts.length?parts.join(''):'<span class="muted">-</span>';
}
async function loadStatus(){
try{
const r=await fetch('/api/status');const d=await r.json();if(!d.ok)return;
document.getElementById('devCount').textContent='设备 '+d.devices.length;
document.getElementById('srvTime').textContent=new Date(d.server_time*1000).toLocaleTimeString();
// 统计卡片
const running=d.devices.filter(x=>x.worker_status==='running').length;
const idle=d.devices.filter(x=>x.worker_status==='idle').length;
const error=d.devices.filter(x=>['error','failed'].includes(x.worker_status)).length;
const occupied=d.devices.filter(x=>x.stf_occupied).length;
document.getElementById('stats').innerHTML=
'<div class="stat-card"><div class="num" style="color:#52c41a">'+running+'</div><div class="lbl">运行中</div></div>'+
'<div class="stat-card"><div class="num" style="color:#1890ff">'+occupied+'</div><div class="lbl">STF占用</div></div>'+
'<div class="stat-card"><div class="num" style="color:#bfbfbf">'+idle+'</div><div class="lbl">空闲</div></div>'+
'<div class="stat-card"><div class="num" style="color:#ff4d4f">'+error+'</div><div class="lbl">异常</div></div>';
// 设备表格
document.getElementById('tb-devices').innerHTML=d.devices.map(dev=>{
const stf=dev.stf_occupied?'<span class="badge b-running">占用</span>':'<span class="badge b-idle">空闲</span>';
if(!dev.present)stf+=' <span class="no">离线</span>';
const ws=dev.worker_status;
const wb='<span class="badge b-'+ws+'">'+({idle:'未启动',connecting:'连接中',running:'运行中',done:'完成',error:'异常',released:'已释放',failed:'失败'}[ws]||ws)+'</span>';
const dy=dev.douyin_running?'<span class="ok">●</span>':'<span class="no">○</span>';
const pct=Math.round(dev.videos_watched/80*100);
const prog='<span class="progress"><div style="width:'+pct+'%"></div></span> '+dev.videos_watched;
const tname=dev.task_job?'<span class="tag">'+dev.task_job+'</span>':'<span class="muted">-</span>';
const retry=dev.attempt>0?' <span class="muted">'+dev.attempt+'次</span>':'';
const btn=dev.worker_status==='running'||dev.worker_status==='connecting'?'<button class="danger sm" onclick="doPost(\'/api/stop_device\',{serial:\''+dev.serial+'\'})">停止</button>':'';
return '<tr><td>'+dev.serial+'</td><td>'+dev.model+'</td><td>'+stf+'</td><td>'+wb+'</td><td>'+dy+'</td><td>'+prog+'</td><td>'+tname+retry+'</td><td class="muted">'+(dev.current_action||'-')+'</td><td>'+fmtActionCounts(dev.action_counts)+'</td><td class="err" title="'+(dev.last_error||'')+'">'+(dev.last_error||'-')+'</td><td>'+btn+'</td></tr>';
}).join('');
}catch(e){console.error(e);}
}
loadStatus();
setInterval(loadStatus,5000);
</script>
</body></html>
+701
View File
@@ -0,0 +1,701 @@
"""监控域 API:状态/运行控制/设备操作/远程看屏。"""
import time
import threading
import re
import subprocess
import shlex
from concurrent.futures import ThreadPoolExecutor, as_completed
from flask import (Blueprint, jsonify, request, Response, render_template)
from flask_login import login_required
from core import device_pool
from core.adb_helper import (screenshot, list_installed_apps, adb_connect,
adb_connect_light, _ADB_LOCK, _adb)
from core.device_worker import (get_all_worker_status, clear_worker_error,
clear_all_worker_errors)
from web import context
from web.auth import perm_required, PERM_DEVICES
from web.common import _merged_device_list, _device_screen_state
from config import ADB_PATH
from core.logger import get_logger
_log = get_logger("web")
bp = Blueprint("monitor", __name__)
# 远程看屏参数
_SCREEN_JPEG_QUALITY = 65
_SCREEN_FRAME_GAP = 0.05
@bp.route("/api/health")
def api_health():
"""轻量健康检查:返回进程/设备/任务摘要,不暴露敏感信息,供运维探活。"""
try:
devices, err = context.mgr.get_status()
devs = devices or []
return jsonify({
"ok": True,
"status": "up",
"time": time.time(),
"device_total": len(devs),
"device_online": sum(1 for d in devs if d.get("present")),
"device_running": sum(1 for d in devs
if d.get("worker_status") in ("running", "connecting")),
"device_error": sum(1 for d in devs
if d.get("worker_status") in ("error", "failed")),
"jobs": len(context.mgr.jobs),
})
except Exception as e:
return jsonify({"ok": False, "status": "down", "error": str(e)}), 500
# ================== API:失败任务汇总 ==================
@bp.route("/api/summary")
@login_required
def api_summary():
"""失败/异常任务汇总:各状态计数 + 异常设备列表(last_error / 重试次数)。
供监控页"异常汇总"面板使用,便于长期运行观察设备健康度。
"""
try:
sts = get_all_worker_status()
# 已删除/不在当前 STF 设备池的设备,其陈旧失败记录不应展示。
# 复用 get_status 缓存(顺带触发上面的状态清理),STF 异常时不隐藏任何记录。
try:
devices, derr = context.mgr.get_status()
if not derr:
present = {d.get("serial") for d in devices}
sts = [s for s in sts if s.get("serial") in present]
except Exception:
pass
counts = {"total": 0, "running": 0, "done": 0,
"error": 0, "failed": 0, "idle": 0}
errors = []
for s in sts:
st = s.get("status", "idle")
counts["total"] += 1
if st in counts:
counts[st] += 1
if st in ("error", "failed") and s.get("last_error"):
errors.append({
"serial": s.get("serial"),
"model": s.get("model", ""),
"status": st,
"last_error": s.get("last_error", ""),
"task": s.get("task_job", ""),
"attempt": s.get("attempt", 0),
"updated": s.get("last_heartbeat", 0),
})
errors.sort(key=lambda x: x.get("updated", 0), reverse=True)
return jsonify({"ok": True, "counts": counts, "errors": errors[:50]})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
# ================== API:状态(监控大屏用)==================
@bp.route("/api/status")
@login_required
def api_status():
devices, err = context.mgr.get_status()
if err:
return jsonify({"ok": False, "error": err}), 500
return jsonify({
"ok": True, "devices": devices, "server_time": time.time(),
"fg_scanning": context.mgr._fg_scanner.is_scanning,
"fg_last_scan": context.mgr._fg_scanner.last_scan_time,
})
@bp.route("/api/scan_foreground", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_scan_foreground():
"""手动触发前台 App 扫描(不打扰设备)。"""
started = context.mgr._fg_scanner.scan_once()
if started:
return jsonify({"ok": True, "msg": "扫描已启动"})
return jsonify({"ok": False, "error": "已有扫描在进行中"})
@bp.route("/api/stop_device", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_stop_device():
serial = (request.json or {}).get("serial", "")
if context.mgr.stop_device(serial):
return jsonify({"ok": True, "msg": f"已发送停止信号给 {serial}"})
return jsonify({"ok": False, "error": f"{serial} 没有运行中的任务"}), 400
@bp.route("/locate")
def locate_page():
"""设备端定位页(免登录):点亮后浏览器打开,大字显示设备 IP/序列号。
只展示 serial 文本,无任何平台数据,免登录可接受。
"""
serial = request.args.get("serial", "")
return render_template_string(
"""<!DOCTYPE html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>设备定位</title><style>
body{margin:0;background:#000;color:#fff;display:flex;flex-direction:column;
align-items:center;justify-content:center;height:100vh;font-family:monospace}
.serial{font-size:min(9vw,72px);font-weight:700;color:#ffd700;word-break:break-all;padding:0 20px;text-align:center}
.ip{font-size:min(5vw,36px);color:#7dd3fc;margin-top:24px;word-break:break-all;padding:0 20px;text-align:center}
.hint{font-size:min(3vw,16px);color:#64748b;margin-top:40px}
</style></head><body>
<div class="serial">{{ serial }}</div>
<div class="ip">{{ ip }}</div>
<div class="hint">定位完成 · 按返回键退出</div>
</body></html>""",
serial=_esc(serial), ip=_esc(serial.split(":")[0]))
# 常见浏览器包名(结束定位时 force-stop 用)
_BROWSER_PKGS = {"com.android.browser", "com.miui.browser", "com.android.chrome",
"com.brave.browser", "com.opera.browser", "org.mozilla.firefox",
"com.UCMobile", "com.tencent.mtt", "com.baidu.browser.apps"}
# 定位时启动的浏览器:serial -> 包名(结束定位时无论前后台都关闭它)
_locate_browsers = {}
_locate_lock = threading.Lock()
def _current_focus_pkg(serial):
"""取设备当前前台包名(dumpsys window 解析,失败返回空串)。"""
try:
r = subprocess.run([ADB_PATH, "-s", serial, "shell", "dumpsys", "window"],
capture_output=True, timeout=20)
out = (r.stdout or b"").decode("utf-8", errors="replace")
m = re.search(r"mCurrentFocus=.*?([\w.]+)/", out)
return m.group(1) if m else ""
except Exception:
return ""
@bp.route("/api/device/locate/stop", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_device_locate_stop():
"""结束定位:关闭定位启动的浏览器(无论当前是否前台)。
优先 force-stop 定位时记录下的浏览器包名(切到后台也能关掉);
无记录时退回:前台是浏览器 → force-stop;否则按返回键(不误杀任务应用)。
"""
serial = str((request.json or {}).get("serial", "")).strip()
if not serial:
return jsonify({"ok": False, "error": "缺少设备 serial"}), 400
try:
if ":" in serial:
adb_connect(serial)
# 1. 优先关闭定位时启动的浏览器(无论前后台)
with _locate_lock:
started = _locate_browsers.pop(serial, None)
if started:
subprocess.run([ADB_PATH, "-s", serial, "shell", "am", "force-stop", started],
capture_output=True, timeout=20)
msg = f"已关闭定位浏览器 {started}"
else:
# 2. 退回:前台是浏览器则关闭,否则返回键轻量退出
pkg = _current_focus_pkg(serial)
if pkg in _BROWSER_PKGS:
subprocess.run([ADB_PATH, "-s", serial, "shell", "am", "force-stop", pkg],
capture_output=True, timeout=20)
msg = f"已关闭浏览器 {pkg}"
else:
subprocess.run([ADB_PATH, "-s", serial, "shell", "input", "keyevent", "4"],
capture_output=True, timeout=15) # BACK 轻量退出
msg = "已按返回键退出" + (f"(前台 {pkg})" if pkg else "")
_log.info(f"结束定位 {serial}: {msg}")
return jsonify({"ok": True, "msg": msg})
except Exception as e:
return jsonify({"ok": False, "error": f"结束定位失败: {e}"}), 502
@bp.route("/api/device/screen_all", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_device_screen_all():
"""批量亮屏/息屏(并发)。mode: on=亮屏解锁 / off=息屏。
请求可带 serials 指定设备(前端勾选);不带则作用于全部在线设备。
息屏会中断运行中的任务(u2 无法操作),前端已有确认提示。
"""
data = request.json or {}
mode = data.get("mode", "on")
try:
online = set(device_pool.list_online())
except Exception as e:
return jsonify({"ok": False, "error": f"获取设备列表失败: {e}"}), 502
serials = data.get("serials") or []
if serials:
# 指定设备:过滤掉不在线的(离线设备操作无意义,直接跳过)
serials = [s for s in serials if s in online]
else:
serials = list(online)
if not serials:
return jsonify({"ok": False, "error": "无在线设备"}), 400
def _do(serial):
try:
if ":" in serial:
adb_connect(serial)
if mode == "off":
subprocess.run([ADB_PATH, "-s", serial, "shell", "input", "keyevent", "26"],
capture_output=True, timeout=15) # KEYCODE_POWER 息屏
else:
subprocess.run([ADB_PATH, "-s", serial, "shell", "input", "keyevent", "224"],
capture_output=True, timeout=15) # WAKEUP
subprocess.run([ADB_PATH, "-s", serial, "shell", "wm", "dismiss-keyguard"],
capture_output=True, timeout=15)
return True
except Exception:
return False
with ThreadPoolExecutor(max_workers=min(10, len(serials))) as pool:
results = list(pool.map(_do, serials))
ok_n = sum(1 for x in results if x)
_log.info(f"批量{'息屏' if mode == 'off' else '亮屏'}: 成功 {ok_n}/{len(serials)}")
return jsonify({"ok": True, "success": ok_n, "total": len(serials)})
@bp.route("/api/device/locate", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_device_locate():
"""定位设备:点亮屏幕并解除锁屏,可选打开大字定位页(查找设备用)。
请求 {"serial": "...", "show": true}:show=true 时额外用浏览器打开
平台 /locate 定位页,全屏大字显示设备 IP(更醒目,但会切换前台,
任务运行中慎用)。点亮组合:WAKEUP → dismiss-keyguard → MENU 兜底。
绝不 disconnect,红线。
"""
data = request.json or {}
serial = str(data.get("serial", "")).strip()
if not serial:
return jsonify({"ok": False, "error": "缺少设备 serial"}), 400
try:
if ":" in serial:
adb_connect(serial)
for cmd in (["shell", "input", "keyevent", "224"],
["shell", "wm", "dismiss-keyguard"],
["shell", "input", "keyevent", "82"]):
try:
subprocess.run([ADB_PATH, "-s", serial, *cmd],
capture_output=True, timeout=15)
except Exception:
pass # 单步失败不阻塞,继续下一步
msgs = ["屏幕已点亮"]
if data.get("show"):
# 用设备浏览器打开定位页(设备走 tailnet 访问本机 100.100.10.2:18050)
locate_url = f"http://100.100.10.2:18050/locate?serial={urllib.parse.quote(serial)}"
r = subprocess.run([ADB_PATH, "-s", serial, "shell", "am", "start",
"-a", "android.intent.action.VIEW", "-d", locate_url],
capture_output=True, timeout=20)
out = ((r.stdout or b"") + (r.stderr or b"")).decode("utf-8", errors="replace")
if "error" in out.lower() or "exception" in out.lower():
msgs.append(f"打开定位页失败: {out.strip()[:80]}")
else:
msgs.append("已打开大字定位页(按返回键退出)")
# 记录启动的浏览器包名:结束定位时无论前后台都关闭它。
# 优先从 am start 输出解析 pkg=(浏览器可能尚未到前台,前台检测会扑空)
pkg = ""
m2 = re.search(r"pkg=([\w.]+)", out)
if m2 and m2.group(1) in _BROWSER_PKGS:
pkg = m2.group(1)
else:
time.sleep(1.5) # 等浏览器切到前台再查
pkg = _current_focus_pkg(serial)
if pkg in _BROWSER_PKGS:
with _locate_lock:
_locate_browsers[serial] = pkg
_log.info(f"定位设备 {serial}: {'; '.join(msgs)}")
return jsonify({"ok": True, "msg": f"{serial} {';'.join(msgs)}"})
except Exception as e:
return jsonify({"ok": False, "error": f"定位失败: {e}"}), 502
@bp.route("/api/stop_all", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_stop_all():
stopped = context.mgr.stop_all()
return jsonify({"ok": True, "stopped": stopped})
@bp.route("/api/device/clear_error", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_device_clear_error():
"""清除单台设备的异常状态(error/failed → idle),供设备列表"清除异常"按钮使用。"""
serial = (request.json or {}).get("serial", "")
if not serial:
return jsonify({"ok": False, "error": "缺少 serial"}), 400
if serial in context.mgr.get_running():
return jsonify({"ok": False, "error": "设备正在运行或等待重试,无法清除"}), 400
if not clear_worker_error(serial):
return jsonify({"ok": False, "error": "设备正在运行任务,无法清除"}), 400
_log.info(f"清除设备 {serial} 的异常状态")
return jsonify({"ok": True, "msg": f"已清除 {serial} 的异常状态"})
@bp.route("/api/device/clear_all_errors", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_device_clear_all_errors():
"""一键清除所有异常/失败设备(跳过正在运行/等待重试的)。"""
running = set(context.mgr.get_running())
cleared = clear_all_worker_errors(exclude=running)
_log.info(f"一键清除异常:共清除 {cleared} 台")
return jsonify({"ok": True, "cleared": cleared,
"msg": f"已清除 {cleared} 台设备的异常状态"})
@bp.route("/api/device/screenshot")
@login_required
def api_device_screenshot():
"""获取设备当前画面截图(PNG)。
用 adb exec-out screencap -p,只读操作,不抢占 u2 的 atx-agent 通道,
任务运行中调用安全。直接返回 image/png,前端用 <img> 加载。
?serial=xxx 设备 serial
?t=123 时间戳,避免浏览器缓存(前端自动加)
"""
serial = request.args.get("serial", "")
if not serial:
return jsonify({"ok": False, "error": "缺少 serial"}), 400
ok, data = screenshot(serial)
if ok:
return Response(data, mimetype="image/png",
headers={"Cache-Control": "no-store"})
return jsonify({"ok": False, "error": data}), 500
@bp.route("/api/devices/<serial>/apps")
@login_required
def api_device_apps(serial):
"""获取指定设备上已安装的应用列表(包名 + versionCode + versionName + 路径)。"""
ok, data = list_installed_apps(serial)
if ok:
return jsonify({"ok": True, "apps": data})
return jsonify({"ok": False, "error": data}), 500
# ================== 自定义动作(步骤打包) ==================
@bp.route("/api/screen/stream")
@perm_required(PERM_DEVICES)
def api_screen_stream():
"""远程看屏:MJPEG 实时画面流(multipart/x-mixed-replace)。
?serial=xxx 设备 serial
浏览器 <img> 直接渲染;客户端断开(GeneratorExit)自动停止,不占资源。
设备离线/atx-agent 无响应时流自然结束,前端提示重新连接。
"""
serial = request.args.get("serial", "")
if not serial:
return jsonify({"ok": False, "error": "缺少 serial"}), 400
# 参数:q=JPEG 质量(大屏弹窗用 85 更清晰);fps=目标帧率(0=默认节流)
try:
quality = max(30, min(95, int(request.args.get("q", _SCREEN_JPEG_QUALITY))))
fps = float(request.args.get("fps", 0) or 0)
except ValueError:
quality, fps = _SCREEN_JPEG_QUALITY, 0
gap = (1.0 / fps) if fps > 0 else _SCREEN_FRAME_GAP
def generate():
import io
import uiautomator2 as u2
try:
d = u2.connect(serial)
except Exception as e:
_log.warning(f"远程看屏 {serial} u2 连接失败: {e}")
return
while True:
try:
img = d.screenshot()
if img is None:
break
if img.mode != "RGB":
img = img.convert("RGB")
buf = io.BytesIO()
img.save(buf, "JPEG", quality=quality)
yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n"
+ buf.getvalue() + b"\r\n")
except GeneratorExit:
break
except Exception as e:
_log.debug(f"远程看屏 {serial} 流中断: {e}")
break
time.sleep(gap)
return Response(generate(),
mimetype="multipart/x-mixed-replace; boundary=frame")
_screen_u2_cache = {} # serial -> (ts, device):大屏高频触控复用连接(60s TTL)
def _screen_get_device(serial):
"""远程看屏用的 u2 连接(60s 缓存复用,避免每次 tap/swipe 都重建连接)。
大屏高频操作(点击/滑动/按键)每次 u2.connect 有 1-3s 握手开销(atx-agent
探测),体验卡顿。缓存 60s 内复用;连接失败(设备离线/重启)时清缓存重建,
由调用方把异常转 503。
"""
import time
import uiautomator2 as u2
now = time.time()
cached = _screen_u2_cache.get(serial)
if cached and now - cached[0] < 60:
return cached[1]
d = u2.connect(serial)
_screen_u2_cache[serial] = (now, d)
if len(_screen_u2_cache) > 30: # 惰性清理过期项
for k in list(_screen_u2_cache):
if now - _screen_u2_cache[k][0] >= 60:
del _screen_u2_cache[k]
return d
def _screen_invalidate(serial):
"""u2 调用失败时清掉缓存(设备离线/atx-agent 重启),下次请求重新连接。"""
_screen_u2_cache.pop(serial, None)
@bp.route("/api/screen/thumb")
@perm_required(PERM_DEVICES)
def api_screen_thumb():
"""大屏缩略图:单张 JPEG(360px 宽,质量 55),供监控大屏轮询。
一次性请求(非流),与任务并发安全(与任务截图同走 u2 minicap)。
大屏按需轮询:每设备约 2.5s 一帧,20 台 ≈ 0.2 核 CPU + 100KB/s。
响应头 X-Screen-State: on/off/unknown —— 大屏据此显示「亮屏中/熄屏中」。
"""
serial = request.args.get("serial", "")
if not serial:
return jsonify({"ok": False, "error": "缺少 serial"}), 400
import io
import uiautomator2 as u2
try:
d = u2.connect(serial)
img = d.screenshot()
if img is None:
return jsonify({"ok": False, "error": "截图失败"}), 503
if img.width > 360:
ratio = 360 / img.width
img = img.resize((360, int(img.height * ratio)))
buf = io.BytesIO()
img.convert("RGB").save(buf, "JPEG", quality=55)
return Response(buf.getvalue(), mimetype="image/jpeg",
headers={"Cache-Control": "no-store",
"X-Screen-State": _device_screen_state(serial)})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 503
def _snap_to_clickable(d, x, y):
"""坐标吸附:找包含 (x,y) 的最小可点击元素,返回其 bounds 中心。
AI/触控给的坐标常偏离目标 20-50px(模型视觉定位误差)。dump 当前 UI 树后
遍历 clickable 节点:点击点落在哪个可点元素内就点它的中心——偏了也点得准;
取包含元素中面积最小者(最具体的那个)。点空白处(收键盘等)无包含元素则
原坐标返回。dump 失败也不阻塞,直接原坐标。返回 (cx, cy, snapped, label)。
"""
try:
import xml.etree.ElementTree as ET
xml_str = d.dump_hierarchy()
best = None # (area, cx, cy, label)
for node in ET.fromstring(xml_str).iter("node"):
if node.get("clickable") != "true":
continue
if node.get("enabled") == "false":
continue
m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", node.get("bounds", ""))
if not m:
continue
x1, y1, x2, y2 = map(int, m.groups())
if x1 <= x <= x2 and y1 <= y <= y2:
area = (x2 - x1) * (y2 - y1)
if best is None or area < best[0]:
best = (area, (x1 + x2) // 2, (y1 + y2) // 2,
(node.get("text") or node.get("content-desc") or "")[:40])
if best:
return best[1], best[2], True, best[3]
except Exception:
pass
return x, y, False, ""
@bp.route("/api/screen/tap", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_screen_tap():
"""点击:{serial, x, y, snap?}(设备原生分辨率坐标)。
snap=1 时先吸附:点落在可点击元素内则改点元素中心(MCP/AI 场景用,粗略
坐标也能点准);大屏精确触控不带 snap 保持原行为。
返回 {ok, snapped, x, y, label}。
"""
data = request.json or {}
serial, x, y = data.get("serial", ""), data.get("x"), data.get("y")
snap = int(data.get("snap", 0) or 0) == 1
if not serial or x is None or y is None:
return jsonify({"ok": False, "error": "缺少 serial/x/y"}), 400
try:
d = _screen_get_device(serial)
sx, sy = int(x), int(y)
label = ""
snapped = False
if snap:
sx, sy, snapped, label = _snap_to_clickable(d, sx, sy)
d.click(sx, sy)
return jsonify({"ok": True, "snapped": snapped, "x": sx, "y": sy,
"label": label})
except Exception as e:
_screen_invalidate(serial)
return jsonify({"ok": False, "error": f"点击失败: {e}"}), 503
@bp.route("/api/screen/tap_text", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_screen_tap_text():
"""按屏幕文字点击:{serial, text}——找到显示该文字的位置并点中心(子串匹配)。
两步:① UI 树 textContains/descriptionContains 命中 → 点元素中心(原生控件);
② 未命中 → 截图 OCR 找文字中心(WebView/图片/画布渲染的文字)。
返回 {ok, found, method: ui|ocr, matched, x, y};found=false 表示屏幕确实
没有该文字(业务结果非设备错误);OCR 不可用且 UI 未命中时也返回 found=false。
"""
data = request.json or {}
serial = (data.get("serial") or "").strip()
text = (data.get("text") or "").strip()
if not serial or not text:
return jsonify({"ok": False, "error": "缺少 serial/text"}), 400
if len(text) > 100:
return jsonify({"ok": False, "error": "文字过长(≤100 字符)"}), 400
try:
d = _screen_get_device(serial)
# ① UI 树:text / content-desc 子串匹配(原生控件最快最准)
for kw in ({"textContains": text}, {"descriptionContains": text}):
try:
el = d(**kw)
if el.exists(timeout=1.5):
# u2 版本差异:3.x 部分版本 bounds 是方法(el.bounds())
b = el.bounds() if callable(el.bounds) else el.bounds
el.click()
return jsonify({"ok": True, "found": True, "method": "ui",
"matched": text,
"x": (b[0] + b[2]) // 2, "y": (b[1] + b[3]) // 2})
except Exception:
continue
# ② OCR 兜底:截图找文字中心(UI 树没有的渲染文字)
img = d.screenshot()
if img is not None:
from core.ocr import find_on_screen
found, center, matched = find_on_screen(img, text)
if found:
d.click(*center)
return jsonify({"ok": True, "found": True, "method": "ocr",
"matched": matched,
"x": center[0], "y": center[1]})
return jsonify({"ok": True, "found": False, "method": "",
"matched": "", "x": 0, "y": 0})
except Exception as e:
_screen_invalidate(serial)
return jsonify({"ok": False, "error": f"文字点击失败: {e}"}), 503
@bp.route("/api/screen/swipe", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_screen_swipe():
"""滑动:{serial, x1, y1, x2, y2, duration?}(设备原生分辨率坐标)。"""
data = request.json or {}
serial = data.get("serial", "")
x1, y1, x2, y2 = (data.get(k) for k in ("x1", "y1", "x2", "y2"))
if not serial or None in (x1, y1, x2, y2):
return jsonify({"ok": False, "error": "缺少 serial/x1/y1/x2/y2"}), 400
try:
_screen_get_device(serial).swipe(int(x1), int(y1), int(x2), int(y2),
duration=float(data.get("duration", 0.2)))
return jsonify({"ok": True})
except Exception as e:
_screen_invalidate(serial)
return jsonify({"ok": False, "error": f"滑动失败: {e}"}), 503
_SCREEN_KEYS = {"back", "home", "recent", "menu", "power", "volume_up",
"volume_down", "enter", "delete", "search", "camera"}
@bp.route("/api/screen/key", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_screen_key():
"""按键:{serial, key}(back/home/recent/menu/power 等,见 _SCREEN_KEYS)。"""
data = request.json or {}
serial, key = data.get("serial", ""), (data.get("key") or "").strip().lower()
if not serial or key not in _SCREEN_KEYS:
return jsonify({"ok": False, "error": "缺少 serial 或不支持的按键"}), 400
try:
_screen_get_device(serial).press(key)
return jsonify({"ok": True})
except Exception as e:
_screen_invalidate(serial)
return jsonify({"ok": False, "error": f"按键失败: {e}"}), 503
@bp.route("/api/screen/text", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_screen_text():
"""输入文字:{serial, text}(需焦点在输入框,u2 send_keys)。"""
data = request.json or {}
serial, text = data.get("serial", ""), (data.get("text") or "").strip()
if not serial or not text:
return jsonify({"ok": False, "error": "缺少 serial/text"}), 400
try:
_screen_get_device(serial).send_keys(text)
return jsonify({"ok": True})
except Exception as e:
return jsonify({"ok": False, "error": f"输入失败: {e}"}), 503
@bp.route("/api/screen/size")
@perm_required(PERM_DEVICES)
def api_screen_size():
"""屏幕原生分辨率(只读,供 MCP 坐标换算:截图是缩放图,操作需原生坐标)。
返回 {ok, width, height}(如 1080x2400);离线/不可达 → 503。
"""
serial = request.args.get("serial", "")
if not serial:
return jsonify({"ok": False, "error": "缺少 serial"}), 400
try:
d = _screen_get_device(serial)
w, h = d.window_size()
return jsonify({"ok": True, "width": w, "height": h})
except Exception as e:
_screen_invalidate(serial)
return jsonify({"ok": False, "error": f"获取分辨率失败: {e}"}), 503
# ================== API:应用管理(APK 上传/安装)==================
def _current_focus_pkg(serial):
"""取设备当前前台包名(dumpsys window 解析,失败返回空串)。"""
try:
r = subprocess.run([ADB_PATH, "-s", serial, "shell", "dumpsys", "window"],
capture_output=True, timeout=20)
out = (r.stdout or b"").decode("utf-8", errors="replace")
m = re.search(r"mCurrentFocus=.*?([\w.]+)/", out)
return m.group(1) if m else ""
except Exception:
return ""
_screen_u2_cache = {} # serial -> (ts, device):大屏高频触控复用连接(60s TTL)
def _screen_get_device(serial):
"""远程看屏用的 u2 连接(60s 缓存复用,避免每次 tap/swipe 都重建连接)。
大屏高频操作(点击/滑动/按键)每次 u2.connect 有 1-3s 握手开销(atx-agent
探测),体验卡顿。缓存 60s 内复用;连接失败(设备离线/重启)时清缓存重建,
由调用方把异常转 503。
"""
import time
import uiautomator2 as u2
now = time.time()
cached = _screen_u2_cache.get(serial)
if cached and now - cached[0] < 60:
return cached[1]
d = u2.connect(serial)
_screen_u2_cache[serial] = (now, d)
if len(_screen_u2_cache) > 30: # 惰性清理过期项
for k in list(_screen_u2_cache):
if now - _screen_u2_cache[k][0] >= 60:
del _screen_u2_cache[k]
return d
def _screen_invalidate(serial):
"""u2 调用失败时清掉缓存(设备离线/atx-agent 重启),下次请求重新连接。"""
_screen_u2_cache.pop(serial, None)
+98
View File
@@ -0,0 +1,98 @@
"""Tailscale 管理 API(仅管理员)。"""
import re
from flask import Blueprint, jsonify, request
from core import tailscale_client
from core.tailscale_client import TailscaleError
from core.logger import get_logger
_log = get_logger("web")
from web.auth import admin_required
bp = Blueprint("tailscale", __name__)
@bp.route("/api/tailscale/status")
@admin_required
def api_tailscale_status():
"""Tailscale 管理配置状态(API key / tailnet 是否已配置)。"""
c = tailscale_client.tailscale
return jsonify({"ok": True, "configured": c.is_configured(),
"hint": c.config_hint() if not c.is_configured() else ""})
@bp.route("/api/tailscale/devices")
@admin_required
def api_tailscale_devices():
"""列出 tailnet 全部设备。"""
try:
return jsonify({"ok": True, "devices": tailscale_client.tailscale.list_devices()})
except TailscaleError as e:
return jsonify({"ok": False, "error": str(e)}), 502
@bp.route("/api/tailscale/devices/<device_id>", methods=["POST"])
@admin_required
def api_tailscale_device_update(device_id):
"""更新设备:按传入字段分发到专属端点(改名 /name、授权 /authorized、密钥 /key)。"""
data = request.json or {}
c = tailscale_client.tailscale
try:
if "name" in data:
c.set_device_name(device_id, data.get("name"))
if "authorized" in data:
c.set_device_authorized(device_id, data.get("authorized"))
if "key_expiry_disabled" in data:
c.set_device_key_expiry(device_id, data.get("key_expiry_disabled"))
_log.info(f"Tailscale 设备更新 {device_id}: {data}")
return jsonify({"ok": True, "msg": "设备已更新"})
except TailscaleError as e:
return jsonify({"ok": False, "error": str(e)}), 502
@bp.route("/api/tailscale/devices/<device_id>/ip", methods=["POST"])
@admin_required
def api_tailscale_device_ip(device_id):
"""设置设备 IPv4 地址(未公开端点,实测可用)。
⚠ 改 IP 会断开设备当前 tailscale 会话,且平台设备池 serial 随之变化,
改完需同步更新 STF 设备池(分组/任务目标里的旧 IP 会失效)。
"""
data = request.json or {}
ipv4 = (data.get("ipv4") or "").strip()
if not ipv4:
return jsonify({"ok": False, "error": "缺少 ipv4"}), 400
try:
tailscale_client.tailscale.set_device_ip(device_id, ipv4)
_log.info(f"Tailscale 设备 {device_id} IP 已设置为 {ipv4}")
return jsonify({"ok": True, "msg": f"设备 IP 已设置为 {ipv4}"})
except TailscaleError as e:
return jsonify({"ok": False, "error": str(e)}), 502
@bp.route("/api/tailscale/devices/<device_id>", methods=["DELETE"])
@admin_required
def api_tailscale_device_delete(device_id):
"""从 tailnet 移除设备。"""
try:
tailscale_client.tailscale.delete_device(device_id)
_log.info(f"Tailscale 设备已移除: {device_id}")
return jsonify({"ok": True, "msg": "设备已从 tailnet 移除"})
except TailscaleError as e:
return jsonify({"ok": False, "error": str(e)}), 502
@bp.route("/api/tailscale/authkey", methods=["POST"])
@admin_required
def api_tailscale_authkey():
"""生成设备接入 auth key(key 只显示一次)。"""
data = request.json or {}
try:
# Tailscale 要求 description 仅 ASCII(中文会 400)
desc = re.sub(r"[^\x20-\x7e]", "", (data.get("description") or "").strip())
key = tailscale_client.tailscale.create_auth_key(
description=desc or "auto_control",
reusable=bool(data.get("reusable", False)),
ephemeral=bool(data.get("ephemeral", False)),
preauthorized=data.get("preauthorized", True),
expiry_seconds=int(data.get("expiry_seconds", 3600)))
_log.info(f"Tailscale auth key 已生成: {key['id']}")
return jsonify({"ok": True, "key": key["key"], "id": key["id"],
"expires": key["expires"]})
except TailscaleError as e:
return jsonify({"ok": False, "error": str(e)}), 502
+339
View File
@@ -0,0 +1,339 @@
"""任务域 API:任务计划/分组/自定义动作/元素抓取/步骤测试。"""
import re
import time
import uuid
import threading
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError as FuturesTimeout
from flask import (Blueprint, jsonify, request, Response)
from flask_login import login_required
from core import device_pool, uiauto_helper
from core.adb_helper import adb_connect_light
from core.models import db, DeviceGroup, TaskJob, CustomAction
from core.logger import get_logger
_log = get_logger("web")
from web import context
from web.auth import perm_required, PERM_DEVICES, PERM_TASKS
from tasks import list_task_types, get_task_class
from web.common import _merged_device_list
bp = Blueprint("tasks", __name__)
@bp.route("/api/task_types")
@login_required
def api_task_types():
return jsonify({"ok": True, "task_types": list_task_types()})
@bp.route("/api/actions")
@login_required
def api_actions():
"""返回指定任务类型支持的专属操作。"""
task_type = request.args.get("task_type", "")
task_cls = get_task_class(task_type)
if not task_cls:
return jsonify({"ok": False, "error": "未知任务类型", "actions": []})
actions = task_cls.list_action_types()
return jsonify({"ok": True, "actions": actions})
@bp.route("/api/devices")
@login_required
def api_devices():
"""返回所有在线设备 serial(供分组表单勾选用)。"""
try:
return jsonify({"ok": True, "devices": context.mgr.list_all_serials()})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
# ================== API:任务计划 CRUD ==================
def _job_next_run(job):
"""任务下次执行时间,格式化为 "YYYY-MM-DD HH:MM"(Flask 序列化 aware datetime 会变 GMT 格式)。"""
nr = context.mgr.next_run_of(job)
return nr.strftime("%Y-%m-%d %H:%M") if nr else None
@bp.route("/api/jobs")
@login_required
def api_jobs_list():
jobs = []
for j in context.mgr.jobs.values():
d = j.to_dict()
d["next_run"] = _job_next_run(j)
jobs.append(d)
return jsonify({"ok": True, "jobs": jobs,
"task_types": list_task_types()})
@bp.route("/api/jobs", methods=["POST"])
@perm_required(PERM_TASKS)
def api_jobs_create():
data = request.json or {}
name = (data.get("name") or "").strip()
if not name:
return jsonify({"ok": False, "error": "任务名不能为空"}), 400
task_type = data.get("task_type", "douyin_nurture")
if not get_task_class(task_type):
return jsonify({"ok": False, "error": f"未知任务类型: {task_type}"}), 400
job = context.mgr.add_job(
name=name, task_type=task_type,
target=data.get("target", {"mode": "all"}),
params=data.get("params", {}),
schedule=data.get("schedule", {"mode": "once"}),
retry=data.get("retry", {"max_attempts": 1, "delay": 60}),
enabled=data.get("enabled", True))
d = job.to_dict()
d["next_run"] = _job_next_run(job)
return jsonify({"ok": True, "msg": "任务已创建", "job": d})
@bp.route("/api/jobs/<job_id>", methods=["PUT"])
@perm_required(PERM_TASKS)
def api_jobs_update(job_id):
job = context.mgr.jobs.get(job_id)
if not job:
return jsonify({"ok": False, "error": "任务不存在"}), 404
data = request.json or {}
fields = {}
for k in ("name", "task_type", "target", "params", "schedule", "retry", "enabled"):
if k in data:
fields[k] = data[k]
if "task_type" in fields and not get_task_class(fields["task_type"]):
return jsonify({"ok": False, "error": f"未知任务类型: {fields['task_type']}"}), 400
context.mgr.update_job(job_id, **fields)
d = job.to_dict()
d["next_run"] = _job_next_run(job)
return jsonify({"ok": True, "msg": "任务已更新", "job": d})
@bp.route("/api/jobs/<job_id>", methods=["DELETE"])
@perm_required(PERM_TASKS)
def api_jobs_delete(job_id):
if context.mgr.delete_job(job_id):
return jsonify({"ok": True, "msg": "任务已删除"})
return jsonify({"ok": False, "error": "任务不存在"}), 404
@bp.route("/api/jobs/<job_id>/run", methods=["POST"])
@perm_required(PERM_TASKS)
def api_jobs_run(job_id):
return jsonify(context.mgr.run_job_now(job_id))
@bp.route("/api/jobs/<job_id>/toggle", methods=["POST"])
@perm_required(PERM_TASKS)
def api_jobs_toggle(job_id):
enabled = (request.json or {}).get("enabled", True)
job = context.mgr.toggle_job(job_id, enabled)
if not job:
return jsonify({"ok": False, "error": "任务不存在"}), 404
return jsonify({"ok": True, "msg": f"任务已{'启用' if enabled else '停用'}"})
# ================== API:设备分组 CRUD ==================
@bp.route("/api/groups")
@login_required
def api_groups_list():
return jsonify({"ok": True, "groups": [g.to_dict() for g in context.mgr.groups.values()]})
@bp.route("/api/groups", methods=["POST"])
@perm_required(PERM_TASKS)
def api_groups_create():
data = request.json or {}
name = (data.get("name") or "").strip()
if not name:
return jsonify({"ok": False, "error": "分组名不能为空"}), 400
if name in context.mgr.groups:
return jsonify({"ok": False, "error": "分组名已存在"}), 400
context.mgr.add_group(name, data.get("serials", []), data.get("description", ""))
return jsonify({"ok": True, "msg": "分组已创建"})
@bp.route("/api/groups/<name>", methods=["PUT"])
@perm_required(PERM_TASKS)
def api_groups_update(name):
if name not in context.mgr.groups:
return jsonify({"ok": False, "error": "分组不存在"}), 404
data = request.json or {}
context.mgr.update_group(name,
serials=data.get("serials"),
description=data.get("description"))
return jsonify({"ok": True, "msg": "分组已更新"})
@bp.route("/api/groups/<name>", methods=["DELETE"])
@perm_required(PERM_TASKS)
def api_groups_delete(name):
if context.mgr.delete_group(name):
return jsonify({"ok": True, "msg": "分组已删除"})
return jsonify({"ok": False, "error": "分组不存在"}), 404
# ================== API:用户管理 CRUD ==================
@bp.route("/api/custom_actions")
@login_required
def api_custom_actions_list():
rows = CustomAction.query.order_by(CustomAction.created_at.desc()).all()
return jsonify({"ok": True, "actions": [r.to_dict() for r in rows]})
@bp.route("/api/custom_actions", methods=["POST"])
@perm_required(PERM_TASKS)
def api_custom_actions_create():
data = request.json or {}
name = (data.get("name") or "").strip()
if not name:
return jsonify({"ok": False, "error": "动作名不能为空"}), 400
steps = data.get("steps", [])
if not steps:
return jsonify({"ok": False, "error": "至少需要1个步骤"}), 400
row = CustomAction(id=uuid.uuid4().hex[:8], name=name,
icon=data.get("icon") or "📦",
created_at=datetime.now().strftime("%Y-%m-%d %H:%M"))
row.set_steps(steps)
db.session.add(row)
db.session.commit()
return jsonify({"ok": True, "msg": "动作已保存", "action": row.to_dict()})
@bp.route("/api/custom_actions/<action_id>", methods=["PUT"])
@perm_required(PERM_TASKS)
def api_custom_actions_update(action_id):
row = CustomAction.query.get(action_id)
if not row:
return jsonify({"ok": False, "error": "动作不存在"}), 404
data = request.json or {}
if "name" in data:
name = (data["name"] or "").strip()
if not name:
return jsonify({"ok": False, "error": "动作名不能为空"}), 400
row.name = name
if "icon" in data:
row.icon = data["icon"] or "📦"
if "steps" in data:
if not data["steps"]:
return jsonify({"ok": False, "error": "至少需要1个步骤"}), 400
row.set_steps(data["steps"])
db.session.commit()
return jsonify({"ok": True, "msg": "已更新", "action": row.to_dict()})
@bp.route("/api/custom_actions/<action_id>", methods=["DELETE"])
@perm_required(PERM_TASKS)
def api_custom_actions_delete(action_id):
row = CustomAction.query.get(action_id)
if not row:
return jsonify({"ok": False, "error": "动作不存在"}), 404
db.session.delete(row)
db.session.commit()
return jsonify({"ok": True, "msg": "已删除"})
@bp.route("/api/uiauto/elements")
@perm_required(PERM_DEVICES)
def api_uiauto_elements():
"""获取设备当前 UI 元素树(供步骤编辑器"抓取元素"用)。
依赖本地运行的 uiautodev 服务(端口 20242)。
?serial=xxx 设备 serial
返回:
200 — {ok:true, elements:[...]}
503 — {ok:false, error:"..."}(uiauto2 未启动)
"""
serial = request.args.get("serial", "")
if not serial:
return jsonify({"ok": False, "error": "缺少 serial"}), 400
ok, data = uiauto_helper.get_elements(serial)
if ok:
return jsonify({"ok": True, "elements": data})
return jsonify({"ok": False, "error": data}), 503
@bp.route("/api/uiauto/status")
@login_required
def api_uiauto_status():
"""探测 uiauto2 本地服务是否运行(前端按钮禁启用)。"""
return jsonify({"ok": True, "running": uiauto_helper.is_running()})
@bp.route("/api/steps/test", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_steps_test():
"""测试单个步骤:在指定设备上试执行,验证选择器是否命中(步骤编辑器"测试此步骤")。
请求: {"serial": "100.100.10.11:5555", "step": {"type": "click", "params": {...}}}
只读连接(adb connect + u2),与运行中任务互不干扰。
返回 result: "命中" / "未找到" / "已执行"。
"""
data = request.json or {}
serial = (data.get("serial") or "").strip()
step = data.get("step") or {}
if not serial:
return jsonify({"ok": False, "error": "缺少 serial"}), 400
if not step.get("type"):
return jsonify({"ok": False, "error": "缺少步骤类型"}), 400
from tasks.generic.task import test_step
ok, msg, result = test_step(serial, step)
res_txt = {True: "命中", False: "未找到"}.get(result, "已执行")
_log.info(f"测试步骤 {step.get('type')} @ {serial}: {res_txt} ({msg})")
return jsonify({"ok": ok, "msg": msg, "result": res_txt})
@bp.route("/api/uiauto/devices")
@perm_required(PERM_DEVICES)
def api_uiauto_devices():
"""获取可选设备列表(抓取元素用)。
uiautodev 只认识本地 adb 已连接的设备;设备池里在线但未本地连接的设备
在这里补全,并先做一次轻量 adb connect(单次尝试,绝不 kill-server),
让它们可被 uiautodev 抓取。
"""
ok, data = uiauto_helper.list_devices()
if not ok:
return jsonify({"ok": False, "error": data}), 503
seen = {d.get("serial") for d in data}
try:
online = set(device_pool.list_online())
pool = [s for s in device_pool.list_configured() if s in online]
except Exception:
pool = []
added = 0
for serial in pool:
if not serial or serial in seen:
continue
# 轻量连接(单次尝试,不重试不 kill-server);连不上也照样列出,
# 前端抓取时会报明确错误,不影响其他设备的连接
adb_connect_light(serial)
data.append({
"serial": serial,
"model": "",
"product": "",
"name": serial,
"status": "device",
"enabled": True,
})
seen.add(serial)
added += 1
if added:
_log.info(f"抓取元素设备列表补全 {added} 台在线设备: {list(seen)}")
return jsonify({"ok": True, "devices": data})
@bp.route("/api/uiauto/screenshot")
@perm_required(PERM_DEVICES)
def api_uiauto_screenshot():
"""通过 uiauto2 获取设备截图(供抓取元素时显示设备画面)。
?serial=xxx 设备 serial
直接返回 image/jpeg,前端用 <img> 加载。
"""
serial = request.args.get("serial", "")
if not serial:
return jsonify({"ok": False, "error": "缺少 serial"}), 400
ok, data = uiauto_helper.get_screenshot(serial)
if ok:
return Response(data, mimetype="image/jpeg",
headers={"Cache-Control": "no-store"})
return jsonify({"ok": False, "error": data}), 503
# ================== API:远程看屏(阶段 2) ==================
# MJPEG 实时画面流 + u2 触控注入。数据源 u2(atx-agent minicap 截图,单帧 ~200ms),
# 零新依赖(原计划 ws-scrcpy 不在 npm 分发,GitHub 下载在国内不可靠,改自建)。
# 坐标约定:前端以 <img> 显示尺寸归一化后映射到设备原生分辨率(naturalWidth/Height)。
_SCREEN_JPEG_QUALITY = 65
_SCREEN_FRAME_GAP = 0.05 # 帧间最小间隔(秒),防止空转烧 CPU
def _job_next_run(job):
"""任务下次执行时间,格式化为 "YYYY-MM-DD HH:MM"(Flask 序列化 aware datetime 会变 GMT 格式)。"""
nr = context.mgr.next_run_of(job)
return nr.strftime("%Y-%m-%d %H:%M") if nr else None
+249
View File
@@ -0,0 +1,249 @@
"""工具域 API:adb 终端/剪贴板注入/应用版本。"""
import re
import subprocess
import shlex
from concurrent.futures import ThreadPoolExecutor, as_completed
from flask import Blueprint, jsonify, request
from core import device_pool
from core.adb_helper import adb_connect, adb_connect_light, _ADB_LOCK, _adb
from config import ADB_PATH
from core.logger import get_logger
from web import context
from web.auth import admin_required, perm_required
from web.common import _merged_device_list
_log = get_logger("web")
bp = Blueprint("tools", __name__)
# adb 终端安全红线:绝不 kill-server / 绝不 disconnect IP:5555——
# 共享 adb transport,断开会让全部设备连接重建(历史与 STF provider 共用)。
_ADB_BLOCKED_PATTERNS = ("kill-server", "disconnect")
@bp.route("/api/adb/devices")
@admin_required
def api_adb_devices():
"""维护终端设备列表(仅管理员):本地 adb 已连接 + 设备池(SQLite)。
状态:device/offline=本机 adb 实际状态;pool=设备池已配置但本机未连接。
供终端设备选择器使用——选中后自动附加 `-s <serial>`,
离线/未连接的设备也可选,配合"重连设备"按钮恢复。
"""
return jsonify({"ok": True, "devices": _merged_device_list()})
@bp.route("/api/adb/cmd", methods=["POST"])
@admin_required
def api_adb_cmd():
"""维护终端:执行 adb 命令(仅管理员)。
请求: {"cmd": "adb -s 100.100.10.11:5555 shell ls /sdcard"}
用平台 adb 二进制执行(config.ADB_PATH),带 20s 超时。
"""
data = request.json or {}
cmd = (data.get("cmd") or "").strip()
if not cmd:
return jsonify({"ok": False, "error": "命令不能为空"}), 400
if _blocked_adb_cmd(cmd):
return jsonify({"ok": False,
"error": "禁止执行 kill-server / disconnect(会中断 STF 设备监控,影响所有任务)"}), 400
tokens = shlex.split(cmd)
if tokens and tokens[0] == "adb":
tokens = tokens[1:]
if not tokens:
return jsonify({"ok": False, "error": "命令不能为空"}), 400
try:
# 与 worker 的 adb 调用共用锁,避免并发操作同一 adb server
with _ADB_LOCK:
r = subprocess.run([ADB_PATH, *tokens], capture_output=True, timeout=20)
out = (r.stdout or b"").decode("utf-8", errors="replace")
err = (r.stderr or b"").decode("utf-8", errors="replace")
_log.info(f"维护终端执行 adb {' '.join(tokens)} -> exit {r.returncode}")
return jsonify({"ok": True, "stdout": out, "stderr": err, "code": r.returncode})
except subprocess.TimeoutExpired:
return jsonify({"ok": False, "error": "命令执行超时(20s)"}), 408
except Exception as e:
_log.warning(f"维护终端 adb 执行异常: {e}")
return jsonify({"ok": False, "error": f"执行失败: {e}"}), 500
@bp.route("/api/tools/clipboard/set", methods=["POST"])
@admin_required
def api_tools_clipboard_set():
"""工具-剪贴板注入:把指定文字写入一台或多台设备的剪贴板。
请求: {"serials": ["100.100.10.11:5555", ...], "text": "要注入的文字"}
设备来源与维护终端一致:本地 adb 已连接设备(含 USB)+ 设备池。
实现:u2 jsonrpc.setClipboard(实测 cmd clipboard 在 MIUI 上不存在)。
"""
data = request.json or {}
serials = data.get("serials") or []
text = (data.get("text") or "").strip()
if not isinstance(serials, list) or not serials:
return jsonify({"ok": False, "error": "未选择设备"}), 400
if not text:
return jsonify({"ok": False, "error": "注入内容不能为空"}), 400
results = {}
for serial in serials:
ok, msg = _set_device_clipboard(serial, text)
results[serial] = {"ok": ok, "msg": msg}
ok_count = sum(1 for v in results.values() if v["ok"])
fail_count = len(results) - ok_count
return jsonify({"ok": True, "results": results,
"ok_count": ok_count, "fail_count": fail_count,
"error": None if ok_count == len(results) else f"{fail_count} 台设备注入失败"})
def _set_device_clipboard(serial, text):
"""向单台设备注入剪贴板文字(ClipInject 通道,见 core.clipboard_helper)。"""
from core.clipboard_helper import inject_clipboard
return inject_clipboard(serial, text)
def _app_ver_on_device(serial, pkg):
"""单设备包版本查询:pm path 检查安装 → dumpsys 取 versionName/versionCode。
IP:port 设备先轻量 adb connect(单次尝试,绝不 disconnect,红线)。
查询是只读 shell 命令,无需 adb server 锁(锁只保护 connect/kill-server 类操作)。
"""
if ":" in serial:
try:
adb_connect_light(serial)
except Exception as e:
return {"installed": False, "error": f"adb 连接失败: {e}"}
try:
r = subprocess.run([ADB_PATH, "-s", serial, "shell", "pm", "path", pkg],
capture_output=True, timeout=15)
if b"package:" not in (r.stdout or b""):
return {"installed": False, "version_name": "", "version_code": ""}
r2 = subprocess.run([ADB_PATH, "-s", serial, "shell", "dumpsys", "package", pkg],
capture_output=True, timeout=20)
out2 = (r2.stdout or b"").decode("utf-8", errors="replace")
vm = re.search(r"versionName=(\S+)", out2)
vc = re.search(r"versionCode=(\d+)", out2)
return {"installed": True,
"version_name": vm.group(1) if vm else "",
"version_code": vc.group(1) if vc else ""}
except subprocess.TimeoutExpired:
return {"installed": False, "error": "查询超时"}
except Exception as e:
return {"installed": False, "error": str(e)}
@bp.route("/api/tools/appver", methods=["POST"])
@admin_required
def api_tools_appver():
"""工具-应用版本管理:查询所有设备上指定包名的安装情况与版本号。
请求: {"pkg": "com.ss.android.ugc.aweme"}
设备来源:本地 adb(含 USB)+ 设备池,并发查询(最多 10 台同时)。
"""
pkg = (request.json or {}).get("pkg", "").strip()
if not re.match(r"^[A-Za-z0-9_.]+$", pkg or ""):
return jsonify({"ok": False, "error": "包名格式不正确(仅字母/数字/._)"}), 400
# 只查在线设备:池内离线条目(如陈旧记录)不连,避免 connect 重试拖慢查询
try:
online = set(device_pool.list_online())
devices = [d for d in _merged_device_list() if d["serial"] in online]
except Exception:
devices = []
if not devices:
return jsonify({"ok": False, "error": "无在线设备"}), 404
results = {}
with ThreadPoolExecutor(max_workers=min(10, len(devices))) as pool:
futures = {pool.submit(_app_ver_on_device, d["serial"], pkg): d for d in devices}
for fut in as_completed(futures, timeout=90):
d = futures[fut]
try:
results[d["serial"]] = fut.result()
except Exception as e:
results[d["serial"]] = {"installed": False, "error": str(e)}
fail = sum(1 for v in results.values() if v.get("error"))
return jsonify({"ok": True, "results": results, "fail": fail,
"total": len(results)})
def _blocked_adb_cmd(cmd):
"""命中红线的 adb 命令(kill-server / disconnect)直接拒绝。"""
low = cmd.lower()
return any(p in low for p in _ADB_BLOCKED_PATTERNS)
def _set_device_clipboard(serial, text):
"""向单台设备注入剪贴板文字(ClipInject APK:透明 Activity 聚焦后写入)。
Android 10+ 禁止后台(atx-agent/shell)写剪贴板,u2.set_clipboard 在
Android 12/13 上调用"成功"但内容被系统静默丢弃。ClipInject
(com.example.clipinject)通过 am start 启动透明 Activity,窗口聚焦后
setPrimaryClip,shell 启动 Activity 不受后台启动限制(广播方式在 MIUI
会被 Background activity start 拦截,必须用 am start)。
- IP:port 设备:先 adb connect(已连接自动跳过;绝不 disconnect,红线)
- 设备未装 ClipInject:am start 返回 unable to resolve Intent,明确报错
- 注入后 u2 读回比对验证(后台读剪贴板实测可用),不一致也明确报错
"""
import base64
import time
import uiautomator2 as u2
if ":" in serial:
try:
adb_connect(serial)
except Exception as e:
return False, f"adb 连接失败: {e}"
b64 = base64.b64encode(text.encode("utf-8")).decode()
# 1. am start 透明 Activity 写剪贴板(shell 身份启动,不受后台启动限制)
try:
r = subprocess.run(
[ADB_PATH, "-s", serial, "shell", "am", "start",
"-n", "com.example.clipinject/.ClipActivity",
"--es", "text_b64", b64],
capture_output=True, timeout=30)
out = ((r.stdout or b"") + (r.stderr or b"")).decode(errors="replace")
if "does not exist" in out or "unable to resolve Intent" in out:
return False, ("设备未安装 ClipInject(com.example.clipinject),"
f"请先安装: {out.strip()[:100]}")
if "Error" in out or ("Starting" not in out and r.returncode != 0):
return False, f"am start 失败: {out.strip()[:120]}"
except subprocess.TimeoutExpired:
return False, "am start 超时(adb 无响应)"
except Exception as e:
return False, f"{type(e).__name__}: {str(e)[:120]}"
# 2. 读回验证(等透明 Activity 完成写入;u2 读剪贴板在 Android 12 实测可用)
time.sleep(0.5)
try:
with ThreadPoolExecutor(max_workers=1) as pool:
d = pool.submit(u2.connect, serial).result(timeout=30)
with ThreadPoolExecutor(max_workers=1) as pool:
got = pool.submit(lambda: d.clipboard).result(timeout=15)
if got == text:
return True, "已注入"
return False, f"剪贴板读回不一致(写入可能被拒): {str(got)[:60]!r}"
except Exception as e:
# 读回不可用(旧版 u2/连接失败)时按写入成功处理,避免误报,但带上具体原因
return True, f"已注入(读回验证不可用: {type(e).__name__}: {str(e)[:80]})"
def _app_ver_on_device(serial, pkg):
"""单设备包版本查询:pm path 检查安装 → dumpsys 取 versionName/versionCode。
IP:port 设备先轻量 adb connect(单次尝试,绝不 disconnect,红线)。
查询是只读 shell 命令,无需 adb server 锁(锁只保护 connect/kill-server 类操作)。
"""
if ":" in serial:
try:
adb_connect_light(serial)
except Exception as e:
return {"installed": False, "error": f"adb 连接失败: {e}"}
try:
r = subprocess.run([ADB_PATH, "-s", serial, "shell", "pm", "path", pkg],
capture_output=True, timeout=15)
if b"package:" not in (r.stdout or b""):
return {"installed": False, "version_name": "", "version_code": ""}
r2 = subprocess.run([ADB_PATH, "-s", serial, "shell", "dumpsys", "package", pkg],
capture_output=True, timeout=20)
out2 = (r2.stdout or b"").decode("utf-8", errors="replace")
vm = re.search(r"versionName=(\S+)", out2)
vc = re.search(r"versionCode=(\d+)", out2)
return {"installed": True,
"version_name": vm.group(1) if vm else "",
"version_code": vc.group(1) if vc else ""}
except subprocess.TimeoutExpired:
return {"installed": False, "error": "查询超时"}
except Exception as e:
return {"installed": False, "error": str(e)}
+91 -1219
View File
File diff suppressed because it is too large Load Diff