5 Commits
9 changed files with 844 additions and 174 deletions
+1 -3
View File
@@ -76,7 +76,6 @@ platform-tools/
├── config.py # 根配置(STF地址/token、adb路径、web端口) ├── config.py # 根配置(STF地址/token、adb路径、web端口)
├── web_server.py # Flask 入口(API + 登录 + 单页应用) ├── web_server.py # Flask 入口(API + 登录 + 单页应用)
├── main.py # 命令行入口(单设备跑一次,不走 web) ├── main.py # 命令行入口(单设备跑一次,不走 web)
├── create_task.py # 一次性脚本(创建定时任务示例)
├── requirements.txt # Python 依赖清单 ├── requirements.txt # Python 依赖清单
├── start_web.bat # Windows 一键启动(提权 + 防火墙) ├── start_web.bat # Windows 一键启动(提权 + 防火墙)
│ │
@@ -255,7 +254,7 @@ self.set_progress(done=5, total=80, unit="视频",
| 分组 | 设备分组管理:创建/编辑/删除分组 | 所有登录用户可看,写操作需"任务管理"权限 | | 分组 | 设备分组管理:创建/编辑/删除分组 | 所有登录用户可看,写操作需"任务管理"权限 |
| 日志 | 实时日志查看:按模块切换(core/task/web/action) | 需"日志查看"权限 | | 日志 | 实时日志查看:按模块切换(core/task/web/action) | 需"日志查看"权限 |
| 用户 | 用户管理:创建/删除/修改密码/分配权限 | 仅管理员 | | 用户 | 用户管理:创建/删除/修改密码/分配权限 | 仅管理员 |
| 维护 | STF 容器一键重启、adb 远程终端(设备快捷选择/自动 `-s`、卸载 STF agent、重连设备) | 仅管理员 | | 维护 | STF 容器一键重启、adb 远程终端(设备快捷选择/自动 `-s`、卸载 STF agent、重连设备)、Tailscale 管理(设备列表/改名/授权/密钥不过期/生成 auth key) | 仅管理员 |
### 用户与权限 ### 用户与权限
@@ -373,7 +372,6 @@ Web 后台"日志"Tab 可实时查看各模块日志。
|------|------| |------|------|
| `python web_server.py` | 启动 Web 管理后台 | | `python web_server.py` | 启动 Web 管理后台 |
| `python main.py` | 命令行跑一次抖音养号(单设备,不走 web) | | `python main.py` | 命令行跑一次抖音养号(单设备,不走 web) |
| `python create_task.py` | 创建"抖音定时8-9点"任务(示例脚本) |
| `python scripts/cleanup.py` | 清理 STF 残留设备占用(强杀进程后用) | | `python scripts/cleanup.py` | 清理 STF 残留设备占用(强杀进程后用) |
| `python scripts/pack.py` | 打包项目为 zip(排除日志/数据库/APK) | | `python scripts/pack.py` | 打包项目为 zip(排除日志/数据库/APK) |
| `start_web.bat` | Windows 一键启动(提权 + 防火墙规则) | | `start_web.bat` | Windows 一键启动(提权 + 防火墙规则) |
+9 -1
View File
@@ -31,7 +31,7 @@ def _env(key, default):
# ================== STF 配置 ================== # ================== STF 配置 ==================
STF_URL = _env("STF_URL", "http://192.168.20.220:7100") STF_URL = _env("STF_URL", "http://192.168.20.220:7100")
STF_TOKEN = _env("STF_TOKEN", "9024544b571647d3a6b09dab5dbfcb28bfd950f5b659400394e44e7217b7051b") STF_TOKEN = _env("STF_TOKEN", "") # 写入 .env,不要提交到 git
# ================== adb 路径 ================== # ================== adb 路径 ==================
# adb 二进制及依赖统一放在 bin/adb/ 下 # adb 二进制及依赖统一放在 bin/adb/ 下
@@ -70,3 +70,11 @@ AUTO_RELEASE_STALE_OCCUPY = _env("AUTO_RELEASE_STALE_OCCUPY", "false").lower() i
# 前置条件:本机可免密 SSH 到目标机(stf@220 需已授权本机公钥)。 # 前置条件:本机可免密 SSH 到目标机(stf@220 需已授权本机公钥)。
STF_SSH_TARGET = _env("STF_SSH_TARGET", "[email protected]") STF_SSH_TARGET = _env("STF_SSH_TARGET", "[email protected]")
STF_DOCKER_CONTAINER = _env("STF_DOCKER_CONTAINER", "stf") STF_DOCKER_CONTAINER = _env("STF_DOCKER_CONTAINER", "stf")
# ================== 维护(Tailscale 管理) ==================
# 维护页"Tailscale 管理"调用官方 API v2 管理 tailnet 设备。
# TAILSCALE_API_KEY:Tailscale 后台 → Settings → API Access Tokens 生成(或 OAuth client_id:secret)
# TAILSCALE_TAILNET:tailnet 名称/ID,个人账号一般是登录邮箱前缀
# 安全:密钥用环境变量/.env 注入,不要写死在代码或提交到 git。
TAILSCALE_API_KEY = _env("TAILSCALE_API_KEY", "") # 写入 .env,不要提交到 git
TAILSCALE_TAILNET = _env("TAILSCALE_TAILNET", "[email protected]")
+213
View File
@@ -0,0 +1,213 @@
"""Tailscale API v2 客户端封装(维护页"Tailscale 管理"用)。
能力:
- 列出 tailnet 全部设备(名称/主机名/IP/系统/在线/授权/密钥过期状态)
- 更新设备:显示名、主机名、授权开关、密钥不过期(keyExpiryDisabled)
- 生成设备接入 auth key(可配置 reusable/ephemeral/preauthorized/有效期)
- 删除设备
配置(config.py,支持 .env 注入,**不要提交密钥到 git**):
TAILSCALE_API_KEY — 管理后台 → Settings → API Access Tokens 生成的 key(或 OAuth client 的 client_id:secret)
TAILSCALE_TAILNET — tailnet 名称或 ID(个人账号一般是登录邮箱前缀,如 1422726308)
注意:设备 IP 由 tailnet 自动分配,API 无法修改;列表里的 addresses 只读展示。
参考:https://tailscale.com/api
"""
import requests
from config import TAILSCALE_API_KEY, TAILSCALE_TAILNET
from core.logger import get_logger
_log = get_logger("core.tailscale")
_API_BASE = "https://api.tailscale.com/api/v2"
_TIMEOUT = (3, 15)
class TailscaleError(Exception):
"""Tailscale API 错误(含 HTTP 状态码)。"""
def __init__(self, message, code=""):
super().__init__(message)
self.code = code
class TailscaleClient:
"""Tailscale API v2 客户端。
认证:Basic Auth,API key 作为用户名、空密码;
也可传 OAuth client 的 "client_id:client_secret" 作为 key。
"""
def __init__(self, api_key=None, tailnet=None):
self.api_key = api_key or TAILSCALE_API_KEY
self.tailnet = tailnet or TAILSCALE_TAILNET
# ================== 配置状态 ==================
def is_configured(self):
"""是否已配置 API key 与 tailnet。"""
return bool(self.api_key and self.tailnet)
def config_hint(self):
"""未配置时的提示文案。"""
missing = []
if not self.api_key:
missing.append("TAILSCALE_API_KEY(Tailscale 后台 → Settings → API Access Tokens)")
if not self.tailnet:
missing.append("TAILSCALE_TAILNET(tailnet 名,个人账号一般是邮箱前缀)")
return ";".join(missing)
# ================== 请求基座 ==================
def _headers(self):
return {"Authorization": f"Basic {self._basic()}"}
def _basic(self):
import base64
return base64.b64encode(f"{self.api_key}:".encode()).decode()
def _get(self, path):
if not self.is_configured():
raise TailscaleError(f"未配置:{self.config_hint()}", "config")
try:
r = requests.get(f"{_API_BASE}{path}", headers=self._headers(), timeout=_TIMEOUT)
except requests.exceptions.ConnectionError as e:
raise TailscaleError(f"Tailscale API 不可达: {e}", "network") from e
except requests.exceptions.Timeout as e:
raise TailscaleError("Tailscale API 请求超时", "network") from e
return self._handle(r)
def _post(self, path, body):
if not self.is_configured():
raise TailscaleError(f"未配置:{self.config_hint()}", "config")
try:
r = requests.post(f"{_API_BASE}{path}", json=body,
headers=self._headers(), timeout=_TIMEOUT)
except requests.exceptions.ConnectionError as e:
raise TailscaleError(f"Tailscale API 不可达: {e}", "network") from e
except requests.exceptions.Timeout as e:
raise TailscaleError("Tailscale API 请求超时", "network") from e
return self._handle(r)
def _handle(self, r):
try:
data = r.json()
except Exception:
data = {}
if r.status_code == 401:
raise TailscaleError("API key 无效或已过期(401)", "auth")
if r.status_code == 404:
raise TailscaleError("tailnet 不存在或无权访问(404),请检查 TAILSCALE_TAILNET", "notfound")
if r.status_code >= 400:
msg = data.get("message") or str(data)[:200]
_log.error(f"Tailscale API 错误 {r.status_code}: {msg}")
raise TailscaleError(f"Tailscale API 错误 {r.status_code}: {msg}", "api")
return data
# ================== 设备 ==================
def list_devices(self):
"""列出 tailnet 全部设备。
返回 [{id, name, hostname, os, addresses[], authorized,
key_expiry_disabled, online, last_seen, ...}](字段已规整)。
"""
data = self._get(f"/tailnet/{self.tailnet}/devices")
devices = []
for d in data.get("devices", []):
online = d.get("online")
devices.append({
"id": d.get("id", ""),
"name": d.get("name", ""),
"hostname": d.get("hostname", ""),
"os": d.get("os", ""),
"addresses": d.get("addresses", []),
"authorized": bool(d.get("authorized")),
"key_expiry_disabled": bool(d.get("keyExpiryDisabled")),
# online 三态:True=在线 / False=离线 / None=最近 12 小时内见过但当前未上报(API 返回 null)。
# 注意不能 bool(null)——那会把"最近在线"误显示成"离线"。
"online": online if online is not None else None,
"last_seen": d.get("lastSeen", ""),
"tags": d.get("tags", []),
})
return devices
def set_device_name(self, device_id, name):
"""修改设备显示名(POST /device/{id}/name)。
注意:POST /device/{id} 是 405,改名/授权/密钥各有专属端点。
"""
if not name or not str(name).strip():
raise TailscaleError("名称不能为空")
self._post(f"/device/{device_id}/name", {"name": str(name).strip()})
return True
def set_device_authorized(self, device_id, authorized):
"""授权/取消授权(POST /device/{id}/authorized)。"""
self._post(f"/device/{device_id}/authorized", {"authorized": bool(authorized)})
return True
def set_device_key_expiry(self, device_id, disabled):
"""关闭/恢复设备密钥过期(POST /device/{id}/key)。
disabled=True 即"密钥不过期"(设备不会被定期踢下线);
恢复过期后按原定过期时间执行,若已过期需重新授权。
"""
self._post(f"/device/{device_id}/key", {"keyExpiryDisabled": bool(disabled)})
return True
def set_device_ip(self, device_id, ipv4):
"""为设备设置新的 Tailscale IPv4 地址。
端点 POST /device/{device_id}/ip 实测存在(官方文档未收录,属未公开接口)。
注意:
- 修改 IP 会断开该设备当前的 tailscale 会话,几秒后以新 IP 重连
- 平台设备池(STF serial)用的就是 tailnet IP,改完需同步更新 STF 设备池
- IPv6 无公开 API 可改
"""
if not ipv4 or not str(ipv4).strip():
raise TailscaleError("IPv4 地址不能为空")
self._post(f"/device/{device_id}/ip", {"ipv4": str(ipv4).strip()})
return True
def delete_device(self, device_id):
"""从 tailnet 移除设备(下次设备上线需重新授权)。"""
if not self.is_configured():
raise TailscaleError(f"未配置:{self.config_hint()}", "config")
try:
r = requests.delete(f"{_API_BASE}/device/{device_id}",
headers=self._headers(), timeout=_TIMEOUT)
except requests.exceptions.ConnectionError as e:
raise TailscaleError(f"Tailscale API 不可达: {e}", "network") from e
return self._handle(r)
# ================== Auth key ==================
def create_auth_key(self, description="auto_control", reusable=False,
ephemeral=False, preauthorized=True, expiry_seconds=3600):
"""生成设备接入 auth key。
返回 {id, key, expires}。key 只显示这一次,请立即复制保存。
preauthorized=True:新设备接入自动授权(免去后台手动点授权)。
"""
body = {
"description": description,
"expirySeconds": max(60, int(expiry_seconds)),
"capabilities": {
"devices": {
"create": {
"reusable": bool(reusable),
"ephemeral": bool(ephemeral),
"preauthorized": bool(preauthorized),
}
}
},
}
data = self._post(f"/tailnet/{self.tailnet}/keys", body)
return {
"id": data.get("id", ""),
"key": data.get("key", ""),
"expires": data.get("expires", ""),
}
# 模块级单例(与 stf_client 的用法一致)
tailscale = TailscaleClient()
-85
View File
@@ -1,85 +0,0 @@
"""一次性脚本:创建"抖音定时8-9点"任务。
用法(在 cmd 中执行,不是 PowerShell):
cd /d d:\\platform-tools
python create_task.py
任务配置:
- 名称:抖音定时8-9点
- 类型:douyin_nurture(抖音养号)
- 目标:全部空闲设备
- 调度:定时启停(每天 8:00 启动,9:00 自动停止)
- 视频数量:不限(watch_count=0,靠停止 cron 到点停)
- 运行时长:不限(max_duration=0,靠停止 cron 到点停)
- 点赞:启用,rate=0.3
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from flask import Flask
from core.models import db, init_db, TaskJob as JobRow
from core.task_manager import TaskManager, TaskJob
from core.stf_client import STFClient
app = Flask(__name__)
app.config["SECRET_KEY"] = "dev-secret-key-change-in-production"
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join(
os.path.dirname(os.path.abspath(__file__)), "data", "users.db")
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
init_db(app)
with app.app_context():
# 检查是否已存在同名任务
existing = JobRow.query.filter_by(name="抖音定时8-9点").first()
if existing:
print(f"任务已存在: id={existing.id}, name={existing.name}")
print("如需重新创建,请先在前端删除该任务。")
sys.exit(0)
# 构造任务计划
job = TaskJob(
name="抖音定时8-9点",
task_type="douyin_nurture",
target={"mode": "all"},
params={
"watch_count": 0, # 不限视频数量
"max_duration": 0, # 不限运行时长(靠 stop_cron 到点停止)
"actions": {
"like": {
"enabled": True,
"params": {"rate": 0.3, "method": "by_element"},
},
},
},
schedule={
"mode": "cron_stop",
"cron": "0 8 * * *", # 每天 8:00 启动
"stop_cron": "0 9 * * *", # 每天 9:00 停止
},
retry={"max_attempts": 1, "delay": 60},
enabled=True,
)
# 写入数据库
row = JobRow(id=job.id, name=job.name, task_type=job.task_type, enabled=job.enabled)
row.set_target(job.target)
row.set_params(job.params)
row.set_schedule(job.schedule)
row.set_retry(job.retry)
db.session.add(row)
db.session.commit()
print(f"✓ 任务创建成功!")
print(f" ID: {job.id}")
print(f" 名称: {job.name}")
print(f" 类型: {job.task_type}")
print(f" 目标: 全部空闲设备")
print(f" 调度: 每天 08:00 启动 → 09:00 自动停止")
print(f" 视频数量: 不限(靠定时停止)")
print(f" 点赞: 启用 (rate=0.3)")
print()
print("启动 web_server 后,该任务会自动加载并注册定时。")
print("启动命令: python web_server.py")
+72
View File
@@ -667,3 +667,75 @@ adb 远程终端:用平台 adb 二进制执行任意 adb 命令(20s 超时
``` ```
`result`:`命中` / `未找到` / `已执行`(无选择器命中语义的步骤)。 `result`:`命中` / `未找到` / `已执行`(无选择器命中语义的步骤)。
无"设备控制"权限返回 403。 无"设备控制"权限返回 403。
---
## 15. Tailscale 管理(仅管理员)
维护页"Tailscale 管理"分区,调用 Tailscale 官方 API v2。所有接口仅管理员可用。
前置:`.env` 配置 `TAILSCALE_API_KEY`(Settings → API Access Tokens)与
`TAILSCALE_TAILNET`(tailnet 名,个人账号一般为邮箱前缀);未配置返回 502 并附提示。
设备 IP 由 tailnet 分配,API 不可修改,列表只读展示。
### GET /api/tailscale/status
配置状态检查。
**响应**:
```json
{"ok": true, "configured": false, "hint": "TAILSCALE_API_KEY(...);TAILSCALE_TAILNET(...)"}
```
### GET /api/tailscale/devices
列出 tailnet 全部设备。
**响应**:
```json
{"ok": true, "devices": [
{"id": "d1", "name": "dev-a", "hostname": "dev-a", "os": "linux",
"addresses": ["100.100.10.11"], "authorized": true,
"key_expiry_disabled": false, "online": true, "last_seen": "...", "tags": []}
]}
```
### POST /api/tailscale/devices/:device_id
更新设备(按传入字段分发到 Tailscale 专属端点,`POST /device/{id}` 本身是 405):
`name` → `/name` 显示名;`authorized` → `/authorized` 授权开关;
`key_expiry_disabled` → `/key`(true=密钥永不过期,即关闭设备密钥验证,恢复后按原定过期时间执行)。
**请求**(JSON):
```json
{"key_expiry_disabled": true}
```
### POST /api/tailscale/devices/:device_id/ip
设置设备的 Tailscale IPv4 地址(未公开端点,实测可用)。
**请求**(JSON):
```json
{"ipv4": "100.100.10.16"}
```
⚠ 改 IP 会断开设备当前 tailscale 会话;平台设备池 serial 随之变化,需同步更新 STF 设备池/分组/任务目标。
### DELETE /api/tailscale/devices/:device_id
从 tailnet 移除设备(下次上线需重新授权)。
### POST /api/tailscale/authkey
生成设备接入 auth key(key 只返回一次)。
**请求**(JSON):
```json
{"description": "新设备接入", "reusable": false, "ephemeral": false,
"preauthorized": true, "expiry_seconds": 3600}
```
**响应**:
```json
{"ok": true, "key": "tskey-auth-...", "id": "k1", "expires": "2026-08-11T01:00:00Z"}
```
+16 -2
View File
@@ -79,11 +79,20 @@ pip install -r requirements.txt
### 2.3 修改配置 ### 2.3 修改配置
编辑 `config.py`,**必须修改**以下两项: **密钥类配置统一放项目根目录 `.env`**(已被 `.gitignore` 排除,不会提交到 git;
`config.py` 不再内置任何密钥):
```ini
# .env 示例
STF_TOKEN=你的STF_API_Token
WEB_SECRET_KEY=随机字符串(会话密钥,python -c "import secrets;print(secrets.token_hex(32))" 生成)
TAILSCALE_API_KEY=你的Tailscale_API_key
```
`config.py` 只改非密钥项,如 `STF_URL`:
```python ```python
STF_URL = "http://你的STF地址:端口" STF_URL = "http://你的STF地址:端口"
STF_TOKEN = "你的STF_API_Token"
``` ```
其他配置按需调整: 其他配置按需调整:
@@ -95,6 +104,11 @@ STF_TOKEN = "你的STF_API_Token"
| `ADB_PATH` | 自动识别 | 用自定义 adb 时修改 | | `ADB_PATH` | 自动识别 | 用自定义 adb 时修改 |
| `STF_SSH_TARGET` | `[email protected]` | 维护页"一键重启 STF 容器"的 SSH 目标(需免密登录) | | `STF_SSH_TARGET` | `[email protected]` | 维护页"一键重启 STF 容器"的 SSH 目标(需免密登录) |
| `STF_DOCKER_CONTAINER` | `stf` | 220 上 STF Docker 容器名(`docker ps` 查看) | | `STF_DOCKER_CONTAINER` | `stf` | 220 上 STF Docker 容器名(`docker ps` 查看) |
| `TAILSCALE_TAILNET` | 按邮箱前缀 | tailnet 名称/ID(个人账号一般为登录邮箱前缀) |
> **未配置 `WEB_SECRET_KEY`**:启动时随机生成(每次重启登录态失效,生产务必配置固定值)。
> **维护页 Tailscale 前置**:`.env` 写入 `TAILSCALE_API_KEY` 后重启服务;
> 未配置时管理分区显示明确提示,不影响其他功能。
> **维护页 STF 重启前置**:本机需能免密 SSH 到部署机(把本机公钥加入目标机 `authorized_keys`), > **维护页 STF 重启前置**:本机需能免密 SSH 到部署机(把本机公钥加入目标机 `authorized_keys`),
> 且 `STF_DOCKER_CONTAINER` 与真实容器名一致;未配置好时按钮会返回明确错误。 > 且 `STF_DOCKER_CONTAINER` 与真实容器名一致;未配置好时按钮会返回明确错误。
+196 -4
View File
@@ -281,20 +281,86 @@ 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'].indexOf(name)].classList.add('active'); document.querySelectorAll('.navbar .tab')[['monitor','tasks','apps','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'){loadTasks();loadCustomActions();} if(name==='tasks'){showSubTab('tasks',_activeSubs.tasks);loadTasks();loadCustomActions();}
if(name==='apps')loadApks(); if(name==='apps'){showSubTab('apps',_activeSubs.apps);loadApks();}
if(name==='maintenance')loadAdbDevices(); if(name==='maintenance'){showSubTab('maintenance',_activeSubs.maintenance);loadAdbDevices();loadTailscaleDevices();}
if(name==='tools'){showSubTab('tools',_activeSubs.tools);loadToolsDevices();}
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 记住上次选中的子分栏,切走再切回来保持原位
let _activeSubs = {tasks: 'plan', apps: 'apks', maintenance: 'stf', tools: 'clipboard'};
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));
}
// ================== 工具:剪贴板注入 ==================
let _clipDevices = []; // [{serial, state}],来源 /api/adb/devices(本地 adb + STF 池)
let _clipSelected = new Set();
function _escHtml(s){
return String(s==null?'':s).replace(/[&<>"']/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
async function loadToolsDevices(){
const r = await apiGet('/api/adb/devices');
const status = document.getElementById('clip-status');
if(!r||!r.ok){status.textContent='加载失败';showToast('加载设备失败: '+(r&&r.error||''),'error');return;}
_clipDevices = r.devices||[];
const wrap = document.getElementById('clip-devices');
if(!_clipDevices.length){
wrap.innerHTML='<div class="empty" style="padding:20px">无可用设备(本地 adb 或 STF 池均无)</div>';
status.textContent='';
return;
}
wrap.innerHTML = _clipDevices.map(d=>{
const st = d.state==='stf' ? 'STF池' : (d.state==='device' ? '已连接' : d.state);
const stCls = (d.state==='device'||d.state==='stf') ? 'label-success' : 'label-warning';
return '<label data-serial="'+_escHtml(d.serial)+'">'
+'<input type="checkbox" value="'+_escHtml(d.serial)+'"'+( _clipSelected.has(d.serial)?' checked':'')+' onchange="toggleClipDevice(this)">'
+'<span style="font-family:monospace;flex:1;overflow:hidden;text-overflow:ellipsis">'+_escHtml(d.serial)+'</span>'
+'<span class="label '+stCls+'">'+st+'</span></label>';
}).join('');
status.textContent = '共 '+_clipDevices.length+' 台设备';
}
function toggleClipDevice(cb){
if(cb.checked)_clipSelected.add(cb.value);
else _clipSelected.delete(cb.value);
}
async function injectClipboard(){
const text = document.getElementById('clip-text').value.trim();
if(!text){showToast('请输入要注入的文字','error');return;}
if(!_clipSelected.size){showToast('请先勾选目标设备','error');return;}
const btn = document.querySelector('#tools-sub-clipboard .btn-primary');
btn.disabled = true; const old = btn.textContent; btn.textContent = '注入中...';
try{
const r = await apiPost('/api/tools/clipboard/set', {serials:[..._clipSelected], text});
if(!r||!r.ok){showToast('注入失败: '+(r&&r.error||''),'error');return;}
const rows = Object.entries(r.results).map(([s,v])=>
'<tr><td style="font-family:monospace">'+_escHtml(s)+'</td><td>'+(v.ok?'<span class="label label-success">成功</span>':'<span class="label label-danger">失败</span> '+_escHtml(v.msg))+'</td></tr>').join('');
document.getElementById('clip-result').innerHTML = rows;
document.getElementById('clip-result-wrap').style.display = 'block';
showToast('注入完成:成功 '+r.ok_count+' 台,失败 '+r.fail_count+' 台', r.fail_count?'error':'success');
} finally {
btn.disabled = false; btn.textContent = old;
}
}
// ================== 通用列表:搜索 + 分页 ================== // ================== 通用列表:搜索 + 分页 ==================
// 用法: // 用法:
// setListPager(key,{data:()=>array, filter:(item,kw)=>bool, render:(item)=>html, // setListPager(key,{data:()=>array, filter:(item,kw)=>bool, render:(item)=>html,
@@ -2267,6 +2333,132 @@ function closeTestModal(){
if(ov)ov.classList.remove('show'); if(ov)ov.classList.remove('show');
} }
// ================== 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)),
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 连接,且平台设备池(STF serial)随之变化,改完记得同步 STF 设备池/分组/任务目标。', (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: 应用 ================== // ================== Tab: 应用 ==================
let _apksCache=[]; let _apksCache=[];
let _installTimer=null; let _installTimer=null;
+175 -74
View File
@@ -63,6 +63,15 @@ body{background:var(--bg);font-family:-apple-system,"Segoe UI",Roboto,"PingFang
/* 区块标题 */ /* 区块标题 */
.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-size:15px;font-weight:600;color:var(--text);margin:0 0 12px;padding-bottom:8px;border-bottom:2px solid var(--primary);display:inline-block}
/* 页内子分栏(维护页用) */
.sub-tabs{display:flex;gap:4px;margin-bottom:16px;border-bottom:2px solid var(--border)}
.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:hover{color:var(--text);background:#f1f5f9}
.sub-tab.active{color:var(--primary);background:#eff6ff}
.sub-tab.active::after{content:"";position:absolute;left:10px;right:10px;bottom:-2px;height:2px;background:var(--primary);border-radius:1px}
.sub-panel{display:none}
.sub-panel.active{display:block}
/* 表格 */ /* 表格 */
.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: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 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:#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}
@@ -273,6 +282,7 @@ select.form-control{cursor:pointer}
<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>
<button class="tab" onclick="showTab('maintenance')" data-perm="admin">维护</button> <button class="tab" onclick="showTab('maintenance')" data-perm="admin">维护</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>
@@ -334,30 +344,38 @@ select.form-control{cursor:pointer}
<!-- ========== Tab 2: 任务 ========== --> <!-- ========== Tab 2: 任务 ========== -->
<div id="tab-tasks" class="tab-panel"> <div id="tab-tasks" 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="toolbar">
<button class="btn btn-primary" onclick="openTaskModal()" data-perm="tasks">新建任务</button>
<button class="btn" onclick="loadTasks()">刷新</button>
<input class="form-control list-search" id="search-tasks" placeholder="搜索任务名/类型/ID...">
</div>
<table class="table">
<thead><tr>
<th>ID</th><th>任务名</th><th>类型</th><th>启用</th><th>目标</th><th>调度</th><th>下次运行</th><th>重试</th><th>操作</th>
</tr></thead>
<tbody id="tb-tasks"></tbody>
</table>
<div class="list-pager" id="pager-tasks"></div>
<!-- 自定义动作管理 --> <div class="sub-tabs">
<div style="margin-top:28px"> <button class="sub-tab active" data-sub="plan" onclick="showSubTab('tasks','plan')">任务计划</button>
<div class="page-title" style="font-size:16px">自定义动作管理</div> <button class="sub-tab" data-sub="actions" onclick="showSubTab('tasks','actions')">自定义动作</button>
<div class="page-sub">把常用步骤打包成可复用的动作,拖入任务步骤编辑器使用;也可在任务编辑器里"☑选中步骤→打包"生成</div> </div>
<!-- 子分栏 1:任务计划 -->
<div class="sub-panel active" id="tasks-sub-plan">
<div class="toolbar">
<button class="btn btn-primary" onclick="openTaskModal()" data-perm="tasks">新建任务</button>
<button class="btn" onclick="loadTasks()">刷新</button>
<input class="form-control list-search" id="search-tasks" placeholder="搜索任务名/类型/ID...">
</div>
<table class="table">
<thead><tr>
<th>ID</th><th>任务名</th><th>类型</th><th>启用</th><th>目标</th><th>调度</th><th>下次运行</th><th>重试</th><th>操作</th>
</tr></thead>
<tbody id="tb-tasks"></tbody>
</table>
<div class="list-pager" id="pager-tasks"></div>
</div>
<!-- 子分栏 2:自定义动作 -->
<div class="sub-panel" id="tasks-sub-actions">
<div class="toolbar"> <div class="toolbar">
<button class="btn btn-primary" onclick="openActionModal()" data-perm="tasks">新建动作</button> <button class="btn btn-primary" onclick="openActionModal()" data-perm="tasks">新建动作</button>
<button class="btn" onclick="loadCustomActions()">刷新</button> <button class="btn" onclick="loadCustomActions()">刷新</button>
<input class="form-control list-search" id="search-actions" placeholder="搜索动作名..."> <input class="form-control list-search" id="search-actions" placeholder="搜索动作名...">
</div> </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>动作名</th><th>步骤数</th><th>创建时间</th><th>操作</th></tr></thead>
<tbody id="tb-actions"></tbody> <tbody id="tb-actions"></tbody>
@@ -368,29 +386,36 @@ select.form-control{cursor:pointer}
<!-- ========== Tab: 应用 ========== --> <!-- ========== Tab: 应用 ========== -->
<div id="tab-apps" class="tab-panel"> <div id="tab-apps" class="tab-panel">
<div class="page-title">应用管理</div> <div class="page-title">应用</div>
<div class="page-sub">上传 APK 文件,批量安装到设备(运行中设备自动跳过)</div> <div class="page-sub">APK 上传安装与设备已装应用查看</div>
<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 class="sub-tabs">
<div style="margin-top:28px"> <button class="sub-tab active" data-sub="apks" onclick="showSubTab('apps','apks')">应用管理</button>
<div class="page-title" style="font-size:16px">设备已装应用</div> <button class="sub-tab" data-sub="devapps" onclick="showSubTab('apps','devapps')">设备已装应用</button>
<div class="page-sub">查看某台设备上安装的所有应用(含版本号)</div> </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"> <div class="toolbar">
<select id="apps-device" class="form-control" style="width:280px;display:inline-block"> <select id="apps-device" class="form-control" style="width:280px;display:inline-block">
<option value="">选择设备...</option> <option value="">选择设备...</option>
@@ -476,47 +501,123 @@ select.form-control{cursor:pointer}
<!-- ========== Tab 7: 维护(仅管理员) ========== --> <!-- ========== Tab 7: 维护(仅管理员) ========== -->
<div id="tab-maintenance" class="tab-panel"> <div id="tab-maintenance" class="tab-panel">
<div class="page-title">维护</div> <div class="page-title">维护</div>
<div class="page-sub">管理员专用:STF 服务管理与 adb 远程终端</div> <div class="page-sub">管理员专用:STF 服务、adb 远程终端与 Tailscale 设备管理</div>
<div class="section-title">STF 服务</div> <div class="sub-tabs">
<div class="toolbar"> <button class="sub-tab active" data-sub="stf" onclick="showSubTab('maintenance','stf')">STF 服务</button>
<button class="btn btn-danger" onclick="restartStf()">一键重启 STF 容器</button> <button class="sub-tab" data-sub="adb" onclick="showSubTab('maintenance','adb')">adb 远程终端</button>
<span id="stf-restart-status" class="text-muted" style="margin-left:10px"></span> <button class="sub-tab" data-sub="ts" onclick="showSubTab('maintenance','ts')">Tailscale 管理</button>
</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>
<div class="section-title" style="margin-top:16px">adb 远程终端</div> <!-- 子分栏 1:STF 服务 -->
<div class="help">选中目标设备后,命令里的 <code>-s &lt;设备&gt;</code> 自动附加,直接写 <div class="sub-panel active" id="maintenance-sub-stf">
<code>shell ls /sdcard</code> 即可。离线设备也可选,用"重连设备"恢复。 <div class="toolbar">
<b>禁止 kill-server / disconnect</b>(会中断 STF provider 的设备监控)。</div> <button class="btn btn-danger" onclick="restartStf()">一键重启 STF 容器</button>
<div style="display:flex;gap:8px;margin:8px 0;align-items:center"> <span id="stf-restart-status" class="text-muted"></span>
<label style="font-size:12px;color:var(--text-light)">目标设备</label> </div>
<select id="adb-device" class="form-control" style="flex:1;max-width:340px;font-family:monospace"></select> <div class="help" style="margin-top:8px">
<button class="btn btn-xs" onclick="loadAdbDevices()" title="刷新设备列表">刷新</button> 通过 SSH 到部署机执行 <code>docker restart</code>(目标与容器名可用环境变量
<code>STF_SSH_TARGET</code> / <code>STF_DOCKER_CONTAINER</code> 配置)。
影响:STF 约 30 秒不可用,期间设备占用状态重置;<b>有任务在运行时会被拒绝执行</b>。
</div>
</div> </div>
<div style="display:flex;gap:8px;margin:8px 0">
<input type="text" id="adb-cmd-input" class="form-control" style="flex:1;font-family:monospace" <!-- 子分栏 2:adb 远程终端 -->
placeholder="shell ls /sdcard(选中设备后 -s 自动附加)" <div class="sub-panel" id="maintenance-sub-adb">
onkeydown="if(event.key==='Enter')runAdbCmd()"> <div class="help">选中目标设备后,命令里的 <code>-s &lt;设备&gt;</code> 自动附加,直接写
<button class="btn btn-primary" onclick="runAdbCmd()">执行</button> <code>shell ls /sdcard</code> 即可。离线设备也可选,用"重连设备"恢复。
<b>禁止 kill-server / disconnect</b>(会中断 STF provider 的设备监控)。</div>
<div style="display:flex;gap:8px;margin:8px 0;align-items:center">
<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>
<button class="btn btn-xs" onclick="loadAdbDevices()" title="刷新设备列表">刷新</button>
</div>
<div style="display:flex;gap:8px;margin:8px 0">
<input type="text" id="adb-cmd-input" class="form-control" style="flex:1;font-family:monospace"
placeholder="shell ls /sdcard(选中设备后 -s 自动附加)"
onkeydown="if(event.key==='Enter')runAdbCmd()">
<button class="btn btn-primary" onclick="runAdbCmd()">执行</button>
</div>
<div style="display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap">
<button class="btn btn-xs" onclick="fillAdbCmd('adb devices')">adb devices</button>
<button class="btn btn-xs" onclick="fillAdbCmd('shell dumpsys window | grep mCurrentFocus')">前台App</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 pm list packages -3')">已装应用</button>
</div>
<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="checkStfAgent()" title="查看 jp.co.cyberagent.stf 是否已安装">检查 agent</button>
</div>
<pre id="adb-output" class="adb-console">(执行命令后输出显示在这里)</pre>
</div> </div>
<div style="display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap">
<button class="btn btn-xs" onclick="fillAdbCmd('adb devices')">adb devices</button> <!-- 子分栏 3:Tailscale 管理 -->
<button class="btn btn-xs" onclick="fillAdbCmd('shell dumpsys window | grep mCurrentFocus')">前台App</button> <div class="sub-panel" id="maintenance-sub-ts">
<button class="btn btn-xs" onclick="fillAdbCmd('shell ls /sdcard')">列文件</button> <div class="help" id="ts-hint"></div>
<button class="btn btn-xs" onclick="fillAdbCmd('shell screencap -p /sdcard/shot.png')">截屏</button> <div class="toolbar" style="margin:8px 0">
<button class="btn btn-xs" onclick="fillAdbCmd('shell pm list packages -3')">已装应用</button> <button class="btn btn-primary" onclick="loadTailscaleDevices()">刷新设备</button>
<button class="btn" onclick="openTailscaleAuthKey()">生成接入 Auth Key</button>
<input class="form-control list-search" id="search-tailscale" placeholder="搜索主机名/名称/IP...">
<span id="ts-status" class="text-muted" style="margin-left:10px"></span>
</div>
<table class="table">
<thead><tr>
<th>主机名</th><th>显示名</th><th>IP</th><th>系统</th><th>在线</th><th>授权</th><th>密钥过期</th><th>操作</th>
</tr></thead>
<tbody id="tb-tailscale"><tr><td colspan="8" class="empty">点击"刷新设备"加载</td></tr></tbody>
</table>
<div class="list-pager" id="pager-tailscale"></div>
<div class="help">"设置IP"可修改设备 IPv4(会断开当前连接,平台 serial 随之变化,需同步 STF 设备池);"密钥不过期"= 关闭设备密钥验证,设备不会被定期踢下线;auth key 生成后只显示一次,请立即复制。</div>
</div> </div>
<div style="display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap"> </div>
<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> <!-- ========== Tab 8: 工具(仅管理员) ========== -->
<button class="btn btn-xs" onclick="checkStfAgent()" title="查看 jp.co.cyberagent.stf 是否已安装">检查 agent</button> <div id="tab-tools" class="tab-panel">
<div class="page-title">工具</div>
<div class="page-sub">管理员专用:常用设备工具集合</div>
<div class="sub-tabs">
<button class="sub-tab active" data-sub="clipboard" onclick="showSubTab('tools','clipboard')">剪贴板注入</button>
</div>
<!-- 子分栏 1:剪贴板注入 -->
<div class="sub-panel active" id="tools-sub-clipboard">
<div class="toolbar">
<button class="btn" onclick="loadToolsDevices()">刷新设备</button>
<span id="clip-status" class="text-muted"></span>
</div>
<div style="display:flex;gap:20px;flex-wrap:wrap">
<div style="flex:1;min-width:340px">
<div class="form-group">
<label>注入内容</label>
<textarea id="clip-text" class="form-control" rows="6"
placeholder="要注入到设备剪贴板的文字(支持中文/引号/换行)"></textarea>
</div>
<div class="toolbar" style="margin-bottom:8px">
<button class="btn btn-primary" onclick="injectClipboard()">注入剪贴板</button>
<span class="hint">注入后需在目标 App 的输入框长按选择"粘贴"</span>
</div>
<div class="help">实现:u2 setClipboard(jsonrpc 直连 atx-agent),实测支持中文、引号、换行;USB 设备首次连接会自动推送 atx-agent。</div>
</div>
<div style="flex:1;min-width:280px">
<div class="form-group">
<label>目标设备(本地 adb 含 USB + STF 池,可多选)</label>
<div class="device-list" id="clip-devices" style="max-height:300px">
<div class="empty" style="padding:20px">点击"刷新设备"加载</div>
</div>
</div>
</div>
</div>
<div id="clip-result-wrap" style="display:none;margin-top:16px">
<div class="section-title">注入结果</div>
<table class="table">
<thead><tr><th>设备</th><th>结果</th></tr></thead>
<tbody id="clip-result"></tbody>
</table>
</div>
</div> </div>
<pre id="adb-output" class="adb-console">(执行命令后输出显示在这里)</pre>
</div> </div>
</div><!-- /.content --> </div><!-- /.content -->
+162 -5
View File
@@ -20,8 +20,10 @@ import atexit
import secrets import secrets
import functools import functools
import shlex import shlex
import re
import subprocess import subprocess
from datetime import datetime from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, 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)
@@ -34,17 +36,23 @@ from core.device_worker import clear_worker_error, clear_all_worker_errors
from core.logger import get_logger, _LOG_DIR, _MODULE_FILES from core.logger import get_logger, _LOG_DIR, _MODULE_FILES
from core.models import db, init_db, User, DeviceGroup, TaskJob, CustomAction 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, _ADB_LOCK, _adb from core.adb_helper import (screenshot, list_installed_apps, adb_connect_light,
from core import uiauto_helper adb_connect, _ADB_LOCK, _adb)
from core import uiauto_helper, tailscale_client
from core.tailscale_client import TailscaleError
from config import ADB_PATH, STF_SSH_TARGET, STF_DOCKER_CONTAINER from config import ADB_PATH, STF_SSH_TARGET, 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")
app = Flask(__name__) app = Flask(__name__)
# 生产必须通过环境变量 WEB_SECRET_KEY 设置强密钥;缺省用开发密钥(不安全) # 会话密钥:优先 .env 的 WEB_SECRET_KEY;未配置则随机生成(重启后登录态失效,生产务必配置固定值)
app.config["SECRET_KEY"] = os.environ.get( _web_secret = os.environ.get("WEB_SECRET_KEY") or secrets.token_hex(32)
"WEB_SECRET_KEY", "dev-secret-key-change-in-production") if not os.environ.get("WEB_SECRET_KEY"):
_log.warning("未配置 WEB_SECRET_KEY,已随机生成会话密钥(重启后登录态失效)")
app.config["SECRET_KEY"] = _web_secret
# debug=False 时模板默认不自动重载,开发期改 HTML 需重启才生效;这里显式开启
app.config["TEMPLATES_AUTO_RELOAD"] = True
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join( app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join(
os.path.dirname(os.path.abspath(__file__)), "data", "users.db") os.path.dirname(os.path.abspath(__file__)), "data", "users.db")
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
@@ -931,6 +939,59 @@ def api_adb_cmd():
return jsonify({"ok": False, "error": f"执行失败: {e}"}), 500 return jsonify({"ok": False, "error": f"执行失败: {e}"}), 500
@app.route("/api/tools/clipboard/set", methods=["POST"])
@admin_required
def api_tools_clipboard_set():
"""工具-剪贴板注入:把指定文字写入一台或多台设备的剪贴板。
请求: {"serials": ["100.100.10.11:5555", ...], "text": "要注入的文字"}
设备来源与维护终端一致:本地 adb 已连接设备(含 USB)+ STF 在线池。
实现: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):
"""向单台设备注入剪贴板文字(u2 jsonrpc.setClipboard)。
- IP:port 设备:先 adb connect(已连接自动跳过;绝不 disconnect,红线)
- USB 设备(无冒号):u2 按 serial 直连,首次会自动推送 atx-agent
- 连接/注入都带超时保护,避免 atx-agent 无响应时挂住请求
"""
import uiautomator2 as u2
if ":" in serial:
try:
adb_connect(serial)
except Exception as e:
return False, f"adb 连接失败: {e}"
d = None
try:
with ThreadPoolExecutor(max_workers=1) as pool:
d = pool.submit(u2.connect, serial).result(timeout=30)
with ThreadPoolExecutor(max_workers=1) as pool:
pool.submit(d.set_clipboard, text).result(timeout=15)
return True, "已注入"
except FuturesTimeout:
return False, "连接/注入超时(atx-agent 可能无响应)"
except Exception as e:
return False, f"{type(e).__name__}: {str(e)[:120]}"
@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():
@@ -976,6 +1037,102 @@ def api_stf_restart():
"detail": out.strip(), "api_alive": alive}) "detail": out.strip(), "api_alive": alive})
# ================== API:Tailscale 管理(仅管理员) ==================
# 通过 Tailscale 官方 API v2 管理 tailnet 设备(列表/改名/授权/密钥不过期/删除/生成 auth key)。
# 设备 IP 由 tailnet 自动分配,API 无法修改,列表只读展示。
@app.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 ""})
@app.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
@app.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
@app.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
@app.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
@app.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
# ================== uiautodev 自动启动 ================== # ================== uiautodev 自动启动 ==================
_uiauto_proc = None _uiauto_proc = None
# PID 文件:web_server 异常退出(kill -9/崩溃)时 uiautodev 成孤儿残留, # PID 文件:web_server 异常退出(kill -9/崩溃)时 uiautodev 成孤儿残留,