5 Commits
12 changed files with 844 additions and 182 deletions
+33 -4
View File
@@ -1,12 +1,41 @@
# 环境变量示例:复制为 .env 并按需修改(.env 不进 git) # 环境变量示例:复制为 .env 并按需修改(.env 不进 git)
# 生产环境建议用环境变量/密钥管理注入,不要把 token 写进 git。 # 生产环境建议用环境变量/密钥管理注入,不要把真实 token 写进 git。
# 全部可配置项见 config.py(_env() 读取,未配置时用代码内默认值);
# 标"必须"的项务必设置,其余可留空用默认。
# STF 服务地址 # ==================== STF 平台 ====================
# STF 服务地址(默认 http://192.168.20.220:7100)
STF_URL=http://192.168.20.220:7100 STF_URL=http://192.168.20.220:7100
# STF API Token(安全敏感,生产必须设置) # STF API Token(必须:STF 个人设置 → API Keys 生成)
STF_TOKEN=请填写你的STF_token STF_TOKEN=请填写你的STF_token
# Web 会话密钥(安全敏感,生产必须设置为随机长字符串) # 启动时自动释放残留 STF 占用(单实例无人值守开 true;多实例共用账户勿开,会误放另一实例的任务)
# AUTO_RELEASE_STALE_OCCUPY=true
# ==================== Web 会话 ====================
# Web 会话密钥(必须:生产设置为随机长字符串,重启不失效)
# 生成:python -c "import secrets; print(secrets.token_hex(32))" # 生成:python -c "import secrets; print(secrets.token_hex(32))"
WEB_SECRET_KEY=请填写随机密钥 WEB_SECRET_KEY=请填写随机密钥
# ==================== SSH 到部署机(维护页重启 STF / 工具页 STF 设备管理) ====================
# SSH 目标(默认 [email protected])
# [email protected]
# SSH 认证密码(推荐):配置后走 paramiko **纯密码**登录(不碰本地密钥/SSH agent,
# 不会弹授权框,跨平台无需 sshpass);留空则退回系统 ssh 免密密钥(BatchMode=yes)。
STF_SSH_PASSWORD=请填写SSH密码
# 220 上 STF / adb 的 Docker 容器名(默认 stf / adb,一般不用改)
# STF_DOCKER_CONTAINER=stf
# STF_ADB_CONTAINER=adb
# STF 设备池脚本路径(工具页"STF 设备管理"增删设备时读写其 DEVICES 列表,默认值见 config.py)
# STF_SCRIPT_PATH=/mnt/data/openstf/connect_devices.sh
# ==================== Tailscale 管理(工具页) ====================
# API key(必须:Tailscale 后台 → Settings → API Access Tokens 生成)
TAILSCALE_API_KEY=请填写Tailscale_API_key
# tailnet 名/ID(默认按邮箱前缀,如 [email protected])
# [email protected]
+19 -8
View File
@@ -229,14 +229,20 @@ self.set_progress(done=5, total=80, unit="视频",
| 配置项 | 默认值 | 说明 | | 配置项 | 默认值 | 说明 |
|-------|--------|------| |-------|--------|------|
| `STF_URL` | `http://192.168.20.220:7100` | OpenSTF 服务地址 | | `STF_URL` | `http://192.168.20.220:7100` | OpenSTF 服务地址 |
| `STF_TOKEN` | (需替换) | STF API Token,在 STF 个人设置里生成 | | `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 目标(需免密) |
| `STF_DOCKER_CONTAINER` | `stf` | 220 上 STF Docker 容器名 |
| `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_TAILNET` | 按邮箱前缀 | tailnet 名/ID |
**必须修改的配置**:`STF_URL` 和 `STF_TOKEN`,改成你自己的 STF 服务地址和 Token。 **必须修改的配置**:`.env` 里的 `STF_TOKEN`(密钥类统一放 `.env`,不入 git);`STF_URL` 在 `config.py`。
--- ---
@@ -244,17 +250,19 @@ self.set_progress(done=5, total=80, unit="视频",
### 页面结构 ### 页面结构
单页应用(`templates/admin/monitor.html`),6 个 Tab: 单页应用(`templates/admin/monitor.html`),7 个 Tab;任务/工具 Tab 内部再有页内子分栏:
| Tab | 功能 | 可见性 | | Tab | 功能 | 可见性 |
|-----|------|--------| |-----|------|--------|
| 监控 | 设备状态大屏:在线/离线、运行任务、当前动作、进度条、前台 App、截图、停止 | 所有登录用户(设备操作按钮需"设备控制"权限) | | 监控 | 设备状态大屏:在线/离线、运行任务、当前动作、进度条、前台 App、截图、停止 | 所有登录用户(设备操作按钮需"设备控制"权限) |
| 任务 | 任务计划 CRUD:新建/编辑/删除/启用停用/立即执行 | 所有登录用户可看,写操作需"任务管理"权限 | | 任务 | 子分栏:任务计划(CRUD/启用停用/立即执行/下次运行时间)、自定义动作(打包复用) | 所有登录用户可看,写操作需"任务管理"权限 |
| 应用 | APK 上传/安装/删除 | 需"应用管理"权限 |
| 分组 | 设备分组管理:创建/编辑/删除分组 | 所有登录用户可看,写操作需"任务管理"权限 | | 分组 | 设备分组管理:创建/编辑/删除分组 | 所有登录用户可看,写操作需"任务管理"权限 |
| 日志 | 实时日志查看:按模块切换(core/task/web/action) | 需"日志查看"权限 | | 日志 | 实时日志查看:按模块切换(core/task/web/action) | 需"日志查看"权限 |
| 用户 | 用户管理:创建/删除/修改密码/分配权限 | 仅管理员 | | 用户 | 用户管理:创建/删除/修改密码/分配权限 | 仅管理员 |
| 维护 | STF 容器一键重启、adb 远程终端(设备快捷选择/自动 `-s`、卸载 STF agent、重连设备)、Tailscale 管理(设备列表/改名/授权/密钥不过期/生成 auth key) | 仅管理员 | | 维护 | STF 服务:一键重启容器 | 仅管理员 |
| 工具 | 子分栏:剪贴板注入、adb 远程终端(快捷命令/自动 `-s`/卸载 STF agent)、Tailscale 管理(改名/授权/密钥不过期/设置IP/auth key)、应用管理(APK 上传安装)、应用版本管理(按包名查所有设备版本)、设备已装应用、STF 设备管理(读写 220 脚本维护设备池) | 仅管理员 |
任务/工具 Tab 的子分栏会记住上次选中的位置;无权限的 tab 和按钮自动隐藏。
### 用户与权限 ### 用户与权限
@@ -264,7 +272,7 @@ self.set_progress(done=5, total=80, unit="视频",
|--------|------|---------| |--------|------|---------|
| 任务管理 `tasks` | 任务/自定义动作/分组的增删改、启停、立即执行 | 任务 Tab、分组 Tab 的写操作 | | 任务管理 `tasks` | 任务/自定义动作/分组的增删改、启停、立即执行 | 任务 Tab、分组 Tab 的写操作 |
| 设备控制 `devices` | 停止设备、释放占用、清除异常、前台扫描、元素抓取 | 监控 Tab 的设备操作按钮、步骤编辑器的"抓取元素" | | 设备控制 `devices` | 停止设备、释放占用、清除异常、前台扫描、元素抓取 | 监控 Tab 的设备操作按钮、步骤编辑器的"抓取元素" |
| 应用管理 `apks` | APK 上传、安装、删除 | 应用 Tab | | 应用管理 `apks` | APK 上传、安装、删除 | 应用管理/设备已装应用(当前并入工具 Tab,工具页整体仅管理员可见) |
| 日志查看 `logs` | 日志页 | 日志 Tab | | 日志查看 `logs` | 日志页 | 日志 Tab |
规则: 规则:
@@ -332,10 +340,13 @@ self.set_progress(done=5, total=80, unit="视频",
| `wait_el` | 等待元素出现(条件等待,替代固定时长) | | `wait_el` | 等待元素出现(条件等待,替代固定时长) |
| `input_text` | 输入文字(随机候选/指定文字,可选输入前先清空) | | `input_text` | 输入文字(随机候选/指定文字,可选输入前先清空) |
| `click` | 点击元素(支持 xpath/description/resourceId/text 选择器) | | `click` | 点击元素(支持 xpath/description/resourceId/text 选择器) |
| `input_text` | 输入文字(随机候选/固定文字) |
| `wait` | 等待(可配置时长范围) | | `wait` | 等待(可配置时长范围) |
| `loop` | 循环块(含子步骤,可配置循环次数) | | `loop` | 循环块(含子步骤,可配置循环次数) |
| `group` | 动作组(含子步骤,按序执行一次) | | `group` | 动作组(含子步骤,按序执行一次) |
| `if_el` | 条件判断:找元素(支持 xpath 等选择器或 **OCR识别** 截屏匹配图片文字),命中执行"找到时"分支,未命中执行"未找到时"分支;OCR 命中可自动点击。分支可嵌套循环/条件判断 |
步骤编辑器特性:操作库按分类分组、卡片可拖拽排序/跨层级嵌套(循环套循环)、☑ 多选打包自定义动作、
"测试此步骤"真机验证、"抓取元素"回填选择器、条件判断的 OCR 识别依赖 `rapidocr_onnxruntime`(跨平台)。
### 新增 App 任务 ### 新增 App 任务
+20 -11
View File
@@ -30,7 +30,8 @@ def _env(key, default):
# ================== STF 配置 ================== # ================== STF 配置 ==================
STF_URL = _env("STF_URL", "http://192.168.20.220:7100") # 部署相关配置统一从 .env 读取(模板见 .env.example),config.py 不再内置具体值
STF_URL = _env("STF_URL", "")
STF_TOKEN = _env("STF_TOKEN", "") # 写入 .env,不要提交到 git STF_TOKEN = _env("STF_TOKEN", "") # 写入 .env,不要提交到 git
# ================== adb 路径 ================== # ================== adb 路径 ==================
@@ -65,16 +66,24 @@ APK_DIR = os.path.join(DATA_DIR, "apks")
# 仅单实例部署时设为 True:多实例共用同一 STF 账户时不要开,会误释放另一实例的运行中任务。 # 仅单实例部署时设为 True:多实例共用同一 STF 账户时不要开,会误释放另一实例的运行中任务。
AUTO_RELEASE_STALE_OCCUPY = _env("AUTO_RELEASE_STALE_OCCUPY", "false").lower() in ("1", "true", "yes") AUTO_RELEASE_STALE_OCCUPY = _env("AUTO_RELEASE_STALE_OCCUPY", "false").lower() in ("1", "true", "yes")
# ================== 维护(STF 容器一键重启) ================== # ================== SSH 到部署机(维护页重启 STF + 工具页 STF 设备管理) ==================
# 维护页"一键重启 STF 容器"通过 SSH 到部署机执行 docker restart。 # 通过 SSH 到部署机执行 docker 命令/改脚本。配置从 .env 读取:
# 前置条件:本机可免密 SSH 到目标机(stf@220 需已授权本机公钥)。 # STF_SSH_TARGET SSH 目标(user@host)
STF_SSH_TARGET = _env("STF_SSH_TARGET", "[email protected]") # STF_SSH_PASSWORD SSH 密码(配置后 paramiko 纯密码登录,跨平台;
STF_DOCKER_CONTAINER = _env("STF_DOCKER_CONTAINER", "stf") # 留空则退回系统 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_PASSWORD = _env("STF_SSH_PASSWORD", "")
STF_DOCKER_CONTAINER = _env("STF_DOCKER_CONTAINER", "")
STF_ADB_CONTAINER = _env("STF_ADB_CONTAINER", "")
STF_SCRIPT_PATH = _env("STF_SCRIPT_PATH", "")
# ================== 维护(Tailscale 管理) ================== # ================== Tailscale 管理(工具页) ==================
# 维护页"Tailscale 管理"调用官方 API v2 管理 tailnet 设备。 # 调用官方 API v2 管理 tailnet 设备。配置从 .env 读取:
# TAILSCALE_API_KEY:Tailscale 后台 → Settings → API Access Tokens 生成(或 OAuth client_id:secret) # TAILSCALE_API_KEY:后台 → Settings → API Access Tokens 生成(或 OAuth client_id:secret)
# TAILSCALE_TAILNET:tailnet 名称/ID,个人账号一般是登录邮箱前缀 # TAILSCALE_TAILNET:tailnet 名称/ID,个人账号一般是登录邮箱前缀
# 安全:密钥用环境变量/.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", "[email protected]") TAILSCALE_TAILNET = _env("TAILSCALE_TAILNET", "")
+97
View File
@@ -0,0 +1,97 @@
"""SSH 远程命令执行统一封装(STF 重启 / STF 设备管理共用)。
认证方式二选一:
1. `.env` 配置 STF_SSH_PASSWORD → 密码认证(paramiko 实现,跨平台,
无需 sshpass/ssh 等外部工具,Windows/Linux/macOS 通吃)
2. 未配置 → 退回系统 `ssh` 命令 + BatchMode=yes(免密密钥,与旧行为一致)
用法:
code, out, err = ssh_client.run("docker ps") # 返回 (退出码, stdout, stderr)
失败抛 SSHError(连接失败/超时),由调用方转成友好错误返回。
"""
import re
import subprocess
import paramiko
from config import STF_SSH_TARGET, STF_SSH_PASSWORD
from core.logger import get_logger
_log = get_logger("core.ssh")
class SSHError(Exception):
"""SSH 执行错误(连接失败/超时等)。"""
def _parse_target(target):
"""解析 STF_SSH_TARGET(user@host[:port] 或 host)→ (user, host, port)。"""
target = (target or "").strip()
user, host, port = "root", target, 22
m = re.match(r"^([^@]+)@(.+)$", target)
if m:
user, host = m.group(1), m.group(2)
if ":" in host:
host, p = host.rsplit(":", 1)
try:
port = int(p)
except ValueError:
pass
return user, host, port
def uses_password():
"""当前是否走密码认证。"""
return bool(STF_SSH_PASSWORD)
def run(cmd, timeout=25):
"""在部署机上执行一条命令。返回 (code, stdout, stderr)。"""
if uses_password():
return _run_paramiko(cmd, timeout)
return _run_system_ssh(cmd, timeout)
def _run_system_ssh(cmd, timeout):
"""免密密钥路径:系统 ssh + BatchMode=yes(不弹密码提示)。"""
ssh = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
"-o", "StrictHostKeyChecking=no", STF_SSH_TARGET]
try:
r = subprocess.run(ssh + [cmd], capture_output=True, timeout=timeout)
except subprocess.TimeoutExpired:
raise SSHError(f"SSH 执行超时: {cmd[:80]}")
except FileNotFoundError:
raise SSHError("本机未安装 ssh,无法远程执行")
except Exception as e:
raise SSHError(f"SSH 连接失败: {e}")
return r.returncode, (r.stdout or b"").decode("utf-8", errors="replace"), \
(r.stderr or b"").decode("utf-8", errors="replace")
def _run_paramiko(cmd, timeout):
"""密码路径:paramiko SSHClient,**纯密码认证**。
look_for_keys=False + allow_agent=False:不尝试本地密钥、不询问 SSH agent
(如 Bitwarden),否则 agent 会弹授权框/用密钥绕过密码。
"""
user, host, port = _parse_target(STF_SSH_TARGET)
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
client.connect(host, port=port, username=user, password=STF_SSH_PASSWORD,
timeout=8, banner_timeout=8, auth_timeout=8,
look_for_keys=False, allow_agent=False)
try:
stdin, stdout, stderr = client.exec_command(cmd, timeout=timeout)
out = stdout.read().decode("utf-8", errors="replace")
err = stderr.read().decode("utf-8", errors="replace")
code = stdout.channel.recv_exit_status()
return code, out, err
finally:
client.close()
except paramiko.AuthenticationException:
raise SSHError(f"SSH 密码认证失败(请检查 .env 的 STF_SSH_PASSWORD)")
except paramiko.SSHException as e:
raise SSHError(f"SSH 连接失败: {e}")
except Exception as e:
raise SSHError(f"SSH 连接失败: {e}")
+133
View File
@@ -0,0 +1,133 @@
"""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
+75 -3
View File
@@ -595,9 +595,11 @@
--- ---
## 13. 维护(仅管理员) ## 13. 维护 / 工具(仅管理员)
维护页两个接口都**仅管理员可用**(普通用户即使有业务权限也访问不了,返回 403)。 以下接口都**仅管理员可用**(普通用户即使有业务权限也访问不了,返回 403)。
维护页现仅含 STF 服务管理;adb 终端、Tailscale 管理、应用管理、STF 设备管理等
已集中到"工具"页(页内子分栏),API 不变。
### POST /api/stf/restart ### POST /api/stf/restart
@@ -672,7 +674,7 @@ adb 远程终端:用平台 adb 二进制执行任意 adb 命令(20s 超时
## 15. Tailscale 管理(仅管理员) ## 15. Tailscale 管理(仅管理员)
维护页"Tailscale 管理"分区,调用 Tailscale 官方 API v2。所有接口仅管理员可用。 工具页"Tailscale 管理"子分栏,调用 Tailscale 官方 API v2。所有接口仅管理员可用。
前置:`.env` 配置 `TAILSCALE_API_KEY`(Settings → API Access Tokens)与 前置:`.env` 配置 `TAILSCALE_API_KEY`(Settings → API Access Tokens)与
`TAILSCALE_TAILNET`(tailnet 名,个人账号一般为邮箱前缀);未配置返回 502 并附提示。 `TAILSCALE_TAILNET`(tailnet 名,个人账号一般为邮箱前缀);未配置返回 502 并附提示。
设备 IP 由 tailnet 分配,API 不可修改,列表只读展示。 设备 IP 由 tailnet 分配,API 不可修改,列表只读展示。
@@ -739,3 +741,73 @@ adb 远程终端:用平台 adb 二进制执行任意 adb 命令(20s 超时
```json ```json
{"ok": true, "key": "tskey-auth-...", "id": "k1", "expires": "2026-08-11T01:00:00Z"} {"ok": true, "key": "tskey-auth-...", "id": "k1", "expires": "2026-08-11T01:00:00Z"}
``` ```
---
## 16. 工具(仅管理员)
工具页(剪贴板注入 / 应用版本管理 / STF 设备管理)接口,均仅管理员可用。
### POST /api/tools/clipboard/set
剪贴板注入:把指定文字写入一台或多台设备的剪贴板。
**请求**(JSON):
```json
{"serials": ["100.100.10.11:5555", "0123456789ABCDEF"], "text": "要注入的文字"}
```
设备来源与维护终端一致(本地 adb 含 USB + STF 池)。
实现:u2 `jsonrpc.setClipboard`(实测 `cmd clipboard` 在 MIUI 上不存在),支持中文/引号/换行;
IP 设备先 adb connect(已连接跳过,绝不 disconnect),USB 设备首次自动推送 atx-agent。
**响应**:
```json
{"ok": true, "results": {"100.100.10.11:5555": {"ok": true, "msg": "已注入"}},
"ok_count": 1, "fail_count": 0, "error": null}
```
### POST /api/tools/appver
应用版本管理:查询所有设备上指定包名的安装情况与版本号(并发 10 台)。
**请求**(JSON):
```json
{"pkg": "com.ss.android.ugc.aweme"}
```
**响应**:
```json
{"ok": true, "total": 8, "fail": 0,
"results": {"100.100.10.11:5555": {"installed": true, "version_name": "28.5.0", "version_code": "280500"}}}
```
未安装返回 `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"}`
+39 -4
View File
@@ -233,6 +233,32 @@ APK 上传/解析/批量安装。
元素树解析:递归提取每个节点的 `resource-id/text/content-desc/class/bounds` 等属性,并推荐最佳选择器(优先 xpath)。 元素树解析:递归提取每个节点的 `resource-id/text/content-desc/class/bounds` 等属性,并推荐最佳选择器(优先 xpath)。
### 3.9 屏幕 OCR(`core/ocr.py`)
条件判断的 `ocr` 选择器实现:截屏 → RapidOCR(ONNX 推理,中英文模型随包内置)→ 关键词匹配 → 返回文字中心像素坐标(与 u2 `d.click` 一致)。
- **跨平台**(Windows/Linux/macOS),依赖 `rapidocr_onnxruntime`;服务器无显示器环境建议将 opencv-python 换成 opencv-python-headless
- 引擎懒加载单例 + 并发加锁(识别约 0.2-0.5s/次)
- 返回坐标约定:像素、原点左上
### 3.10 STF 设备管理(`core/stf_device_mgmt.py`)
工具页"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 实际连接状态 |
| `add_device(ip)` | 用 220 的 python3 精确改脚本 DEVICES 块 + `docker exec adb adb connect` |
| `remove_device(ip)` | 脚本删条目 + `adb disconnect` |
关键点:
- 脚本编辑用 python3 行级增删(sed 处理多行数组易误伤)
- `adb connect` 到不可达 IP 会挂 40s+,220 侧用 `timeout 15` 兜底,超时保留在脚本由 cron 重试
- 断开的是 220(STF provider 侧)的 adb 连接,与本机任务直连的 adb 相互独立
--- ---
## 4. 任务系统设计 ## 4. 任务系统设计
@@ -289,13 +315,22 @@ tasks/douyin/actions/like.py — @register_action(ACTIONS) LikeAction
### 5.2 步骤编辑器 ### 5.2 步骤编辑器
`generic_steps` 任务的步骤编辑器: `generic_steps` 任务的步骤编辑器(任务弹窗已加宽到 1000px):
- 左侧操作库(拖拽源) - 左侧操作库(拖拽源,按 交互操作/屏幕与App/流程控制 分组)
- 中间画布(步骤卡片列表,HTML5 Drag API 排序) - 中间画布(步骤卡片列表,HTML5 Drag API 排序 + 跨层级嵌套:循环套循环、动作组)
- 每个步骤卡片可展开参数表单 - 每个步骤卡片可展开参数表单
- 选择器字段旁有"抓取元素"按钮(独立模态框) - 选择器字段旁有"抓取元素"按钮(独立模态框)
- **容器步骤**(loop/group/if_el):卡片内嵌子步骤容器接收拖入;if_el 有"✅找到时/❌未找到时"两个独立分支容器,分支可嵌套任意步骤
- **条件判断**(if_el):选择器支持 xpath 等 UI 树选择器或 OCR识别(`core/ocr.py`,截屏匹配图片/画布文字,命中可自动点击)
- 所有递归操作(选择打包、存自定义动作、校验、防循环自套)统一遍历 children/then/else 三个子数组
### 5.3 元素抓取模态框 ### 5.3 页内子分栏
任务/工具 Tab 用通用 `showSubTab(tabId, name)` 实现页内子分栏:每个子分栏一个 `.sub-panel`,
`_activeSubs` 记住各 Tab 上次选中的子分栏。工具 Tab 集中了全部管理工具(剪贴板注入/adb 终端/
Tailscale/应用管理/应用版本/已装应用/STF 设备管理),维护 Tab 仅保留 STF 服务。
### 5.4 元素抓取模态框
独立的第二层模态框(`el-picker-overlay`,z-index 1100),不影响任务编辑窗口: 独立的第二层模态框(`el-picker-overlay`,z-index 1100),不影响任务编辑窗口:
1. 选择设备 → 2. 加载截图 + 元素树 → 3. 点击元素/边界框 → 4. 回填选择器 1. 选择设备 → 2. 加载截图 + 元素树 → 3. 点击元素/边界框 → 4. 回填选择器
+12 -4
View File
@@ -74,6 +74,9 @@ pip install -r requirements.txt
| uiautomator2 | >=3.0 | Android UI 自动化 | | uiautomator2 | >=3.0 | Android UI 自动化 |
| uiautodev | >=0.14 | UI 元素抓取 | | uiautodev | >=0.14 | UI 元素抓取 |
| pyaxmlparser | >=0.3.27 | APK 元信息解析 | | pyaxmlparser | >=0.3.27 | APK 元信息解析 |
| rapidocr_onnxruntime | >=1.4 | 屏幕 OCR(条件判断的 OCR识别 选择器,中英文模型随包内置,跨平台) |
> 服务器(无显示器/Linux)环境建议把 opencv-python 换成 `opencv-python-headless`(rapidocr 依赖 cv2,两者取一)。
> uiautomator2 首次连接设备时会自动推送 atx-agent 到设备,无需手动安装。 > uiautomator2 首次连接设备时会自动推送 atx-agent 到设备,无需手动安装。
@@ -102,16 +105,21 @@ 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 容器"的 SSH 目标(需免密登录) | | `STF_SSH_TARGET` | `[email protected]` | 维护页"一键重启 STF 容器"与工具页"STF 设备管理"的 SSH 目标 |
| `STF_SSH_PASSWORD` | (空) | SSH 密码认证(.env 配置后走 paramiko 密码登录,跨平台);留空则退回系统 ssh 免密密钥(BatchMode) |
| `STF_DOCKER_CONTAINER` | `stf` | 220 上 STF Docker 容器名(`docker ps` 查看) | | `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 重启前置**:本机需能免密 SSH 到部署机(把本机公钥加入目标机 `authorized_keys`), > **维护页 STF 重启 / 工具页 STF 设备管理前置**:需能 SSH 到部署机——两种方式任选:
> 且 `STF_DOCKER_CONTAINER` 与真实容器名一致;未配置好时按钮会返回明确错误。 > 1. `.env` 配置 `STF_SSH_PASSWORD` 走密码认证(paramiko,无需额外工具,推荐)
> 2. 免密密钥:本机公钥加入目标机 `authorized_keys`
> 且 `STF_DOCKER_CONTAINER`/`STF_ADB_CONTAINER` 与真实容器名一致;未配置好时按钮会返回明确错误。
### 2.4 启动服务 ### 2.4 启动服务
+4
View File
@@ -18,6 +18,10 @@ APScheduler>=3.10,<4.0
# HTTP 客户端(STF API 调用) # HTTP 客户端(STF API 调用)
requests>=2.28 requests>=2.28
# SSH 远程执行(STF 重启 / STF 设备管理;.env 配 STF_SSH_PASSWORD 时走密码认证,
# 跨平台无需 sshpass;未配置则退回系统 ssh 免密密钥)
paramiko>=3.0
# Android UI 自动化(uiautomator2) # Android UI 自动化(uiautomator2)
uiautomator2>=3.0 uiautomator2>=3.0
+176 -54
View File
@@ -60,31 +60,9 @@ async function apiPut(url,body){
} }
// ================== 设备列表排序与多选 ================== // ================== 设备列表排序与多选 ==================
let _sortKey=''; // 当前排序字段
let _sortDir=''; // '' | 'asc' | 'desc'
let _selectedSerials=new Set(); // 已勾选的 serial 集合 let _selectedSerials=new Set(); // 已勾选的 serial 集合
// 点击表头排序 // 排序比较:从设备对象取排序值(统一排序用,见 setListPager/sortValue)
function sortBy(key){
if(_sortKey===key){
// 同一列:切换方向;已是降序则取消
if(_sortDir==='asc'){_sortDir='desc';}
else if(_sortDir==='desc'){_sortKey='';_sortDir='';}
else{_sortDir='asc';}
}else{
_sortKey=key;_sortDir='asc';
}
// 更新表头样式
document.querySelectorAll('.table thead th.sortable').forEach(th=>{
th.classList.remove('sort-asc','sort-desc');
if(th.dataset.key===_sortKey){
th.classList.add(_sortDir==='asc'?'sort-asc':(_sortDir==='desc'?'sort-desc':''));
}
});
renderDevices();
}
// 排序比较:从设备对象取排序值
function _sortValue(dev,key){ function _sortValue(dev,key){
let v; let v;
if(key==='progress'){ if(key==='progress'){
@@ -281,23 +259,22 @@ function showTab(name){
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active')); document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
document.querySelectorAll('.navbar .tab').forEach(t=>t.classList.remove('active')); document.querySelectorAll('.navbar .tab').forEach(t=>t.classList.remove('active'));
document.getElementById('tab-'+name).classList.add('active'); document.getElementById('tab-'+name).classList.add('active');
document.querySelectorAll('.navbar .tab')[['monitor','tasks','apps','groups','logs','users','maintenance','tools'].indexOf(name)].classList.add('active'); document.querySelectorAll('.navbar .tab')[['monitor','tasks','groups','logs','users','maintenance','tools'].indexOf(name)].classList.add('active');
// 停止所有定时器 // 停止所有定时器
if(_monitorTimer){clearInterval(_monitorTimer);_monitorTimer=null;} if(_monitorTimer){clearInterval(_monitorTimer);_monitorTimer=null;}
if(_logTimer){clearInterval(_logTimer);_logTimer=null;} if(_logTimer){clearInterval(_logTimer);_logTimer=null;}
// 按需加载数据 // 按需加载数据
if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);} if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);}
if(name==='tasks'){showSubTab('tasks',_activeSubs.tasks);loadTasks();loadCustomActions();} if(name==='tasks'){showSubTab('tasks',_activeSubs.tasks);loadTasks();loadCustomActions();}
if(name==='apps'){showSubTab('apps',_activeSubs.apps);loadApks();} if(name==='tools'){showSubTab('tools',_activeSubs.tools);loadToolsDevices();loadAdbDevices();loadTailscaleDevices();loadApks();loadStfDevMgmt();}
if(name==='tools'){showSubTab('tools',_activeSubs.tools);loadToolsDevices();loadAdbDevices();loadTailscaleDevices();}
if(name==='groups')loadGroups(); if(name==='groups')loadGroups();
if(name==='logs'){loadLogs();if(document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);} if(name==='logs'){loadLogs();if(document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);}
if(name==='users')loadUsers(); if(name==='users')loadUsers();
} }
// ================== 页内子分栏(任务/应用/工具 通用) ================== // ================== 页内子分栏(任务/工具 通用) ==================
// 每个带子分栏的 Tab 记住上次选中的子分栏,切走再切回来保持原位 // 每个带子分栏的 Tab 记住上次选中的子分栏,切走再切回来保持原位
let _activeSubs = {tasks: 'plan', apps: 'apks', tools: 'clipboard'}; let _activeSubs = {tasks: 'plan', tools: 'clipboard'};
function showSubTab(tabId, name){ function showSubTab(tabId, name){
_activeSubs[tabId] = name; _activeSubs[tabId] = name;
@@ -351,6 +328,113 @@ function toggleClipDevice(cb){
else _clipSelected.delete(cb.value); else _clipSelected.delete(cb.value);
} }
// ================== 工具:应用版本管理 ==================
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>';
}
// ================== 工具:STF 设备管理 ==================
let _stfDevRows = []; // [{ip, inCfg, state}]
async function loadStfDevMgmt(){
const status = document.getElementById('stfdev-status');
const r = await apiGet('/api/stf/devmgmt/status');
if(!r || !r.ok){status.textContent='加载失败';showToast('加载失败: '+(r&&r.error||''),'error');return;}
const configured = r.configured || []; // 脚本 DEVICES 里的 IP(裸 IP)
const connected = {}; // 裸 IP -> state(220 adb devices 的 serial 带 :5555,归一化)
(r.connected || []).forEach(d=>{
const s = d.serial;
connected[s.endsWith(':5555') ? s.slice(0,-5) : s] = d.state;
});
_stfDevRows = Array.from(new Set(configured.concat(Object.keys(connected)))).map(ip=>({
ip, inCfg: configured.includes(ip), state: connected[ip]||''
}));
setListPager('stfdev',{
data:()=>_stfDevRows,
filter:(x,kw)=>x.ip.toLowerCase().includes(kw),
sortValue:(x,k)=>{
if(k==='cfg')return x.inCfg?1:0;
if(k==='state'){ const o={device:0,offline:1,unauthorized:2,'':9}; return (o[x.state]!==undefined)?o[x.state]:9; }
return x.ip||'';
},
render:renderStfDevRow,
tbody:'tb-stfdev',pager:'pager-stfdev',pageSize:10,
empty:'<tr><td colspan="4" class="empty">无设备(脚本未配置、220 adb 也无连接)</td></tr>'
});
applyListPager('stfdev');
initListSearch('stfdev','search-stfdev');
status.textContent = '脚本配置 '+configured.length+' 台,已连接 '+Object.keys(connected).length+' 台';
}
function renderStfDevRow(x){
const stHtml = x.state==='device'
? '<span class="label label-success">已连接</span>'
: (x.state ? '<span class="label label-warning">'+esc(x.state)+'</span>' : '<span class="label label-default">未连接</span>');
return '<tr><td style="font-family:monospace">'+esc(x.ip)+'</td>'+
'<td>'+(x.inCfg?'<span class="label label-info">脚本中</span>':'<span class="label label-default">未配置</span>')+'</td>'+
'<td>'+stHtml+'</td>'+
'<td><button class="btn btn-xs btn-danger" onclick="removeStfDev(\''+esc(x.ip)+'\')">移除(断开+删脚本)</button></td></tr>';
}
async function addStfDev(){
const ip = document.getElementById('stfdev-ip').value.trim();
if(!ip){showToast('请输入设备 IP','error');return;}
const r = await apiPost('/api/stf/devmgmt/add', {ip:ip});
if(!r)return;
if(r.ok){
showToast((r.msgs||[]).join(';'),'success');
document.getElementById('stfdev-ip').value='';
loadStfDevMgmt();
}else showToast('添加失败: '+(r.error||''),'error');
}
async function removeStfDev(ip){
if(!confirm('确定移除 '+ip+'?\n\n将从 connect_devices.sh 删除并断开 220 的 adb 连接,\n设备会从 OpenSTF 设备池下线(任务不再分配到它)。'))return;
const r = await apiPost('/api/stf/devmgmt/remove', {ip:ip});
if(!r)return;
if(r.ok){
showToast((r.msgs||[]).join(';'),'success');
loadStfDevMgmt();
}else showToast('移除失败: '+(r.error||''),'error');
}
async function injectClipboard(){ async function injectClipboard(){
const text = document.getElementById('clip-text').value.trim(); const text = document.getElementById('clip-text').value.trim();
if(!text){showToast('请输入要注入的文字','error');return;} if(!text){showToast('请输入要注入的文字','error');return;}
@@ -370,18 +454,22 @@ async function injectClipboard(){
} }
} }
// ================== 通用列表:搜索 + 分页 ================== // ================== 通用列表:搜索 + 分页 + 排序 ==================
// 用法: // 用法:
// setListPager(key,{data:()=>array, filter:(item,kw)=>bool, render:(item)=>html, // setListPager(key,{data:()=>array, filter:(item,kw)=>bool, render:(item)=>html,
// tbody:'tb-id', pager:'pager-id', pageSize:N}); // tbody:'tb-id', pager:'pager-id', pageSize:N,
// applyListPager(key); // 数据变化后重渲染(含搜索/翻页状态) // sortValue:(item,colKey)=>值, // 可排序列的取值函数
// initListSearch(key,'search-id');// 搜索框绑定 // empty:'<tr>...'});
// applyListPager(key); // 数据变化后重渲染(含搜索/翻页/排序状态)
// initListSearch(key,'search-id'); // 搜索框绑定
// _lpSort(key,'colKey'); // 表头排序:th 加 data-sort="colKey" + onclick,
// // 再点切换升降序,第三次取消;排序指示器自动更新
var _LIST_PAGERS={}; var _LIST_PAGERS={};
function _lp(key){ return _LIST_PAGERS[key] || (_LIST_PAGERS[key]={page:1,kw:'',cfg:null}); } function _lp(key){ return _LIST_PAGERS[key] || (_LIST_PAGERS[key]={page:1,kw:'',cfg:null,sort:null}); }
function setListPager(key,cfg){ function setListPager(key,cfg){
// 只更新配置,不重置 page/kw —— 监控列表 5 秒自动刷新时保持用户当前页 // 只更新配置,不重置 page/kw/sort —— 监控列表 5 秒自动刷新时保持用户当前状态
var st=_lp(key); st.cfg=cfg; var st=_lp(key); st.cfg=cfg;
} }
function initListSearch(key,inputId){ function initListSearch(key,inputId){
@@ -392,6 +480,17 @@ function initListSearch(key,inputId){
var st=_lp(key); st.kw=this.value.trim(); st.page=1; applyListPager(key); 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){ function applyListPager(key){
var st=_lp(key); var st=_lp(key);
if(!st.cfg)return; if(!st.cfg)return;
@@ -399,6 +498,18 @@ function applyListPager(key){
var all=(cfg.data&&cfg.data())||[]; var all=(cfg.data&&cfg.data())||[];
var kw=st.kw.toLowerCase(); var kw=st.kw.toLowerCase();
var filtered=kw?all.filter(function(x){return cfg.filter(x,kw);}):all; 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 pageSize=cfg.pageSize||10;
var pages=Math.max(1,Math.ceil(filtered.length/pageSize)); var pages=Math.max(1,Math.ceil(filtered.length/pageSize));
if(st.page>pages)st.page=pages; if(st.page>pages)st.page=pages;
@@ -406,7 +517,18 @@ function applyListPager(key){
var start=(st.page-1)*pageSize; var start=(st.page-1)*pageSize;
var slice=filtered.slice(start,start+pageSize); var slice=filtered.slice(start,start+pageSize);
var tb=document.getElementById(cfg.tbody); 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>'); 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); var pg=document.getElementById(cfg.pager);
if(pg)pg.innerHTML=_lpPagerHtml(key,pages,st.page,filtered.length,pageSize); if(pg)pg.innerHTML=_lpPagerHtml(key,pages,st.page,filtered.length,pageSize);
} }
@@ -516,33 +638,20 @@ async function loadSummary(){
}catch(e){console.error(e);} }catch(e){console.error(e);}
} }
// 渲染设备表格(带排序+搜索+分页,复选状态保持) // 渲染设备表格(统一排序+搜索+分页,复选状态保持)
let _sortedDevices=[];
let _devicesSig=''; let _devicesSig='';
function renderDevices(){ function renderDevices(){
if(!_statusData||!_statusData.devices){return;} if(!_statusData||!_statusData.devices){return;}
// 5秒自动刷新时若设备数据/分页/排序都没变化,跳过整表重渲染(避免DOM抖动) // 5秒自动刷新时若设备数据/分页/搜索/排序都没变化,跳过整表重渲染(避免DOM抖动)
const _st=_lp('devices'); const _st=_lp('devices');
const _sig=JSON.stringify({d:_statusData.devices,p:_st.page,k:_st.kw,s:_sortKey+(_sortDir||'')}); const _sig=JSON.stringify({d:_statusData.devices,p:_st.page,k:_st.kw,s:_st.sort});
if(_sig===_devicesSig)return; if(_sig===_devicesSig)return;
_devicesSig=_sig; _devicesSig=_sig;
let devices=_statusData.devices.slice(); // 排序由 applyListPager 统一处理(sortValue + _lpSort)
// 排序
if(_sortKey&&_sortDir){
const dir=_sortDir==='asc'?1:-1;
devices.sort((a,b)=>{
let va=_sortValue(a,_sortKey),vb=_sortValue(b,_sortKey);
if(typeof va==='string'&&typeof vb==='string'){
return va.localeCompare(vb)*dir;
}
va=Number(va)||0;vb=Number(vb)||0;
return (va-vb)*dir;
});
}
_sortedDevices=devices;
setListPager('devices',{ setListPager('devices',{
data:()=>_sortedDevices, data:()=>_statusData.devices||[],
filter:(dev,kw)=>((dev.serial||'').toLowerCase().includes(kw)||(dev.model||'').toLowerCase().includes(kw)||(dev.foreground_app||'').toLowerCase().includes(kw)||(dev.task_job||'').toLowerCase().includes(kw)||(dev.current_action||'').toLowerCase().includes(kw)), filter:(dev,kw)=>((dev.serial||'').toLowerCase().includes(kw)||(dev.model||'').toLowerCase().includes(kw)||(dev.foreground_app||'').toLowerCase().includes(kw)||(dev.task_job||'').toLowerCase().includes(kw)||(dev.current_action||'').toLowerCase().includes(kw)),
sortValue:_sortValue,
render:renderDeviceRow, render:renderDeviceRow,
tbody:'tb-devices',pager:'pager-devices',pageSize:10, tbody:'tb-devices',pager:'pager-devices',pageSize:10,
empty:'<tr><td colspan="11" class="empty">暂无设备</td></tr>' empty:'<tr><td colspan="11" class="empty">暂无设备</td></tr>'
@@ -725,6 +834,7 @@ function renderTaskTable(){
setListPager('tasks',{ setListPager('tasks',{
data:()=>jobs, data:()=>jobs,
filter:(j,kw)=>((j.name||'').toLowerCase().includes(kw)||(j.task_type||'').toLowerCase().includes(kw)||(j.id||'').toLowerCase().includes(kw)), 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, render:renderTaskRow,
tbody:'tb-tasks',pager:'pager-tasks',pageSize:10, tbody:'tb-tasks',pager:'pager-tasks',pageSize:10,
empty:'<tr><td colspan="9" class="empty">暂无任务,点击"新建任务"创建</td></tr>' empty:'<tr><td colspan="9" class="empty">暂无任务,点击"新建任务"创建</td></tr>'
@@ -2045,6 +2155,7 @@ async function loadCustomActions(){
setListPager('actions',{ setListPager('actions',{
data:()=>_customActions, data:()=>_customActions,
filter:(a,kw)=>((a.name||'').toLowerCase().includes(kw)), 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, render:renderActionRow,
tbody:'tb-actions',pager:'pager-actions',pageSize:10, tbody:'tb-actions',pager:'pager-actions',pageSize:10,
empty:'<tr><td colspan="5" class="empty">暂无自定义动作,可点击"新建动作"或用任务编辑器☑选中步骤打包</td></tr>' empty:'<tr><td colspan="5" class="empty">暂无自定义动作,可点击"新建动作"或用任务编辑器☑选中步骤打包</td></tr>'
@@ -2132,6 +2243,7 @@ function renderGroupTable(){
setListPager('groups',{ setListPager('groups',{
data:()=>groups, data:()=>groups,
filter:(g,kw)=>((g.name||'').toLowerCase().includes(kw)||(g.description||'').toLowerCase().includes(kw)||(g.serials||[]).join(' ').toLowerCase().includes(kw)), 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, render:renderGroupRow,
tbody:'tb-groups',pager:'pager-groups',pageSize:10, tbody:'tb-groups',pager:'pager-groups',pageSize:10,
empty:'<tr><td colspan="5" class="empty">暂无分组,点击"新建分组"创建</td></tr>' empty:'<tr><td colspan="5" class="empty">暂无分组,点击"新建分组"创建</td></tr>'
@@ -2236,6 +2348,7 @@ async function loadUsers(){
setListPager('users',{ setListPager('users',{
data:()=>users, data:()=>users,
filter:(u,kw)=>(u.username||'').toLowerCase().includes(kw), 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, render:renderUserRow,
tbody:'tb-users',pager:'pager-users',pageSize:10, tbody:'tb-users',pager:'pager-users',pageSize:10,
empty:'<tr><td colspan="5" class="empty">暂无用户</td></tr>' empty:'<tr><td colspan="5" class="empty">暂无用户</td></tr>'
@@ -2436,6 +2549,13 @@ async function loadTailscaleDevices(){
setListPager('tailscale',{ setListPager('tailscale',{
data:()=>_tsDevices, data:()=>_tsDevices,
filter:(d,kw)=>((d.hostname||'').toLowerCase().includes(kw)||(d.name||'').toLowerCase().includes(kw)||(d.addresses||[]).join(',').includes(kw)), 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, render:renderTsRow,
tbody:'tb-tailscale',pager:'pager-tailscale',pageSize:10, tbody:'tb-tailscale',pager:'pager-tailscale',pageSize:10,
empty:'<tr><td colspan="8" class="empty">tailnet 暂无设备</td></tr>' empty:'<tr><td colspan="8" class="empty">tailnet 暂无设备</td></tr>'
@@ -2558,6 +2678,7 @@ async function loadApks(){
document.getElementById('apk-hint').textContent='共 '+apks.length+' 个应用'; document.getElementById('apk-hint').textContent='共 '+apks.length+' 个应用';
setListPager('apks',{ setListPager('apks',{
data:()=>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)), filter:(a,kw)=>((a.display_name||'').toLowerCase().includes(kw)||(a.package_name||'').toLowerCase().includes(kw)),
render:renderApkRow, render:renderApkRow,
tbody:'tb-apks',pager:'pager-apks',pageSize:10, tbody:'tb-apks',pager:'pager-apks',pageSize:10,
@@ -2616,6 +2737,7 @@ async function loadDeviceApps(){
setListPager('devapps',{ setListPager('devapps',{
data:()=>_devAppsData||[], data:()=>_devAppsData||[],
filter:(a,kw)=>((a.label||a.package||'').toLowerCase().includes(kw)||(a.package||'').toLowerCase().includes(kw)||(a.path||'').toLowerCase().includes(kw)), 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, render:renderDevAppRow,
tbody:'tb-device-apps',pager:'pager-devapps',pageSize:20, tbody:'tb-device-apps',pager:'pager-devapps',pageSize:20,
empty:'<tr><td colspan="4" class="empty">无应用</td></tr>' empty:'<tr><td colspan="4" class="empty">无应用</td></tr>'
+111 -70
View File
@@ -286,7 +286,6 @@ select.form-control{cursor:pointer}
<div class="tabs"> <div class="tabs">
<button class="tab active" onclick="showTab('monitor')">监控</button> <button class="tab active" onclick="showTab('monitor')">监控</button>
<button class="tab" onclick="showTab('tasks')">任务</button> <button class="tab" onclick="showTab('tasks')">任务</button>
<button class="tab" onclick="showTab('apps')" data-perm="apks">应用</button>
<button class="tab" onclick="showTab('groups')">分组</button> <button class="tab" onclick="showTab('groups')">分组</button>
<button class="tab" onclick="showTab('logs')" data-perm="logs">日志</button> <button class="tab" onclick="showTab('logs')" data-perm="logs">日志</button>
<button class="tab" onclick="showTab('users')" data-perm="admin">用户</button> <button class="tab" onclick="showTab('users')" data-perm="admin">用户</button>
@@ -322,15 +321,15 @@ select.form-control{cursor:pointer}
<table class="table"> <table class="table">
<thead><tr> <thead><tr>
<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 class="sortable" data-key="serial" onclick="sortBy('serial')">设备</th> <th data-sort="serial" onclick="_lpSort('devices','serial')">设备</th>
<th class="sortable" data-key="model" onclick="sortBy('model')">型号</th> <th data-sort="model" onclick="_lpSort('devices','model')">型号</th>
<th class="sortable" data-key="stf_occupied" onclick="sortBy('stf_occupied')">STF</th> <th data-sort="stf_occupied" onclick="_lpSort('devices','stf_occupied')">STF</th>
<th class="sortable" data-key="worker_status" onclick="sortBy('worker_status')">任务状态</th> <th data-sort="worker_status" onclick="_lpSort('devices','worker_status')">任务状态</th>
<th class="sortable" data-key="foreground_app" onclick="sortBy('foreground_app')">前台App</th> <th data-sort="foreground_app" onclick="_lpSort('devices','foreground_app')">前台App</th>
<th class="sortable" data-key="progress" onclick="sortBy('progress')">进度</th> <th data-sort="progress" onclick="_lpSort('devices','progress')">进度</th>
<th class="sortable" data-key="task_job" onclick="sortBy('task_job')">当前任务</th> <th data-sort="task_job" onclick="_lpSort('devices','task_job')">当前任务</th>
<th class="sortable" data-key="current_action" onclick="sortBy('current_action')">当前动作</th> <th data-sort="current_action" onclick="_lpSort('devices','current_action')">当前动作</th>
<th class="sortable" data-key="last_error" onclick="sortBy('last_error')">最近错误</th> <th data-sort="last_error" onclick="_lpSort('devices','last_error')">最近错误</th>
<th>操作</th> <th>操作</th>
</tr></thead> </tr></thead>
<tbody id="tb-devices"></tbody> <tbody id="tb-devices"></tbody>
@@ -370,7 +369,7 @@ select.form-control{cursor:pointer}
</div> </div>
<table class="table"> <table class="table">
<thead><tr> <thead><tr>
<th>ID</th><th>任务名</th><th>类型</th><th>启用</th><th>目标</th><th>调度</th><th>下次运行</th><th>重试</th><th>操作</th> <th data-sort="id" onclick="_lpSort('tasks','id')">ID</th><th data-sort="name" onclick="_lpSort('tasks','name')">任务名</th><th data-sort="task_type" onclick="_lpSort('tasks','task_type')">类型</th><th data-sort="enabled" onclick="_lpSort('tasks','enabled')">启用</th><th>目标</th><th>调度</th><th data-sort="next_run" onclick="_lpSort('tasks','next_run')">下次运行</th><th>重试</th><th>操作</th>
</tr></thead> </tr></thead>
<tbody id="tb-tasks"></tbody> <tbody id="tb-tasks"></tbody>
</table> </table>
@@ -386,67 +385,13 @@ select.form-control{cursor:pointer}
</div> </div>
<div class="help">把常用步骤打包成可复用的动作,拖入任务步骤编辑器使用;也可在任务编辑器里"☑选中步骤→打包"生成</div> <div class="help">把常用步骤打包成可复用的动作,拖入任务步骤编辑器使用;也可在任务编辑器里"☑选中步骤→打包"生成</div>
<table class="table"> <table class="table">
<thead><tr><th>图标</th><th>动作名</th><th>步骤数</th><th>创建时间</th><th>操作</th></tr></thead> <thead><tr><th>图标</th><th data-sort="name" onclick="_lpSort('actions','name')">动作名</th><th data-sort="steps" onclick="_lpSort('actions','steps')">步骤数</th><th data-sort="created_at" onclick="_lpSort('actions','created_at')">创建时间</th><th>操作</th></tr></thead>
<tbody id="tb-actions"></tbody> <tbody id="tb-actions"></tbody>
</table> </table>
<div class="list-pager" id="pager-actions"></div> <div class="list-pager" id="pager-actions"></div>
</div> </div>
</div> </div>
<!-- ========== Tab: 应用 ========== -->
<div id="tab-apps" class="tab-panel">
<div class="page-title">应用</div>
<div class="page-sub">APK 上传安装与设备已装应用查看</div>
<div class="sub-tabs">
<button class="sub-tab active" data-sub="apks" onclick="showSubTab('apps','apks')">应用管理</button>
<button class="sub-tab" data-sub="devapps" onclick="showSubTab('apps','devapps')">设备已装应用</button>
</div>
<!-- 子分栏 1:应用管理 -->
<div class="sub-panel active" id="apps-sub-apks">
<div class="toolbar">
<label class="btn btn-primary" style="cursor:pointer;margin-bottom:0">
上传APK
<input type="file" accept=".apk" style="display:none" onchange="uploadApk(this)">
</label>
<button class="btn" onclick="loadApks()">刷新</button>
<input class="form-control list-search" id="search-apks" placeholder="搜索应用名/包名...">
<span class="hint" id="apk-hint"></span>
</div>
<table class="table">
<thead><tr>
<th>应用名</th><th>包名</th><th>版本</th><th>大小</th><th>上传时间</th><th>操作</th>
</tr></thead>
<tbody id="tb-apks"></tbody>
</table>
<div class="list-pager" id="pager-apks"></div>
</div>
<!-- 子分栏 2:设备已装应用 -->
<div class="sub-panel" id="apps-sub-devapps">
<div class="toolbar">
<select id="apps-device" class="form-control" style="width:280px;display:inline-block">
<option value="">选择设备...</option>
</select>
<button class="btn btn-primary" onclick="loadDeviceApps()">查看应用</button>
<input class="form-control list-search" id="search-devapps" placeholder="搜索包名/路径...">
<span class="hint" id="apps-hint"></span>
</div>
<div style="max-height:420px;overflow:auto;border:1px solid var(--border);border-radius:8px">
<table class="table" style="margin:0">
<thead><tr>
<th>应用</th><th>版本号</th><th>versionCode</th><th>APK 路径</th>
</tr></thead>
<tbody id="tb-device-apps">
<tr><td colspan="4" class="empty">选择设备后点击"查看应用"</td></tr>
</tbody>
</table>
</div>
<div class="list-pager" id="pager-devapps"></div>
</div>
</div>
<!-- ========== Tab 3: 分组 ========== --> <!-- ========== Tab 3: 分组 ========== -->
<div id="tab-groups" class="tab-panel"> <div id="tab-groups" class="tab-panel">
<div class="page-title">设备分组管理</div> <div class="page-title">设备分组管理</div>
@@ -458,7 +403,7 @@ select.form-control{cursor:pointer}
</div> </div>
<table class="table"> <table class="table">
<thead><tr> <thead><tr>
<th>分组名</th><th>描述</th><th>设备数</th><th>设备列表</th><th>操作</th> <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> </tr></thead>
<tbody id="tb-groups"></tbody> <tbody id="tb-groups"></tbody>
</table> </table>
@@ -500,7 +445,7 @@ select.form-control{cursor:pointer}
</div> </div>
<table class="table"> <table class="table">
<thead><tr> <thead><tr>
<th>ID</th><th>用户名</th><th>管理员</th><th>权限</th><th>操作</th> <th data-sort="id" onclick="_lpSort('users','id')">ID</th><th data-sort="username" onclick="_lpSort('users','username')">用户名</th><th data-sort="is_admin" onclick="_lpSort('users','is_admin')">管理员</th><th>权限</th><th>操作</th>
</tr></thead> </tr></thead>
<tbody id="tb-users"></tbody> <tbody id="tb-users"></tbody>
</table> </table>
@@ -525,12 +470,16 @@ select.form-control{cursor:pointer}
<!-- ========== Tab 8: 工具(仅管理员) ========== --> <!-- ========== Tab 8: 工具(仅管理员) ========== -->
<div id="tab-tools" class="tab-panel"> <div id="tab-tools" class="tab-panel">
<div class="page-title">工具</div> <div class="page-title">工具</div>
<div class="page-sub">管理员专用:常用设备工具集合</div> <div class="page-sub">管理员专用:设备工具与应用管理集合</div>
<div class="sub-tabs"> <div class="sub-tabs">
<button class="sub-tab active" data-sub="clipboard" onclick="showSubTab('tools','clipboard')">剪贴板注入</button> <button class="sub-tab active" data-sub="clipboard" onclick="showSubTab('tools','clipboard')">剪贴板注入</button>
<button class="sub-tab" data-sub="adb" onclick="showSubTab('tools','adb')">adb 远程终端</button> <button class="sub-tab" data-sub="adb" onclick="showSubTab('tools','adb')">adb 远程终端</button>
<button class="sub-tab" data-sub="ts" onclick="showSubTab('tools','ts')">Tailscale 管理</button> <button class="sub-tab" data-sub="ts" onclick="showSubTab('tools','ts')">Tailscale 管理</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="devapps" onclick="showSubTab('tools','devapps')">设备已装应用</button>
<button class="sub-tab" data-sub="stfdev" onclick="showSubTab('tools','stfdev')">STF 设备管理</button>
</div> </div>
<!-- 子分栏 1:剪贴板注入 --> <!-- 子分栏 1:剪贴板注入 -->
@@ -612,13 +561,105 @@ select.form-control{cursor:pointer}
</div> </div>
<table class="table"> <table class="table">
<thead><tr> <thead><tr>
<th>主机名</th><th>显示名</th><th>IP</th><th>系统</th><th>在线</th><th>授权</th><th>密钥过期</th><th>操作</th> <th data-sort="hostname" onclick="_lpSort('tailscale','hostname')">主机名</th><th data-sort="name" onclick="_lpSort('tailscale','name')">显示名</th><th data-sort="ip" onclick="_lpSort('tailscale','ip')">IP</th><th data-sort="os" onclick="_lpSort('tailscale','os')">系统</th><th data-sort="online" onclick="_lpSort('tailscale','online')">在线</th><th data-sort="authorized" onclick="_lpSort('tailscale','authorized')">授权</th><th data-sort="key_expiry" onclick="_lpSort('tailscale','key_expiry')">密钥过期</th><th>操作</th>
</tr></thead> </tr></thead>
<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 随之变化,需同步 STF 设备池);"密钥不过期"= 关闭设备密钥验证,设备不会被定期踢下线;auth key 生成后只显示一次,请立即复制。</div>
</div> </div>
<!-- 子分栏 4:应用管理 -->
<div class="sub-panel" id="tools-sub-apks">
<div class="toolbar">
<label class="btn btn-primary" style="cursor:pointer;margin-bottom:0">
上传APK
<input type="file" accept=".apk" style="display:none" onchange="uploadApk(this)">
</label>
<button class="btn" onclick="loadApks()">刷新</button>
<input class="form-control list-search" id="search-apks" placeholder="搜索应用名/包名...">
<span class="hint" id="apk-hint"></span>
</div>
<table class="table">
<thead><tr>
<th data-sort="name" onclick="_lpSort('apks','name')">应用名</th><th data-sort="package_name" onclick="_lpSort('apks','package_name')">包名</th><th data-sort="version_name" onclick="_lpSort('apks','version_name')">版本</th><th data-sort="size" onclick="_lpSort('apks','size')">大小</th><th data-sort="upload_time" onclick="_lpSort('apks','upload_time')">上传时间</th><th>操作</th>
</tr></thead>
<tbody id="tb-apks"></tbody>
</table>
<div class="list-pager" id="pager-apks"></div>
</div>
<!-- 子分栏 5:应用版本管理 -->
<div class="sub-panel" id="tools-sub-appver">
<div class="toolbar">
<input type="text" id="appver-pkg" class="form-control" style="width:340px;font-family:monospace"
placeholder="包名,如 com.ss.android.ugc.aweme" onkeydown="if(event.key==='Enter')queryAppVers()">
<button class="btn btn-primary" onclick="queryAppVers()">查询版本</button>
<input class="form-control list-search" id="search-appver" placeholder="搜索设备/版本...">
<span id="appver-status" class="text-muted"></span>
</div>
<table class="table">
<thead><tr>
<th data-sort="serial" onclick="_lpSort('appver','serial')">设备</th>
<th data-sort="state" onclick="_lpSort('appver','state')">状态</th>
<th data-sort="version" onclick="_lpSort('appver','version')">版本号</th>
<th data-sort="code" onclick="_lpSort('appver','code')">versionCode</th>
</tr></thead>
<tbody id="tb-appver"><tr><td colspan="4" class="empty">输入包名后点击"查询版本"</td></tr></tbody>
</table>
<div class="list-pager" id="pager-appver"></div>
<div class="help">查询所有设备(本地 adb 含 USB + STF 池)上该包名的安装情况与版本号,用于对比各设备 App 版本是否一致;未连接的 STF 设备会自动连接(绝不断开,不影响任务)。</div>
</div>
<!-- 子分栏 6:设备已装应用 -->
<div class="sub-panel" id="tools-sub-devapps">
<div class="toolbar">
<select id="apps-device" class="form-control" style="width:280px;display:inline-block">
<option value="">选择设备...</option>
</select>
<button class="btn btn-primary" onclick="loadDeviceApps()">查看应用</button>
<input class="form-control list-search" id="search-devapps" placeholder="搜索包名/路径...">
<span class="hint" id="apps-hint"></span>
</div>
<div style="max-height:420px;overflow:auto;border:1px solid var(--border);border-radius:8px">
<table class="table" style="margin:0">
<thead><tr>
<th data-sort="label" onclick="_lpSort('devapps','label')">应用</th><th data-sort="version_name" onclick="_lpSort('devapps','version_name')">版本号</th><th data-sort="version_code" onclick="_lpSort('devapps','version_code')">versionCode</th><th>APK 路径</th>
</tr></thead>
<tbody id="tb-device-apps">
<tr><td colspan="4" class="empty">选择设备后点击"查看应用"</td></tr>
</tbody>
</table>
</div>
<div class="list-pager" id="pager-devapps"></div>
</div>
<!-- 子分栏 6:STF 设备管理 -->
<div class="sub-panel" id="tools-sub-stfdev">
<div class="toolbar">
<input type="text" id="stfdev-ip" class="form-control" style="width:220px;font-family:monospace"
placeholder="设备 IP,如 100.100.10.20" onkeydown="if(event.key==='Enter')addStfDev()">
<button class="btn btn-primary" onclick="addStfDev()">添加设备并连接</button>
<button class="btn" onclick="loadStfDevMgmt()">刷新</button>
<input class="form-control list-search" id="search-stfdev" placeholder="搜索 IP...">
<span id="stfdev-status" class="text-muted"></span>
</div>
<table class="table">
<thead><tr>
<th data-sort="ip" onclick="_lpSort('stfdev','ip')">设备</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>
<tbody id="tb-stfdev"><tr><td colspan="4" class="empty">点击"刷新"加载</td></tr></tbody>
</table>
<div class="list-pager" id="pager-stfdev"></div>
<div class="help">
管理 220 上 OpenSTF 设备池:添加 = 写入 connect_devices.sh 的 DEVICES 列表并立即 adb connect;
移除 = 从脚本删除并 adb disconnect(设备从 STF 池下线,任务将不再分配到它)。
脚本的 cron 每 5 分钟自动补连掉线设备。adb 操作发生在 220(STF provider 侧),不影响本机任务直连。
</div>
</div>
</div> </div>
</div><!-- /.content --> </div><!-- /.content -->
+125 -24
View File
@@ -23,7 +23,7 @@ import shlex
import re import re
import subprocess import subprocess
from datetime import datetime from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError as FuturesTimeout
from flask import (Flask, jsonify, request, redirect, url_for, render_template, from flask import (Flask, jsonify, request, redirect, url_for, render_template,
Response, make_response, session) Response, make_response, session)
@@ -38,9 +38,11 @@ from core.models import db, init_db, User, DeviceGroup, TaskJob, CustomAction
from core.apk_manager import ApkManager from core.apk_manager import ApkManager
from core.adb_helper import (screenshot, list_installed_apps, adb_connect_light, from core.adb_helper import (screenshot, list_installed_apps, adb_connect_light,
adb_connect, _ADB_LOCK, _adb) adb_connect, _ADB_LOCK, _adb)
from core import uiauto_helper, tailscale_client from core import uiauto_helper, tailscale_client, stf_device_mgmt, ssh_client
from core.tailscale_client import TailscaleError from core.tailscale_client import TailscaleError
from config import ADB_PATH, STF_SSH_TARGET, STF_DOCKER_CONTAINER from core.stf_device_mgmt import StfDevError
from core.ssh_client import SSHError
from config import ADB_PATH, STF_DOCKER_CONTAINER
from tasks import list_task_types, get_task_class from tasks import list_task_types, get_task_class
_log = get_logger("web") _log = get_logger("web")
@@ -879,13 +881,10 @@ def _blocked_adb_cmd(cmd):
return any(p in low for p in _ADB_BLOCKED_PATTERNS) return any(p in low for p in _ADB_BLOCKED_PATTERNS)
@app.route("/api/adb/devices") def _merged_device_list():
@admin_required """本地 adb(含 USB)+ STF 在线设备池的合并列表([{serial, state}])。
def api_adb_devices():
"""维护终端设备列表(仅管理员):本地 adb 已连接 + STF 在线设备池。
供终端设备选择器使用——选中后自动附加 `-s <serial>`, 供维护终端/剪贴板注入/应用版本管理等设备选择场景共用。
离线/未连接的设备也可选,配合"重连设备"按钮恢复。
""" """
states = {} states = {}
out = _adb("devices") out = _adb("devices")
@@ -900,8 +899,18 @@ def api_adb_devices():
states[serial] = "stf" states[serial] = "stf"
except Exception: except Exception:
pass pass
devices = [{"serial": s, "state": st} for s, st in sorted(states.items())] return [{"serial": s, "state": st} for s, st in sorted(states.items())]
return jsonify({"ok": True, "devices": devices})
@app.route("/api/adb/devices")
@admin_required
def api_adb_devices():
"""维护终端设备列表(仅管理员):本地 adb 已连接 + STF 在线设备池。
供终端设备选择器使用——选中后自动附加 `-s <serial>`,
离线/未连接的设备也可选,配合"重连设备"按钮恢复。
"""
return jsonify({"ok": True, "devices": _merged_device_list()})
@app.route("/api/adb/cmd", methods=["POST"]) @app.route("/api/adb/cmd", methods=["POST"])
@@ -992,6 +1001,103 @@ def _set_device_clipboard(serial, text):
return False, f"{type(e).__name__}: {str(e)[:120]}" return False, f"{type(e).__name__}: {str(e)[:120]}"
@app.route("/api/stf/devmgmt/status")
@admin_required
def api_stf_devmgmt_status():
"""STF 设备管理:脚本配置的 IP + 220 adb 实际连接状态。"""
try:
st = stf_device_mgmt.status()
return jsonify({"ok": True, **st})
except StfDevError as e:
return jsonify({"ok": False, "error": str(e)}), 502
@app.route("/api/stf/devmgmt/add", methods=["POST"])
@admin_required
def api_stf_devmgmt_add():
"""STF 设备管理:添加设备(写入脚本 + 立即 connect)。"""
ip = (request.json or {}).get("ip", "").strip()
if not ip:
return jsonify({"ok": False, "error": "请输入设备 IP"}), 400
try:
msgs = stf_device_mgmt.add_device(ip)
return jsonify({"ok": True, "msgs": msgs})
except StfDevError as e:
return jsonify({"ok": False, "error": str(e)}), 502
@app.route("/api/stf/devmgmt/remove", methods=["POST"])
@admin_required
def api_stf_devmgmt_remove():
"""STF 设备管理:移除设备(脚本删除 + disconnect)。"""
ip = (request.json or {}).get("ip", "").strip()
if not ip:
return jsonify({"ok": False, "error": "请输入设备 IP"}), 400
try:
msgs = stf_device_mgmt.remove_device(ip)
return jsonify({"ok": True, "msgs": msgs})
except StfDevError as e:
return jsonify({"ok": False, "error": str(e)}), 502
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(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)}
@app.route("/api/tools/appver", methods=["POST"])
@admin_required
def api_tools_appver():
"""工具-应用版本管理:查询所有设备上指定包名的安装情况与版本号。
请求: {"pkg": "com.ss.android.ugc.aweme"}
设备来源:本地 adb(含 USB)+ STF 在线池,并发查询(最多 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
devices = _merged_device_list()
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)})
@app.route("/api/stf/restart", methods=["POST"]) @app.route("/api/stf/restart", methods=["POST"])
@admin_required @admin_required
def api_stf_restart(): def api_stf_restart():
@@ -1006,22 +1112,17 @@ def api_stf_restart():
if running: if running:
return jsonify({"ok": False, return jsonify({"ok": False,
"error": f"有 {len(running)} 台设备正在运行任务,请先停止再维护"}), 409 "error": f"有 {len(running)} 台设备正在运行任务,请先停止再维护"}), 409
ssh_cmd = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
"-o", "StrictHostKeyChecking=no", STF_SSH_TARGET,
f"docker restart {STF_DOCKER_CONTAINER} && "
f"docker ps --filter name={STF_DOCKER_CONTAINER} --format '{{{{.Names}}}}: {{{{.Status}}}}'"]
try: try:
r = subprocess.run(ssh_cmd, capture_output=True, timeout=60) code, out, err = ssh_client.run(
except subprocess.TimeoutExpired: f"docker restart {STF_DOCKER_CONTAINER} && "
return jsonify({"ok": False, "error": "SSH 执行超时(60s)"}), 408 f"docker ps --filter name={STF_DOCKER_CONTAINER} --format '{{{{.Names}}}}: {{{{.Status}}}}'",
except FileNotFoundError: timeout=60)
return jsonify({"ok": False, "error": "本机未安装 ssh,无法远程操作"}), 500 except SSHError as e:
out = (r.stdout or b"").decode("utf-8", errors="replace") return jsonify({"ok": False, "error": str(e)}), 502
err = (r.stderr or b"").decode("utf-8", errors="replace") if code != 0:
if r.returncode != 0:
_log.error(f"STF 容器重启失败: {err or out}") _log.error(f"STF 容器重启失败: {err or out}")
return jsonify({"ok": False, return jsonify({"ok": False,
"error": f"SSH 执行失败:{err or out}(请确认本机可免密 SSH 到 {STF_SSH_TARGET},且容器名 {STF_DOCKER_CONTAINER} 正确)"}), 502 "error": f"SSH 执行失败:{err or out}(请确认 SSH 配置(STF_SSH_TARGET / STF_SSH_PASSWORD)且容器名 {STF_DOCKER_CONTAINER} 正确)"}), 502
# 轮询 STF API 确认恢复(最长 30s) # 轮询 STF API 确认恢复(最长 30s)
alive = False alive = False
for _ in range(30): for _ in range(30):