Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f1003bf1f | ||
|
|
cff0fc6a79 | ||
|
|
6051c5c2fd | ||
|
|
de66c6194b | ||
|
|
a48032da23 |
@@ -76,7 +76,6 @@ platform-tools/
|
||||
├── config.py # 根配置(STF地址/token、adb路径、web端口)
|
||||
├── web_server.py # Flask 入口(API + 登录 + 单页应用)
|
||||
├── main.py # 命令行入口(单设备跑一次,不走 web)
|
||||
├── create_task.py # 一次性脚本(创建定时任务示例)
|
||||
├── requirements.txt # Python 依赖清单
|
||||
├── start_web.bat # Windows 一键启动(提权 + 防火墙)
|
||||
│
|
||||
@@ -255,7 +254,7 @@ self.set_progress(done=5, total=80, unit="视频",
|
||||
| 分组 | 设备分组管理:创建/编辑/删除分组 | 所有登录用户可看,写操作需"任务管理"权限 |
|
||||
| 日志 | 实时日志查看:按模块切换(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 main.py` | 命令行跑一次抖音养号(单设备,不走 web) |
|
||||
| `python create_task.py` | 创建"抖音定时8-9点"任务(示例脚本) |
|
||||
| `python scripts/cleanup.py` | 清理 STF 残留设备占用(强杀进程后用) |
|
||||
| `python scripts/pack.py` | 打包项目为 zip(排除日志/数据库/APK) |
|
||||
| `start_web.bat` | Windows 一键启动(提权 + 防火墙规则) |
|
||||
|
||||
@@ -31,7 +31,7 @@ def _env(key, default):
|
||||
|
||||
# ================== STF 配置 ==================
|
||||
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 二进制及依赖统一放在 bin/adb/ 下
|
||||
@@ -70,3 +70,11 @@ AUTO_RELEASE_STALE_OCCUPY = _env("AUTO_RELEASE_STALE_OCCUPY", "false").lower() i
|
||||
# 前置条件:本机可免密 SSH 到目标机(stf@220 需已授权本机公钥)。
|
||||
STF_SSH_TARGET = _env("STF_SSH_TARGET", "[email protected]")
|
||||
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]")
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -667,3 +667,75 @@ adb 远程终端:用平台 adb 二进制执行任意 adb 命令(20s 超时
|
||||
```
|
||||
`result`:`命中` / `未找到` / `已执行`(无选择器命中语义的步骤)。
|
||||
无"设备控制"权限返回 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
@@ -79,11 +79,20 @@ pip install -r requirements.txt
|
||||
|
||||
### 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
|
||||
STF_URL = "http://你的STF地址:端口"
|
||||
STF_TOKEN = "你的STF_API_Token"
|
||||
```
|
||||
|
||||
其他配置按需调整:
|
||||
@@ -95,6 +104,11 @@ STF_TOKEN = "你的STF_API_Token"
|
||||
| `ADB_PATH` | 自动识别 | 用自定义 adb 时修改 |
|
||||
| `STF_SSH_TARGET` | `[email protected]` | 维护页"一键重启 STF 容器"的 SSH 目标(需免密登录) |
|
||||
| `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_DOCKER_CONTAINER` 与真实容器名一致;未配置好时按钮会返回明确错误。
|
||||
|
||||
+196
-4
@@ -281,20 +281,86 @@ function showTab(name){
|
||||
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
|
||||
document.querySelectorAll('.navbar .tab').forEach(t=>t.classList.remove('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(_logTimer){clearInterval(_logTimer);_logTimer=null;}
|
||||
// 按需加载数据
|
||||
if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);}
|
||||
if(name==='tasks'){loadTasks();loadCustomActions();}
|
||||
if(name==='apps')loadApks();
|
||||
if(name==='maintenance')loadAdbDevices();
|
||||
if(name==='tasks'){showSubTab('tasks',_activeSubs.tasks);loadTasks();loadCustomActions();}
|
||||
if(name==='apps'){showSubTab('apps',_activeSubs.apps);loadApks();}
|
||||
if(name==='maintenance'){showSubTab('maintenance',_activeSubs.maintenance);loadAdbDevices();loadTailscaleDevices();}
|
||||
if(name==='tools'){showSubTab('tools',_activeSubs.tools);loadToolsDevices();}
|
||||
if(name==='groups')loadGroups();
|
||||
if(name==='logs'){loadLogs();if(document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);}
|
||||
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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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,
|
||||
@@ -2267,6 +2333,132 @@ function closeTestModal(){
|
||||
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: 应用 ==================
|
||||
let _apksCache=[];
|
||||
let _installTimer=null;
|
||||
|
||||
+117
-16
@@ -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}
|
||||
|
||||
/* 页内子分栏(维护页用) */
|
||||
.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 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('users')" 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 class="user-info">
|
||||
<span id="me-username">...</span>
|
||||
@@ -334,8 +344,16 @@ select.form-control{cursor:pointer}
|
||||
|
||||
<!-- ========== Tab 2: 任务 ========== -->
|
||||
<div id="tab-tasks" class="tab-panel">
|
||||
<div class="page-title">任务计划管理</div>
|
||||
<div class="page-sub">新建、编辑、删除任务计划,支持立即执行和定时调度</div>
|
||||
<div class="page-title">任务</div>
|
||||
<div class="page-sub">任务计划定时调度与自定义动作复用管理</div>
|
||||
|
||||
<div class="sub-tabs">
|
||||
<button class="sub-tab active" data-sub="plan" onclick="showSubTab('tasks','plan')">任务计划</button>
|
||||
<button class="sub-tab" data-sub="actions" onclick="showSubTab('tasks','actions')">自定义动作</button>
|
||||
</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>
|
||||
@@ -348,16 +366,16 @@ select.form-control{cursor:pointer}
|
||||
<tbody id="tb-tasks"></tbody>
|
||||
</table>
|
||||
<div class="list-pager" id="pager-tasks"></div>
|
||||
</div>
|
||||
|
||||
<!-- 自定义动作管理 -->
|
||||
<div style="margin-top:28px">
|
||||
<div class="page-title" style="font-size:16px">自定义动作管理</div>
|
||||
<div class="page-sub">把常用步骤打包成可复用的动作,拖入任务步骤编辑器使用;也可在任务编辑器里"☑选中步骤→打包"生成</div>
|
||||
<!-- 子分栏 2:自定义动作 -->
|
||||
<div class="sub-panel" id="tasks-sub-actions">
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-primary" onclick="openActionModal()" data-perm="tasks">新建动作</button>
|
||||
<button class="btn" onclick="loadCustomActions()">刷新</button>
|
||||
<input class="form-control list-search" id="search-actions" placeholder="搜索动作名...">
|
||||
</div>
|
||||
<div class="help">把常用步骤打包成可复用的动作,拖入任务步骤编辑器使用;也可在任务编辑器里"☑选中步骤→打包"生成</div>
|
||||
<table class="table">
|
||||
<thead><tr><th>图标</th><th>动作名</th><th>步骤数</th><th>创建时间</th><th>操作</th></tr></thead>
|
||||
<tbody id="tb-actions"></tbody>
|
||||
@@ -368,8 +386,16 @@ select.form-control{cursor:pointer}
|
||||
|
||||
<!-- ========== Tab: 应用 ========== -->
|
||||
<div id="tab-apps" class="tab-panel">
|
||||
<div class="page-title">应用管理</div>
|
||||
<div class="page-sub">上传 APK 文件,批量安装到设备(运行中设备自动跳过)</div>
|
||||
<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
|
||||
@@ -386,11 +412,10 @@ select.form-control{cursor:pointer}
|
||||
<tbody id="tb-apks"></tbody>
|
||||
</table>
|
||||
<div class="list-pager" id="pager-apks"></div>
|
||||
</div>
|
||||
|
||||
<!-- 设备已装应用 -->
|
||||
<div style="margin-top:28px">
|
||||
<div class="page-title" style="font-size:16px">设备已装应用</div>
|
||||
<div class="page-sub">查看某台设备上安装的所有应用(含版本号)</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>
|
||||
@@ -476,20 +501,29 @@ select.form-control{cursor:pointer}
|
||||
<!-- ========== Tab 7: 维护(仅管理员) ========== -->
|
||||
<div id="tab-maintenance" class="tab-panel">
|
||||
<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">
|
||||
<button class="sub-tab active" data-sub="stf" onclick="showSubTab('maintenance','stf')">STF 服务</button>
|
||||
<button class="sub-tab" data-sub="adb" onclick="showSubTab('maintenance','adb')">adb 远程终端</button>
|
||||
<button class="sub-tab" data-sub="ts" onclick="showSubTab('maintenance','ts')">Tailscale 管理</button>
|
||||
</div>
|
||||
|
||||
<!-- 子分栏 1:STF 服务 -->
|
||||
<div class="sub-panel active" id="maintenance-sub-stf">
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-danger" onclick="restartStf()">一键重启 STF 容器</button>
|
||||
<span id="stf-restart-status" class="text-muted" style="margin-left:10px"></span>
|
||||
<span id="stf-restart-status" class="text-muted"></span>
|
||||
</div>
|
||||
<div class="help" style="margin-top:8px">
|
||||
通过 SSH 到部署机执行 <code>docker restart</code>(目标与容器名可用环境变量
|
||||
<code>STF_SSH_TARGET</code> / <code>STF_DOCKER_CONTAINER</code> 配置)。
|
||||
影响:STF 约 30 秒不可用,期间设备占用状态重置;<b>有任务在运行时会被拒绝执行</b>。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-title" style="margin-top:16px">adb 远程终端</div>
|
||||
<!-- 子分栏 2:adb 远程终端 -->
|
||||
<div class="sub-panel" id="maintenance-sub-adb">
|
||||
<div class="help">选中目标设备后,命令里的 <code>-s <设备></code> 自动附加,直接写
|
||||
<code>shell ls /sdcard</code> 即可。离线设备也可选,用"重连设备"恢复。
|
||||
<b>禁止 kill-server / disconnect</b>(会中断 STF provider 的设备监控)。</div>
|
||||
@@ -519,6 +553,73 @@ select.form-control{cursor:pointer}
|
||||
<pre id="adb-output" class="adb-console">(执行命令后输出显示在这里)</pre>
|
||||
</div>
|
||||
|
||||
<!-- 子分栏 3:Tailscale 管理 -->
|
||||
<div class="sub-panel" id="maintenance-sub-ts">
|
||||
<div class="help" id="ts-hint"></div>
|
||||
<div class="toolbar" style="margin:8px 0">
|
||||
<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>
|
||||
|
||||
<!-- ========== Tab 8: 工具(仅管理员) ========== -->
|
||||
<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>
|
||||
|
||||
</div><!-- /.content -->
|
||||
|
||||
<!-- ========== 测试此步骤弹窗 ========== -->
|
||||
|
||||
+162
-5
@@ -20,8 +20,10 @@ import atexit
|
||||
import secrets
|
||||
import functools
|
||||
import shlex
|
||||
import re
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
|
||||
|
||||
from flask import (Flask, jsonify, request, redirect, url_for, render_template,
|
||||
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.models import db, init_db, User, DeviceGroup, TaskJob, CustomAction
|
||||
from core.apk_manager import ApkManager
|
||||
from core.adb_helper import screenshot, list_installed_apps, adb_connect_light, _ADB_LOCK, _adb
|
||||
from core import uiauto_helper
|
||||
from core.adb_helper import (screenshot, list_installed_apps, adb_connect_light,
|
||||
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 tasks import list_task_types, get_task_class
|
||||
|
||||
_log = get_logger("web")
|
||||
|
||||
app = Flask(__name__)
|
||||
# 生产必须通过环境变量 WEB_SECRET_KEY 设置强密钥;缺省用开发密钥(不安全)
|
||||
app.config["SECRET_KEY"] = os.environ.get(
|
||||
"WEB_SECRET_KEY", "dev-secret-key-change-in-production")
|
||||
# 会话密钥:优先 .env 的 WEB_SECRET_KEY;未配置则随机生成(重启后登录态失效,生产务必配置固定值)
|
||||
_web_secret = os.environ.get("WEB_SECRET_KEY") or secrets.token_hex(32)
|
||||
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(
|
||||
os.path.dirname(os.path.abspath(__file__)), "data", "users.db")
|
||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||
@@ -931,6 +939,59 @@ def api_adb_cmd():
|
||||
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"])
|
||||
@admin_required
|
||||
def api_stf_restart():
|
||||
@@ -976,6 +1037,102 @@ def api_stf_restart():
|
||||
"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 自动启动 ==================
|
||||
_uiauto_proc = None
|
||||
# PID 文件:web_server 异常退出(kill -9/崩溃)时 uiautodev 成孤儿残留,
|
||||
|
||||
Reference in New Issue
Block a user