feat: 监控页按名称排序 + 按钮改「看屏 / 打开设备端 Agent」;设备名匹配补 USB 设备

用户反馈的三件事:

1) **监控页设备排序**改成按**名称**(A01/A02/…),没名字的排最后按地址。
   之前是 adb 返回的顺序,看着像按 IP 排,同型号多台时对不上号。
   排序放在 `/api/status`(后端),大屏页一并受益。

2) **操作按钮改名 + 换实现**:
   - 「截图」→「**看屏**」(行为不变,就是查看设备实时画面)
   - 「定位」→「**打开设备端 Agent**」:改成让设备上的 Agent 弹身份大字页
     (名称/IP/平台地址),替代原来"推 HTML 让设备浏览器打开定位页"那套
     (那套要设备装浏览器、页面还依赖设备能访问平台,backlog A1/A2 一直没修)
     * 新接口 `POST /api/agent/show-info {serial, close?}`;没装 Agent 时明确报出来
     * **先点亮屏幕 + 解除锁屏再拉起** —— 设备息屏时 am start 会把大字页开在锁屏后面,
       人看到的还是一片黑(实测踩过)
     * 平台地址取操作者当前访问地址;若是 localhost 则换成本机局域网地址
       (设备上显示 127.0.0.1 毫无意义)

3) **安装应用的选择设备界面仍显示裸序列号**:USB 设备在 adb 里的 serial 就是
   `ro.serialno`,等于池里那条网络记录的**指纹** —— 之前只按 serial 匹配,插着 USB
   时会冒出一个没名字的 `s8o7nrt8pbzhfadq`。现在按 serial 或指纹任一命中都取名称,
   三处(剪贴板注入 / 安装弹窗 / 安装进度表)统一。

顺带修掉几个自己踩出来的坑:
- `device_pool.list_configured()` 返回的是 **serial 字符串列表**,我当成字典用了
  (`.get("serial")` 抛异常被 except 吞掉 → 名字全空)→ 改用 `list_devices()`
- `web/device_agent_api.py` 用了 `PERM_DEVICES` 但没导入 → 服务直接起不来(502)
- Agent 自检里的版本号是写死的常量(升到 1.4 还显示 1.0)→ 改成读包信息
- `doc/API.md` 章节编号(插了一节没把后面整体下移,出现两个 §16)

验证:安装弹窗里 USB 那台现在显示「A07」(指纹匹配);监控页按钮为
「看屏 / 打开设备端 Agent」;对装/未装 Agent 的设备分别得到大字页 / 明确报错;
大字页实测显示 A07 + 192.168.20.250:18050 + Agent v1.4。
This commit is contained in:
2026-09-13 17:18:30 +08:00
parent 3f041668ec
commit 3422b4f265
7 changed files with 150 additions and 41 deletions
+7 -3
View File
@@ -277,12 +277,16 @@ class ApkManager:
# 设备名(平台给设备起的名字,如 A08)—— 进度表里显示这个而不是型号:
# 8 台同型号时型号根本区分不了,名字才行
pool_names = {}
pool_names, fp_names = {}, {}
try:
from core import device_pool
for d in device_pool.list_configured(enabled_only=False):
# list_devices() 返回字典(含 name/fingerprint)
for d in device_pool.list_devices():
if d.get("serial"):
pool_names[d["serial"]] = d.get("name") or ""
# USB 设备的 adb serial == ro.serialno == 池里那条记录的指纹
if d.get("fingerprint"):
fp_names[d["fingerprint"]] = d.get("name") or ""
except Exception as e:
_log.warning(f"读取设备名失败(进度表将显示地址): {e}")
@@ -290,7 +294,7 @@ class ApkManager:
items = {}
for s in serials:
items[s] = {
"name": pool_names.get(s) or s,
"name": pool_names.get(s) or fp_names.get(s) or s,
"status": "pending",
"msg": "",
}
+10 -8
View File
@@ -21,9 +21,10 @@
- [11. 运维工具(adb / 剪贴板 / 应用版本 / Tailscale)](#11-运维工具adb--剪贴板--应用版本--tailscale)
- [12. AI 控制台](#12-ai-控制台)
- [13. 系统备份](#13-系统备份)
- [14. 非 JSON 响应汇总](#14-非-json-响应汇总)
- [15. 错误分支速查](#15-错误分支速查)
- [16. 已知问题](#16-已知问题)
- [14. 设备端应用商店](#14-设备端应用商店)
- [15. 非 JSON 响应汇总](#15-非-json-响应汇总)
- [16. 错误分支速查](#16-错误分支速查)
- [17. 已知问题](#17-已知问题)
---
@@ -48,14 +49,14 @@
### 1.3 CSRF
- `GET /api/csrf` 取 token(存在 session);前端对所有非 GET 请求带 `X-CSRF-Token`。
- ⚠️ **当前服务端并未强制校验**(`web_server.py` 只 import 了 `_csrf_protect`,没有注册 `before_request`)。即 token 已签发、前端已携带,但**伪造请求不会被拦**。见 [§16 已知问题](#16-已知问题)。
- ⚠️ **当前服务端并未强制校验**(`web_server.py` 只 import 了 `_csrf_protect`,没有注册 `before_request`)。即 token 已签发、前端已携带,但**伪造请求不会被拦**。见 [§17 已知问题](#17-已知问题)。
### 1.4 响应约定
- 绝大多数接口返回 **JSON**:成功 `{"ok": true, ...}`,失败 `{"ok": false, "error": "中文原因"}`(部分接口额外带 `msg`)。
- **HTTP 状态码与 `ok` 并存**:参数错误用 400,权限 403,不存在 404,冲突 409,依赖不可用 502/503。少数接口用 `{"ok":false}` + HTTP 200(如 `POST /api/jobs/<id>/run` 的"任务类型不存在")。以本文每节标注为准。
- 时间统一字符串:`"YYYY-MM-DD HH:MM"`(展示)或 `"YYYY-MM-DD HH:MM:SS"`(cron 相关)。
- 非 JSON 接口(HTML / 图片 / SSE / MJPEG / zip)见 [§14](#14-非-json-响应汇总)。
- 非 JSON 接口(HTML / 图片 / SSE / MJPEG / zip)见 [§15](#15-非-json-响应汇总)。
---
@@ -237,6 +238,7 @@
| GET | `/api/agent-store/config` | 读配置(含设备令牌) |
| POST | `/api/agent-store/config` | 启用/停用、重置令牌 |
| GET | `/api/agent-store/logs` | 设备端安装记录 |
| POST | `/api/agent/show-info` | 让设备上的 Agent 显示/关闭**身份大字页**(监控页「打开设备端 Agent」按钮)|
> 完整协议、鉴权与版本约定见 [DEVICE_AGENT.md](DEVICE_AGENT.md)。
@@ -749,7 +751,7 @@ multipart 上传 `.zip` 或 `.db`(字段 `file`)→ 校验并暂存:
| 方法 | 路径 | 响应类型 | 说明 |
|------|------|---------|------|
| GET | `/` `/wall` `/login` `/locate` | `text/html` | 四个页面(`/locate` 免登录) |
| GET | `/` `/wall` `/login` `/locate` | `text/html` | 四个页面(`/locate` 免登录;**已被设备端 Agent 的身份页取代**,仅作历史保留)|
| GET | `/api/device/screenshot` | `image/png` | 原始截图(`Cache-Control: no-store`) |
| GET | `/api/screen/thumb` | `image/jpeg` | 360px 缩略图 + `X-Screen-State` |
| GET | `/api/screen/stream` | `multipart/x-mixed-replace` | MJPEG 实时流 |
@@ -759,7 +761,7 @@ multipart 上传 `.zip` 或 `.db`(字段 `file`)→ 校验并暂存:
---
## 15. 错误分支速查
## 16. 错误分支速查
| 码 | 典型触发 |
|----|---------|
@@ -775,7 +777,7 @@ multipart 上传 `.zip` 或 `.db`(字段 `file`)→ 校验并暂存:
---
## 16. 已知问题
## 17. 已知问题
| 问题 | 影响 | 位置 |
|------|------|------|
+18 -17
View File
@@ -70,27 +70,28 @@ async function stopSelectedDevices(){
setTimeout(loadMonitor,500);
}
// 定位设备:点亮屏幕 + 打开大字定位页(显示设备 IP),便于在机架上找到目标
// 注意:打开定位页会切换设备前台,任务运行中慎用
async function locateDevice(serial){
if(!confirm('点亮 '+serial+' 屏幕并打开大字定位页?\n\n(会切换设备前台显示,任务运行中慎用)'))return;
const r=await apiPost('/api/device/locate',{serial:serial,show:true});
// 打开设备端 Agent:让设备上的 Agent 弹出「身份大字页」(名称/IP/平台地址),
// 人站在机架前一眼看出这台是平台上的哪一台。
// 替代原来的"推 HTML 让设备浏览器打开定位页"——那条路要设备装浏览器、页面还依赖
// 设备能访问平台,且在 MIUI 上本来就不稳。
async function showAgentInfo(serial){
const r=await apiPost('/api/agent/show-info',{serial:serial});
if(!r)return;
if(r.ok){
_locatingSerials.add(serial);
showToast(r.msg||'已定位','success');
showToast(r.msg||'已让设备显示身份信息','success');
renderDevices();
}else showToast(r.error||'定位失败','error');
}else showToast(r.error||'打开失败','error');
}
// 结束定位:关闭设备上的定位页(浏览器),按钮恢复为"定位"
async function stopLocate(serial){
const r=await apiPost('/api/device/locate/stop',{serial:serial});
// 关闭设备端 Agent 的身份页
async function hideAgentInfo(serial){
const r=await apiPost('/api/agent/show-info',{serial:serial,close:true});
if(!r)return;
_locatingSerials.delete(serial);
renderDevices();
if(r.ok)showToast(r.msg||'已结束定位','success');
else showToast(r.error||'结束定位失败','error');
if(r.ok)showToast(r.msg||'已关闭','success');
else showToast(r.error||'关闭失败','error');
}
// 批量亮屏/息屏所有在线设备(息屏会中断运行中任务,前端已确认)
@@ -344,13 +345,13 @@ function renderDeviceRow(dev){
// 设备操作按钮需要"设备控制"权限(无权限只保留截图查看)
const canDev=_can('devices');
const stopBtn=canDev&&(dev.worker_status==='running'||dev.worker_status==='connecting')?'<button class="btn btn-danger btn-xs" onclick="apiAction(\'/api/stop_device\',{serial:\''+esc(dev.serial)+'\'})">停止</button>':'';
// 截图按钮(任何状态都可看,离线时会报错)
const shotBtn='<button class="btn btn-xs" onclick="openScreenshot(\''+esc(dev.serial)+'\')" title="查看设备画面(不影响任务)">截图</button>';
// 定位按钮:定位中显示"结束定位"(关闭定位页),否则显示"定位"(点亮+大字页)
// 看屏按钮(任何状态都可看,离线时会报错)
const shotBtn='<button class="btn btn-xs" onclick="openScreenshot(\''+esc(dev.serial)+'\')" title="查看设备实时画面(不影响任务)">看屏</button>';
// 设备端 Agent:开着身份页时显示"关闭",否则"打开设备端 Agent"
const locating=_locatingSerials.has(dev.serial);
const locateBtn=canDev?(locating
?'<button class="btn btn-xs btn-danger" onclick="stopLocate(\''+esc(dev.serial)+'\')" title="关闭定位页(退出浏览器)">结束定位</button>'
:'<button class="btn btn-xs" onclick="locateDevice(\''+esc(dev.serial)+'\')" title="点亮屏幕并显示设备IP大字页(会切换前台,任务运行中慎用)">定位</button>'):'';
?'<button class="btn btn-xs btn-danger" onclick="hideAgentInfo(\''+esc(dev.serial)+'\')" title="让设备上的 Agent 关掉身份大字页">关闭 Agent</button>'
:'<button class="btn btn-xs" onclick="showAgentInfo(\''+esc(dev.serial)+'\')" title="让设备上的 Agent 显示身份大字页(名称/IP/平台地址)——人在机架前对号用(会切换设备前台)">打开设备端 Agent</button>'):'';
// 异常/失败设备提供"清除异常"(把状态恢复为未启动)
const clearBtn=canDev&&(dev.worker_status==='error'||dev.worker_status==='failed')
?'<button class="btn btn-xs" onclick="clearDeviceError(\''+esc(dev.serial)+'\')" title="清除该设备的异常状态,恢复为未启动">清除异常</button>':'';
+16 -6
View File
@@ -65,23 +65,33 @@ def api_apks_install_devices():
source: pool=设备池 / usb=本机有线 adb / adb=本机网络 adb。
"""
devices, seen = [], set()
name_by_serial, name_by_fp = {}, {}
try:
online = set(device_pool.list_online())
for d in device_pool.list_configured(enabled_only=False):
# list_devices() 返回字典(含 name/fingerprint);list_configured() 只有 serial 字符串
for d in device_pool.list_devices():
serial = d.get("serial")
if serial in online and serial not in seen:
devices.append({"serial": serial, "model": "",
"name": d.get("name") or "", "source": "pool"})
seen.add(serial)
except Exception:
pass
if serial:
name_by_serial[serial] = d.get("name") or ""
# USB 设备的 adb serial == ro.serialno == 池里那条网络记录的指纹,
# 靠指纹才认得出是同一台(否则插着 USB 时会冒出一个没名字的裸序列号)
if d.get("fingerprint"):
name_by_fp[d["fingerprint"]] = d.get("name") or ""
except Exception as e:
_log.warning("读取设备名失败(列表将只显示地址): %s", e)
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": "",
"name": "", "source": "usb" if ":" not in parts[0] else "adb"})
seen.add(parts[0])
s = parts[0]
devices.append({"serial": s, "model": "",
"name": name_by_serial.get(s) or name_by_fp.get(s) or "",
"source": "usb" if ":" not in s else "adb"})
seen.add(s)
return jsonify({"ok": True, "devices": devices})
@bp.route("/api/apks/install", methods=["POST"])
+14 -5
View File
@@ -20,20 +20,29 @@ def _merged_device_list():
parts = line.split()
if len(parts) >= 2 and parts[0] and not parts[0].startswith("*"):
states[parts[0]] = parts[1]
names = {}
# 注意用的是 list_devices()(**字典**,含 name/fingerprint);
# list_configured() 只返回 serial 字符串列表,别拿它当字典用(实测踩过:.get 抛异常
# 被 except 吞掉 → 名字全空)
names, fp_names = {}, {}
try:
for d in device_pool.list_configured(enabled_only=False):
for d in device_pool.list_devices():
if d.get("serial"):
names[d["serial"]] = d.get("name") or ""
except Exception:
pass
# USB 设备在 adb 里的 serial 就是 ro.serialno,等于池里那条网络记录的
# **指纹** —— 插着 USB 时会以另一个 serial 出现,靠指纹才认得出是同一台,
# 否则界面上会冒出一个没名字的裸序列号(实测踩过)
if d.get("fingerprint"):
fp_names[d["fingerprint"]] = d.get("name") or ""
except Exception as e:
import logging
logging.getLogger("web").warning(f"读取设备名失败: {e}")
try:
for serial in device_pool.list_configured():
if serial not in states:
states[serial] = "pool"
except Exception:
pass
return [{"serial": s, "name": names.get(s, ""), "state": st}
return [{"serial": s, "name": names.get(s) or fp_names.get(s) or "", "state": st}
for s, st in sorted(states.items())]
+80 -2
View File
@@ -28,7 +28,7 @@ from config import APK_DIR
from core import device_pool
from core.db_config import meta_get, meta_set
from core.logger import get_logger
from web.auth import PERM_APKS, perm_required
from web.auth import PERM_APKS, PERM_DEVICES, perm_required
bp = Blueprint("device_agent", __name__)
_log = get_logger("web.devagent")
@@ -86,7 +86,7 @@ def _identify():
if d:
return fp, d.get("serial", serial), d.get("name") or "", True
if serial:
for d in device_pool.list_configured(enabled_only=False):
for d in device_pool.list_devices(): # 返回字典(list_configured 只有 serial)
if d.get("serial") == serial:
return fp, serial, d.get("name") or "", True
except Exception as e:
@@ -264,6 +264,84 @@ def _prune_logs(keep=_MAX_LOG):
# ================== 管理端接口(登录 + 权限)==================
@bp.route("/api/agent/show-info", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_agent_show_info():
"""让设备上的 Agent 打开「身份大字页」(监控页那个按钮调它)。
替代原来那套「推 HTML 让设备浏览器打开定位页」——那套在 MIUI 上要装浏览器、
页面还依赖设备能访问平台;Agent 是常驻 App,一条 `am start` 就出画面。
请求:{"serial": "…", "close": false}
返回:{ok, msg};设备没装 Agent 时明确报出来(不静默失败)
"""
from core.adb_helper import _adb
data = request.json or {}
serial = (data.get("serial") or "").strip()
close = bool(data.get("close"))
if not serial:
return jsonify({"ok": False, "error": "缺少 serial"}), 400
if close:
args = ["--es", "close", "1"]
else:
# 设备信息:名称/IP/平台地址 —— Agent 用它们显示大字页
name, ip = "", ""
ip = serial.split(":")[0] if ":" in serial else ""
for d in device_pool.list_devices(): # 字典列表(list_configured 只有 serial)
if d.get("serial") == serial:
name = d.get("name") or ""
break
if not name:
# USB 设备的 serial 就是指纹:池里那条记录的 serial 是网络地址,
# 靠指纹才认得出来(否则大字页标题会显示一串裸序列号)
for d in device_pool.list_devices():
if d.get("fingerprint") and d["fingerprint"] == serial:
name = d.get("name") or ""
break
if not ip:
# USB 设备:平台不知道它的 IP,现问一下设备(只读命令,不影响设备)
out = _adb("-s", serial, "shell", "ip", "-4", "addr", "show", "wlan0")
import re as _re
m = _re.search(r"inet\s+(\d+\.\d+\.\d+\.\d+)", out or "")
ip = m.group(1) if m else ""
# 平台地址:用操作者当前访问的地址;如果是 localhost,设备根本连不上 →
# 换成局域网地址(设备上显示 127.0.0.1 毫无意义)
server = request.host_url.rstrip("/")
if "127.0.0.1" in server or "localhost" in server:
from web.devices_api import _lan_ip
server = "http://%s:18050" % _lan_ip()
args = ["--es", "name", name or serial.split(":")[0],
"--es", "serial", serial,
"--es", "server", server]
if ip:
args += ["--es", "ip", ip]
# 先点亮屏幕、解除锁屏:设备息屏时 am start 会把大字页开在锁屏**后面**,
# 人看到的还是一片黑(实测踩过)——这正是原来"定位"按钮里的"点亮屏幕"那一步
if not close:
try:
_adb("-s", serial, "shell", "input", "keyevent", "KEYCODE_WAKEUP")
_adb("-s", serial, "shell", "wm", "dismiss-keyguard")
except Exception as e:
_log.warning("点亮屏幕失败(不影响显示身份页): %s", e)
try:
out = _adb("-s", serial, "shell", "am", "start", "-n",
"com.example.deviceagent/.InfoActivity", *args) or ""
except Exception as e:
return jsonify({"ok": False, "error": f"adb 执行失败: {e}"}), 500
low = out.lower()
if "unable to resolve intent" in low or "does not exist" in low or "error" in low:
return jsonify({"ok": False,
"error": "这台设备没装设备 Agent(com.example.deviceagent)——"
"可以从「应用管理 → 设备端应用商店」装,或用扫码配置"}), 400
_log.info("设备端 Agent %s: %s", "关闭身份页" if close else "打开身份页", serial)
return jsonify({"ok": True, "msg": "已关闭" if close else "已让设备显示身份信息"})
@bp.route("/api/agent-store/config", methods=["GET", "POST"])
@perm_required(PERM_APKS)
def api_agent_store_config():
+5
View File
@@ -102,6 +102,11 @@ def api_status():
devices, err = context.mgr.get_status()
if err:
return jsonify({"ok": False, "error": err}), 500
# 按**名称**排(A01/A02/…)——之前是 adb 返回的顺序,看着像按 IP 排,很别扭。
# 没名字的排在最后、彼此按地址排(历史数据里可能有没命名的设备)。
devices.sort(key=lambda d: (
1 if not (d.get("device_name") or "").strip() else 0,
(d.get("device_name") or "").strip() or d.get("serial", "")))
return jsonify({
"ok": True, "devices": devices, "server_time": time.time(),
"fg_scanning": context.mgr._fg_scanner.is_scanning,