refactor: 前端JS拆分为8个模块(base/list/monitor/editor/tasks/tools/apps/admin,按依赖顺序加载)+ 删除废弃文件(start_web.bat/main.py/fix_web.bat/cleanup.py,web已全覆盖)+ 文档同步 + 修复/api/summary缺导入
This commit is contained in:
@@ -45,8 +45,6 @@ python web_server.py
|
|||||||
|
|
||||||
启动后访问 **http://localhost:18050/**,默认账号 `admin` / `admin123`。
|
启动后访问 **http://localhost:18050/**,默认账号 `admin` / `admin123`。
|
||||||
|
|
||||||
> Windows 用户也可双击 `start_web.bat` 启动(自动提权 + 添加防火墙规则,支持局域网访问)。
|
|
||||||
|
|
||||||
### 验证启动
|
### 验证启动
|
||||||
|
|
||||||
控制台看到以下日志即表示启动成功:
|
控制台看到以下日志即表示启动成功:
|
||||||
@@ -73,11 +71,9 @@ python web_server.py
|
|||||||
|
|
||||||
```
|
```
|
||||||
platform-tools/
|
platform-tools/
|
||||||
├── config.py # 根配置(STF地址/token、adb路径、web端口)
|
├── config.py # 根配置(部署配置统一从 .env 读,模板见 .env.example)
|
||||||
├── web_server.py # Flask 入口(API + 登录 + 单页应用)
|
├── web_server.py # Flask 入口(API + 登录 + 单页应用)
|
||||||
├── main.py # 命令行入口(单设备跑一次,不走 web)
|
|
||||||
├── requirements.txt # Python 依赖清单
|
├── requirements.txt # Python 依赖清单
|
||||||
├── start_web.bat # Windows 一键启动(提权 + 防火墙)
|
|
||||||
│
|
│
|
||||||
├── core/ # 核心基础设施层
|
├── core/ # 核心基础设施层
|
||||||
│ ├── logger.py # 统一日志(分文件、10MB 滚动)
|
│ ├── logger.py # 统一日志(分文件、10MB 滚动)
|
||||||
@@ -88,49 +84,50 @@ platform-tools/
|
|||||||
│ ├── u2_helper.py # uiautomator2 通用辅助函数
|
│ ├── u2_helper.py # uiautomator2 通用辅助函数
|
||||||
│ ├── uiauto_helper.py # uiautodev 元素抓取客户端
|
│ ├── uiauto_helper.py # uiautodev 元素抓取客户端
|
||||||
│ ├── apk_manager.py # APK 上传/解析/批量安装
|
│ ├── apk_manager.py # APK 上传/解析/批量安装
|
||||||
|
│ ├── ocr.py # 屏幕 OCR(RapidOCR,条件判断 OCR 选择器)
|
||||||
|
│ ├── ssh_client.py # SSH 统一执行(paramiko 纯密码 / 免密密钥)
|
||||||
|
│ ├── stf_device_mgmt.py # STF 设备池管理(读写 220 脚本 + adb connect/disconnect)
|
||||||
|
│ ├── tailscale_client.py # Tailscale API v2 客户端
|
||||||
│ ├── models.py # SQLAlchemy 数据模型 + 数据库初始化
|
│ ├── models.py # SQLAlchemy 数据模型 + 数据库初始化
|
||||||
│ └── actions/
|
│ └── actions/ # 全局 Action 框架
|
||||||
│ ├── __init__.py # 全局 Action 框架导出
|
|
||||||
│ └── base.py # BaseAction 基类 + 注册机制
|
|
||||||
│
|
│
|
||||||
├── tasks/ # 任务定义层(每个 App 一个子包)
|
├── tasks/ # 任务定义层(每个 App 一个子包)
|
||||||
│ ├── __init__.py # 全局任务注册表
|
│ ├── __init__.py # 全局任务注册表
|
||||||
│ ├── base.py # BaseTask 基类 + @register_task 装饰器
|
│ ├── base.py # BaseTask 基类 + @register_task 装饰器
|
||||||
│ ├── douyin/ # 抖音养号任务
|
│ ├── douyin/ # 抖音养号任务
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── task.py # DEFAULT_PARAMS + Worker + Task
|
|
||||||
│ │ └── actions/ # 抖音专属操作(点赞等)
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── base.py # 抖音 ACTIONS 注册表
|
|
||||||
│ │ └── like.py # 点赞操作(找红心/双击回退)
|
|
||||||
│ └── generic/ # 通用步骤任务(可视化编辑器编排)
|
│ └── generic/ # 通用步骤任务(可视化编辑器编排)
|
||||||
│ ├── __init__.py
|
│ └── task.py # 步骤执行引擎(open_app/click/swipe/if_el...)
|
||||||
│ └── task.py # 步骤执行引擎(open_app/click/swipe/loop...)
|
|
||||||
│
|
│
|
||||||
├── templates/admin/ # 前端页面
|
├── templates/admin/ # 前端页面
|
||||||
│ ├── monitor.html # 单页应用(监控/任务/分组/日志/用户 5 Tab)
|
│ ├── monitor.html # 单页应用(监控/任务/分组/日志/用户/维护/工具)
|
||||||
│ └── login.html # 登录页
|
│ └── login.html # 登录页
|
||||||
│
|
│
|
||||||
├── static/admin/
|
├── static/admin/ # 前端 JS 模块(monitor.html 按依赖顺序加载)
|
||||||
│ ├── custom.css # 自定义样式
|
│ ├── base.js # 通用基础:API/CSRF/权限/Tab切换/子分栏/模态框
|
||||||
│ └── monitor.js # 前端 JS(已从 monitor.html 拆分)
|
│ ├── list.js # 统一列表组件(搜索+分页+排序)
|
||||||
|
│ ├── monitor.js # 监控页(设备表/截图/异常汇总)
|
||||||
|
│ ├── editor.js # 步骤编辑器(拖拽/条件判断/元素抓取)
|
||||||
|
│ ├── tasks.js # 任务 Tab + 自定义动作
|
||||||
|
│ ├── tools.js # 工具 Tab(剪贴板/应用版本/STF设备/adb终端/Tailscale)
|
||||||
|
│ ├── apps.js # 工具 Tab-应用管理(APK/设备已装应用)
|
||||||
|
│ ├── admin.js # 管理 Tab(分组/日志/用户)+ 初始化
|
||||||
|
│ └── custom.css # 自定义样式
|
||||||
│
|
│
|
||||||
├── data/ # 运行时数据
|
├── data/ # 运行时数据
|
||||||
│ ├── users.db # SQLite(用户/分组/任务/自定义动作/APK记录)
|
│ ├── users.db # SQLite(用户/分组/任务/自定义动作/APK记录)
|
||||||
│ └── apks/ # 上传的 APK 文件存储
|
│ └── apks/ # 上传的 APK 文件存储
|
||||||
│
|
│
|
||||||
├── logs/ # 日志文件(自动生成,10MB 滚动保留 5 份)
|
├── logs/ # 日志文件(自动生成,10MB 滚动保留 5 份)
|
||||||
│ ├── core.log # 核心程序日志
|
|
||||||
│ ├── task.log # 任务执行日志
|
|
||||||
│ ├── web.log # Web 请求日志
|
|
||||||
│ └── action.log # 操作执行日志
|
|
||||||
│
|
|
||||||
├── bin/adb/ # adb 可执行文件(Windows: adb.exe + dll)
|
├── bin/adb/ # adb 可执行文件(Windows: adb.exe + dll)
|
||||||
├── scripts/ # 实用脚本
|
├── scripts/ # 实用脚本
|
||||||
│ ├── cleanup.py # 清理 STF 残留设备占用
|
│ ├── pack.py # 打包项目为 zip(排除运行时产物)
|
||||||
│ └── pack.py # 打包项目为 zip(排除运行时产物)
|
│ └── supervise.sh # 进程守护(崩溃自动重启)
|
||||||
└── doc/ # 项目文档
|
└── doc/ # 项目文档
|
||||||
├── TASK_DEV.md # 任务开发指南(新增 App 任务模板)
|
├── API.md # API 文档
|
||||||
|
├── ARCHITECTURE.md # 架构详解
|
||||||
|
├── DEPLOY.md # 部署指南
|
||||||
|
├── DEVELOPMENT.md # 开发指南
|
||||||
|
└── TASK_DEV.md # 任务开发指南(新增 App 任务模板)
|
||||||
├── ARCHITECTURE.md # 架构详解
|
├── ARCHITECTURE.md # 架构详解
|
||||||
├── DEPLOY.md # 部署指南
|
├── DEPLOY.md # 部署指南
|
||||||
└── API.md # API 接口文档
|
└── API.md # API 接口文档
|
||||||
@@ -382,10 +379,8 @@ Web 后台"日志"Tab 可实时查看各模块日志。
|
|||||||
| 脚本 | 用途 |
|
| 脚本 | 用途 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `python web_server.py` | 启动 Web 管理后台 |
|
| `python web_server.py` | 启动 Web 管理后台 |
|
||||||
| `python main.py` | 命令行跑一次抖音养号(单设备,不走 web) |
|
|
||||||
| `python scripts/cleanup.py` | 清理 STF 残留设备占用(强杀进程后用) |
|
|
||||||
| `python scripts/pack.py` | 打包项目为 zip(排除日志/数据库/APK) |
|
| `python scripts/pack.py` | 打包项目为 zip(排除日志/数据库/APK) |
|
||||||
| `start_web.bat` | Windows 一键启动(提权 + 防火墙规则) |
|
| `bash scripts/supervise.sh` | 进程守护(web_server 崩溃自动重启) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -407,13 +402,8 @@ STF 状态有缓存,`present=True` 不代表设备真在线。用"扫描前台
|
|||||||
|
|
||||||
### 设备一直被占用无法释放
|
### 设备一直被占用无法释放
|
||||||
|
|
||||||
运行清理脚本:
|
监控页点"**强制释放占用**"按钮释放当前账户占用的所有设备;
|
||||||
|
无人值守场景可在 `.env` 设置 `AUTO_RELEASE_STALE_OCCUPY=true`(单实例)启动自动清理。
|
||||||
```bash
|
|
||||||
python scripts/cleanup.py
|
|
||||||
```
|
|
||||||
|
|
||||||
会释放当前账户占用的所有设备。
|
|
||||||
|
|
||||||
### u2.connect 卡死
|
### u2.connect 卡死
|
||||||
|
|
||||||
|
|||||||
+4
-11
@@ -123,18 +123,13 @@ STF_URL = "http://你的STF地址:端口"
|
|||||||
|
|
||||||
### 2.4 启动服务
|
### 2.4 启动服务
|
||||||
|
|
||||||
**方式一:命令行启动**
|
**命令行启动**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python web_server.py
|
python web_server.py
|
||||||
```
|
```
|
||||||
|
|
||||||
**方式二:Windows 一键启动**
|
(推荐配合 `scripts/supervise.sh` 进程守护,见 3.1 节)
|
||||||
|
|
||||||
双击 `start_web.bat`:
|
|
||||||
- 自动提权到管理员
|
|
||||||
- 添加防火墙规则(支持局域网访问)
|
|
||||||
- 启动 web_server
|
|
||||||
|
|
||||||
### 2.5 验证部署
|
### 2.5 验证部署
|
||||||
|
|
||||||
@@ -254,7 +249,7 @@ server {
|
|||||||
| 7100 | STF | STF 服务端口(STF 自己的配置) |
|
| 7100 | STF | STF 服务端口(STF 自己的配置) |
|
||||||
| 5555 | adb | 设备 adb 网络端口(设备端) |
|
| 5555 | adb | 设备 adb 网络端口(设备端) |
|
||||||
|
|
||||||
### 4.2 Windows 端口问题
|
### 4.2 Windows 端口问题(仅 Windows)
|
||||||
|
|
||||||
Windows 可能将某些端口范围划为动态排除范围,导致绑定失败(WinError 10013)。
|
Windows 可能将某些端口范围划为动态排除范围,导致绑定失败(WinError 10013)。
|
||||||
|
|
||||||
@@ -265,12 +260,10 @@ netsh interface ipv4 show excludedportrange protocol=tcp
|
|||||||
|
|
||||||
如果 18050 在排除范围内,修改 `config.py` 的 `WEB_PORT` 到一个不在排除范围内的端口。
|
如果 18050 在排除范围内,修改 `config.py` 的 `WEB_PORT` 到一个不在排除范围内的端口。
|
||||||
|
|
||||||
或运行 `scripts/fix_web.bat`(关闭系统代理 + 添加防火墙规则 + 刷新 DNS)。
|
|
||||||
|
|
||||||
### 4.3 局域网访问
|
### 4.3 局域网访问
|
||||||
|
|
||||||
- `WEB_HOST = "0.0.0.0"` 允许局域网访问
|
- `WEB_HOST = "0.0.0.0"` 允许局域网访问
|
||||||
- 需要添加防火墙入站规则(`start_web.bat` 会自动处理)
|
- 需要添加防火墙入站规则(Windows 手动添加,或参考 `netsh advfirewall firewall add rule`)
|
||||||
- 局域网其他机器访问 `http://部署机IP:18050/`
|
- 局域网其他机器访问 `http://部署机IP:18050/`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+1
-1
@@ -151,7 +151,7 @@ with sync_playwright() as p:
|
|||||||
|
|
||||||
- **看日志**:`logs/` 下 `core.log` / `task.log` / `web.log` / `action.log`(10MB 滚动,保留 5 份)
|
- **看日志**:`logs/` 下 `core.log` / `task.log` / `web.log` / `action.log`(10MB 滚动,保留 5 份)
|
||||||
- **看设备/任务状态**:浏览器监控页,或 `GET /api/status`、`GET /api/health`
|
- **看设备/任务状态**:浏览器监控页,或 `GET /api/status`、`GET /api/health`
|
||||||
- **清理 STF 残留占用**:`python scripts/cleanup.py`(或前端"强制释放占用")
|
- **清理 STF 残留占用**:前端监控页"强制释放占用"(或 `.env` 配 `AUTO_RELEASE_STALE_OCCUPY=true` 启动自动清理)
|
||||||
- **打包项目**:`python scripts/pack.py`
|
- **打包项目**:`python scripts/pack.py`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+1
-2
@@ -26,9 +26,8 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
platform-tools/
|
platform-tools/
|
||||||
├── config.py # 根配置(STF/adb/web,不放任务参数)
|
├── config.py # 根配置(部署值从 .env 读,不放任务参数)
|
||||||
├── web_server.py # Flask 入口(JSON API + 登录页 + 单页应用)
|
├── web_server.py # Flask 入口(JSON API + 登录页 + 单页应用)
|
||||||
├── main.py # 根目录入口
|
|
||||||
├── core/ # 核心程序层
|
├── core/ # 核心程序层
|
||||||
│ ├── __init__.py
|
│ ├── __init__.py
|
||||||
│ ├── logger.py # 日志器(分文件、10MB 滚动)
|
│ ├── logger.py # 日志器(分文件、10MB 滚动)
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
"""抖音养号命令行入口。
|
|
||||||
|
|
||||||
直接运行:python main.py
|
|
||||||
- 单设备跑一次养号,不走 web/调度
|
|
||||||
- 任务参数默认值在 tasks/douyin/task.py 顶部,可通过环境变量 WATCH_COUNT 覆盖视频数
|
|
||||||
|
|
||||||
web 管理后台请运行:python web_server.py
|
|
||||||
清理 STF 残留占用:python scripts/cleanup.py
|
|
||||||
|
|
||||||
目录结构:
|
|
||||||
config.py — 项目根配置(STF 地址/token、adb 路径、web 端口、数据目录)
|
|
||||||
bin/adb/ — adb.exe 及依赖 dll
|
|
||||||
core/ — 核心程序(STF 客户端、adb 工具、BaseWorker 基类、任务管理器)
|
|
||||||
tasks/ — 任务定义(每个 App 一个子包,自带参数默认值,继承 BaseTask + @register_task)
|
|
||||||
templates/ — 前端页面(admin/monitor.html 单页应用)
|
|
||||||
data/ — 持久化数据(SQLite:用户/分组/任务)
|
|
||||||
scripts/ — 实用脚本
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
|
|
||||||
from core.stf_client import STFClient
|
|
||||||
from tasks import get_task_class
|
|
||||||
|
|
||||||
|
|
||||||
def run_once(serial=None, **overrides):
|
|
||||||
"""单设备跑一次养号(命令行入口,不启动 web)。
|
|
||||||
|
|
||||||
走任务框架:取 douyin_nurture 任务类 → create_worker → 直接 run(当前线程)。
|
|
||||||
overrides 可传任务参数覆盖默认值,如 run_once(watch_count=20, like_enabled=False)。
|
|
||||||
"""
|
|
||||||
stf = STFClient()
|
|
||||||
task_cls = get_task_class("douyin_nurture")
|
|
||||||
task = task_cls()
|
|
||||||
params = dict(overrides)
|
|
||||||
# 命令行友好:环境变量 WATCH_COUNT 覆盖视频数
|
|
||||||
if "WATCH_COUNT" in os.environ and "watch_count" not in params:
|
|
||||||
params["watch_count"] = int(os.environ["WATCH_COUNT"])
|
|
||||||
worker = task.create_worker(stf, serial, params)
|
|
||||||
worker.run() # 直接在当前线程跑,不起子线程
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
run_once()
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
"""清理 STF 上残留的设备占用(强杀进程导致没正常释放时用)。
|
|
||||||
|
|
||||||
运行:python scripts/cleanup.py
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# 让脚本既能直接运行(python scripts/cleanup.py)又能作为模块运行。
|
|
||||||
# scripts/ 的上一级是项目根目录(core 包的父目录),需加入 sys.path。
|
|
||||||
_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
if _PROJECT_ROOT not in sys.path:
|
|
||||||
sys.path.insert(0, _PROJECT_ROOT)
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from config import STF_URL, STF_TOKEN
|
|
||||||
|
|
||||||
headers = {"Authorization": f"Bearer {STF_TOKEN}"}
|
|
||||||
|
|
||||||
# /user/devices 返回当前用户正在占用的设备
|
|
||||||
resp = requests.get(f"{STF_URL}/api/v1/user/devices", headers=headers)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
mine = data.get("devices", [])
|
|
||||||
print(f"当前账户占用的设备数: {len(mine)}")
|
|
||||||
for d in mine:
|
|
||||||
print(f" - {d.get('serial')} using={d.get('using')}")
|
|
||||||
|
|
||||||
# 全部释放
|
|
||||||
for d in mine:
|
|
||||||
serial = d["serial"]
|
|
||||||
try:
|
|
||||||
requests.post(
|
|
||||||
f"{STF_URL}/api/v1/user/devices/{serial}/remoteDisconnect",
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
r = requests.delete(f"{STF_URL}/api/v1/user/devices/{serial}", headers=headers)
|
|
||||||
print(f"释放 {serial}: HTTP {r.status_code}")
|
|
||||||
|
|
||||||
# 再看一次总设备池
|
|
||||||
resp = requests.get(f"{STF_URL}/api/v1/devices", headers=headers)
|
|
||||||
resp.raise_for_status()
|
|
||||||
free = [d for d in resp.json().get("devices", [])
|
|
||||||
if d.get("present") and d.get("ready") and not d.get("using") and d.get("owner") is None]
|
|
||||||
print(f"\n清理后空闲设备数: {len(free)}")
|
|
||||||
for d in free:
|
|
||||||
print(f" - {d.get('serial')}")
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
@echo off
|
|
||||||
title Fix Web Access
|
|
||||||
|
|
||||||
:: Auto elevate to admin
|
|
||||||
net session >nul 2>&1
|
|
||||||
if %errorLevel% neq 0 (
|
|
||||||
echo Requesting admin privileges...
|
|
||||||
powershell -Command "Start-Process cmd -ArgumentList '/c ""%~f0""' -Verb RunAs"
|
|
||||||
exit /b
|
|
||||||
)
|
|
||||||
|
|
||||||
echo ============================================
|
|
||||||
echo Fix Web Backend Access
|
|
||||||
echo ============================================
|
|
||||||
echo.
|
|
||||||
|
|
||||||
:: 1. Disable residual system proxy (blocks localhost)
|
|
||||||
echo [1/4] Disabling system proxy...
|
|
||||||
reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings" /v ProxyEnable /t REG_DWORD /d 0 /f >nul 2>&1
|
|
||||||
reg delete "HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings" /v ProxyServer /f >nul 2>&1
|
|
||||||
echo Done.
|
|
||||||
echo.
|
|
||||||
|
|
||||||
:: 2. Add firewall rules for Python
|
|
||||||
echo [2/4] Adding firewall rules for Python...
|
|
||||||
for /f "delims=" %%i in ('where python') do set PYTHON_PATH=%%i
|
|
||||||
if not defined PYTHON_PATH (
|
|
||||||
echo ERROR: Python not found in PATH
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
echo Python path: %PYTHON_PATH%
|
|
||||||
netsh advfirewall firewall delete rule name="PythonWeb" >nul 2>&1
|
|
||||||
netsh advfirewall firewall add rule name="PythonWeb" dir=in action=allow program="%PYTHON_PATH%" enable=yes profile=any >nul
|
|
||||||
netsh advfirewall firewall delete rule name="PythonWebOut" >nul 2>&1
|
|
||||||
netsh advfirewall firewall add rule name="PythonWebOut" dir=out action=allow program="%PYTHON_PATH%" enable=yes profile=any >nul
|
|
||||||
echo Done.
|
|
||||||
echo.
|
|
||||||
|
|
||||||
:: 3. Flush DNS cache
|
|
||||||
echo [3/4] Flushing DNS cache...
|
|
||||||
ipconfig /flushdns >nul 2>&1
|
|
||||||
echo Done.
|
|
||||||
echo.
|
|
||||||
|
|
||||||
:: 4. Show excluded port ranges for reference
|
|
||||||
echo [4/4] TCP excluded port ranges (for reference):
|
|
||||||
netsh interface ipv4 show excludedportrange protocol=tcp
|
|
||||||
echo.
|
|
||||||
|
|
||||||
echo ============================================
|
|
||||||
echo Fix complete! Now:
|
|
||||||
echo 1. Close ALL browser windows and reopen
|
|
||||||
echo 2. Run: python web_server.py
|
|
||||||
echo 3. Open: http://127.0.0.1:18050/
|
|
||||||
echo ============================================
|
|
||||||
pause
|
|
||||||
+1
-1
@@ -15,7 +15,7 @@ SKIP_EXTS = {".pyc", ".pyo"}
|
|||||||
# 路径片段包含这些的跳过
|
# 路径片段包含这些的跳过
|
||||||
SKIP_PARTS = {"__pycache__"}
|
SKIP_PARTS = {"__pycache__"}
|
||||||
# 文件名完全匹配这些的跳过
|
# 文件名完全匹配这些的跳过
|
||||||
SKIP_FILES = {"fix_web.bat"}
|
SKIP_FILES = {}
|
||||||
# 文件名后缀匹配这些的跳过
|
# 文件名后缀匹配这些的跳过
|
||||||
SKIP_SUFFIX = (".migrated",)
|
SKIP_SUFFIX = (".migrated",)
|
||||||
# 特定目录下特定后缀跳过
|
# 特定目录下特定后缀跳过
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
@echo off
|
|
||||||
chcp 65001 >nul 2>&1
|
|
||||||
title 设备自动化后台(局域网模式)
|
|
||||||
|
|
||||||
REM ===== 自动提权到管理员 =====
|
|
||||||
net session >nul 2>&1
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo 需要管理员权限以支持局域网访问,正在提权...
|
|
||||||
powershell -Command "Start-Process -FilePath '%~f0' -Verb RunAs"
|
|
||||||
exit /b
|
|
||||||
)
|
|
||||||
|
|
||||||
cd /d "%~dp0"
|
|
||||||
|
|
||||||
REM ===== 添加防火墙入站规则(放行 Python,仅一次) =====
|
|
||||||
for /f "delims=" %%i in ('where python') do set PY_EXE=%%i
|
|
||||||
netsh advfirewall firewall show rule name="PlatformTools Web" >nul 2>&1
|
|
||||||
if %errorlevel% neq 0 (
|
|
||||||
echo 正在添加防火墙规则...
|
|
||||||
netsh advfirewall firewall add rule name="PlatformTools Web" dir=in action=allow program="%PY_EXE%" enable=yes >nul 2>&1
|
|
||||||
netsh advfirewall firewall add rule name="PlatformTools Web" dir=out action=allow program="%PY_EXE%" enable=yes >nul 2>&1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo ============================================
|
|
||||||
echo 管理后台已启动(局域网可访问)
|
|
||||||
echo 本机访问: http://127.0.0.1:18050/
|
|
||||||
echo 局域网访问: http://本机IP:18050/
|
|
||||||
echo 默认账号: admin / admin123
|
|
||||||
echo 按 Ctrl+C 停止
|
|
||||||
echo ============================================
|
|
||||||
echo.
|
|
||||||
|
|
||||||
python web_server.py
|
|
||||||
pause
|
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
// 管理 Tab:分组/日志/用户
|
||||||
|
// ================== Tab 3: 分组 ==================
|
||||||
|
async function loadGroups(){
|
||||||
|
const r=await apiGet('/api/groups');if(!r)return;
|
||||||
|
_groupsList=r.groups||[];
|
||||||
|
const dr=await apiGet('/api/devices');
|
||||||
|
_devicesList=dr?dr.devices||[]:[];
|
||||||
|
renderGroupTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGroupTable(){
|
||||||
|
const groups=_groupsList||[];
|
||||||
|
setListPager('groups',{
|
||||||
|
data:()=>groups,
|
||||||
|
filter:(g,kw)=>((g.name||'').toLowerCase().includes(kw)||(g.description||'').toLowerCase().includes(kw)||(g.serials||[]).join(' ').toLowerCase().includes(kw)),
|
||||||
|
sortValue:(g,k)=>{ if(k==='count')return (g.serials||[]).length; return g[k]||''; },
|
||||||
|
render:renderGroupRow,
|
||||||
|
tbody:'tb-groups',pager:'pager-groups',pageSize:10,
|
||||||
|
empty:'<tr><td colspan="5" class="empty">暂无分组,点击"新建分组"创建</td></tr>'
|
||||||
|
});
|
||||||
|
applyListPager('groups');
|
||||||
|
initListSearch('groups','search-groups');
|
||||||
|
}
|
||||||
|
function renderGroupRow(g){
|
||||||
|
const serials=g.serials||[];
|
||||||
|
return '<tr>'+
|
||||||
|
'<td><strong>'+esc(g.name)+'</strong></td>'+
|
||||||
|
'<td>'+esc(g.description||'-')+'</td>'+
|
||||||
|
'<td>'+serials.length+'</td>'+
|
||||||
|
'<td class="text-muted" style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+(serials.length?serials.map(esc).join(', '):'-')+'</td>'+
|
||||||
|
'<td>'+
|
||||||
|
(_can('tasks')?(
|
||||||
|
'<button class="btn btn-xs" onclick="openGroupModal(\''+esc(g.name)+'\')">编辑</button>'+
|
||||||
|
'<button class="btn btn-danger btn-xs" onclick="deleteGroup(\''+esc(g.name)+'\')">删除</button>'
|
||||||
|
):'<span class="text-muted">只读</span>')+
|
||||||
|
'</td>'+
|
||||||
|
'</tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGroupModal(name){
|
||||||
|
const group=name?(_groupsList||[]).find(g=>g.name===name):null;
|
||||||
|
const isEdit=!!group;
|
||||||
|
document.getElementById('modal-title').textContent=isEdit?'编辑分组':'新建分组';
|
||||||
|
const g=group||{name:'',description:'',serials:[]};
|
||||||
|
const current= new Set(g.serials||[]);
|
||||||
|
const allSerials=[...new Set([..._devicesList, ...(g.serials||[])])].sort();
|
||||||
|
const deviceCheckboxes=allSerials.map(s=>
|
||||||
|
'<label><input type="checkbox" value="'+esc(s)+'" '+(current.has(s)?'checked':'')+'>'+esc(s)+'</label>'
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
document.getElementById('modal-body').innerHTML=
|
||||||
|
'<div class="form-group"><label>分组名</label><input type="text" class="form-control" id="g-name" value="'+esc(g.name)+'" placeholder="如:测试组" '+(isEdit?'readonly':'')+'></div>'+
|
||||||
|
'<div class="form-group"><label>描述</label><input type="text" class="form-control" id="g-description" value="'+esc(g.description||'')+'" placeholder="可选"></div>'+
|
||||||
|
'<div class="form-group"><label>设备列表(勾选属于此分组的设备)</label>'+
|
||||||
|
'<div class="device-list">'+(deviceCheckboxes||'<div class="text-muted">暂无在线设备</div>')+'</div>'+
|
||||||
|
'<div class="help">仅显示当前在线设备,已选设备即使离线也会保留</div>'+
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
document.getElementById('modal-footer').innerHTML=
|
||||||
|
'<button class="btn" onclick="closeModal()">取消</button>'+
|
||||||
|
'<button class="btn btn-primary" onclick="saveGroup('+(isEdit?'\''+esc(g.name)+'\'':'null')+')">'+(isEdit?'保存':'创建')+'</button>';
|
||||||
|
|
||||||
|
showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGroup(name){
|
||||||
|
const newName=document.getElementById('g-name').value.trim();
|
||||||
|
if(!newName){showToast('分组名不能为空','error');return;}
|
||||||
|
const desc=document.getElementById('g-description').value;
|
||||||
|
const serials=Array.from(document.querySelectorAll('.device-list input:checked')).map(c=>c.value);
|
||||||
|
const data={name:newName,description:desc,serials:serials};
|
||||||
|
|
||||||
|
if(name){
|
||||||
|
const r=await apiPut('/api/groups/'+encodeURIComponent(name),data);
|
||||||
|
handleResult(r,()=>{closeModal();loadGroups();});
|
||||||
|
}else{
|
||||||
|
const r=await apiPost('/api/groups',data);
|
||||||
|
handleResult(r,()=>{closeModal();loadGroups();});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteGroup(name){
|
||||||
|
if(!confirm('确认删除分组"'+name+'"吗?'))return;
|
||||||
|
const r=await apiDelete('/api/groups/'+encodeURIComponent(name));
|
||||||
|
handleResult(r,()=>loadGroups());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================== Tab 4: 日志 ==================
|
||||||
|
async function loadLogs(){
|
||||||
|
const file=document.getElementById('log-file').value;
|
||||||
|
const lines=document.getElementById('log-lines').value;
|
||||||
|
const r=await apiGet('/api/logs?file='+encodeURIComponent(file)+'&lines='+lines);
|
||||||
|
if(!r||!r.ok)return;
|
||||||
|
const content=r.content||'';
|
||||||
|
// 简单着色
|
||||||
|
const colored=esc(content)
|
||||||
|
.replace(/\[ERROR\]/g,'<span class="log-err">[ERROR]</span>')
|
||||||
|
.replace(/\[WARNING\]/g,'<span class="log-warn">[WARNING]</span>')
|
||||||
|
.replace(/\[INFO\]/g,'<span class="log-info">[INFO]</span>');
|
||||||
|
document.getElementById('log-content').innerHTML=colored||'<span class="text-muted">(空)</span>';
|
||||||
|
document.getElementById('log-hint').textContent=file+' · '+content.split('\n').length+' 行';
|
||||||
|
// 自动滚动到底部
|
||||||
|
const el=document.getElementById('log-content');
|
||||||
|
el.scrollTop=el.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLogAuto(){
|
||||||
|
if(_logTimer){clearInterval(_logTimer);_logTimer=null;}
|
||||||
|
if(document.getElementById('log-auto').checked && _activeTab==='logs'){
|
||||||
|
_logTimer=setInterval(loadLogs,3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================== Tab 5: 用户 ==================
|
||||||
|
async function loadUsers(){
|
||||||
|
const r=await apiGet('/api/users');if(!r)return;
|
||||||
|
const users=r.users||[];
|
||||||
|
setListPager('users',{
|
||||||
|
data:()=>users,
|
||||||
|
filter:(u,kw)=>(u.username||'').toLowerCase().includes(kw),
|
||||||
|
sortValue:(u,k)=>{ if(k==='is_admin')return u.is_admin?1:0; return u[k]||''; },
|
||||||
|
render:renderUserRow,
|
||||||
|
tbody:'tb-users',pager:'pager-users',pageSize:10,
|
||||||
|
empty:'<tr><td colspan="5" class="empty">暂无用户</td></tr>'
|
||||||
|
});
|
||||||
|
applyListPager('users');
|
||||||
|
initListSearch('users','search-users');
|
||||||
|
}
|
||||||
|
function renderUserRow(u){
|
||||||
|
const admin=u.is_admin?'<span class="label label-primary">管理员</span>':'<span class="label label-default">普通</span>';
|
||||||
|
const perms=(u.perms||[]).map(p=>'<span class="label label-info">'+(PERM_LABELS[p]||p)+'</span>').join(' ')
|
||||||
|
||'<span class="text-muted">-</span>';
|
||||||
|
return '<tr>'+
|
||||||
|
'<td>'+u.id+'</td>'+
|
||||||
|
'<td><strong>'+esc(u.username)+'</strong></td>'+
|
||||||
|
'<td>'+admin+'</td>'+
|
||||||
|
'<td>'+perms+'</td>'+
|
||||||
|
'<td>'+
|
||||||
|
'<button class="btn btn-xs" onclick="openUserModal('+u.id+')">编辑</button>'+
|
||||||
|
(u.username==='admin'?'':'<button class="btn btn-danger btn-xs" onclick="deleteUser('+u.id+')">删除</button>')+
|
||||||
|
'</td>'+
|
||||||
|
'</tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePermBoxes(){
|
||||||
|
// 勾选"管理员权限"时:权限位全选并禁用(管理员拥有全部权限)
|
||||||
|
const isAdmin=document.getElementById('u-is_admin').checked;
|
||||||
|
document.querySelectorAll('.u-perm').forEach(cb=>{
|
||||||
|
cb.disabled=isAdmin;
|
||||||
|
if(isAdmin)cb.checked=true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUserModal(uid){
|
||||||
|
const isEdit=!!uid;
|
||||||
|
document.getElementById('modal-title').textContent=isEdit?'编辑用户':'新建用户';
|
||||||
|
const permBoxes=Object.keys(PERM_LABELS).map(p=>
|
||||||
|
'<label style="margin-right:12px"><input type="checkbox" class="u-perm" value="'+p+'">'+PERM_LABELS[p]+'</label>'
|
||||||
|
).join('');
|
||||||
|
document.getElementById('modal-body').innerHTML=
|
||||||
|
'<div class="form-group"><label>用户名</label><input type="text" class="form-control" id="u-username" '+(isEdit?'disabled':'')+' placeholder="用户名"></div>'+
|
||||||
|
'<div class="form-group"><label>密码'+(isEdit?'(留空不修改)':'')+'</label><input type="password" class="form-control" id="u-password" placeholder="密码"></div>'+
|
||||||
|
'<div class="form-group form-check"><input type="checkbox" id="u-is_admin" onchange="togglePermBoxes()" checked><label for="u-is_admin">管理员权限(拥有全部权限)</label></div>'+
|
||||||
|
'<div class="form-group"><label>业务权限(管理员自动拥有全部,无需勾选)</label>'+
|
||||||
|
'<div>'+permBoxes+'</div>'+
|
||||||
|
'<div class="help">任务管理=任务/自定义动作/分组;设备控制=停止/释放/清异常/扫描/抓元素;应用管理=APK上传安装;日志查看=日志页</div>'+
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
document.getElementById('modal-footer').innerHTML=
|
||||||
|
'<button class="btn" onclick="closeModal()">取消</button>'+
|
||||||
|
'<button class="btn btn-primary" onclick="saveUser('+(uid||'null')+')">'+(isEdit?'保存':'创建')+'</button>';
|
||||||
|
|
||||||
|
if(isEdit){
|
||||||
|
// 预填充(从表格数据获取)
|
||||||
|
apiGet('/api/users').then(r=>{
|
||||||
|
const u=(r&&r.users||[]).find(x=>x.id===uid);
|
||||||
|
if(u){
|
||||||
|
document.getElementById('u-username').value=u.username;
|
||||||
|
document.getElementById('u-is_admin').checked=u.is_admin;
|
||||||
|
document.querySelectorAll('.u-perm').forEach(cb=>{
|
||||||
|
cb.checked=(u.perms||[]).includes(cb.value);
|
||||||
|
});
|
||||||
|
togglePermBoxes();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}else{
|
||||||
|
togglePermBoxes();
|
||||||
|
}
|
||||||
|
showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUser(uid){
|
||||||
|
const username=document.getElementById('u-username').value.trim();
|
||||||
|
const password=document.getElementById('u-password').value;
|
||||||
|
const is_admin=document.getElementById('u-is_admin').checked;
|
||||||
|
const perms=[...document.querySelectorAll('.u-perm:checked')].map(cb=>cb.value);
|
||||||
|
|
||||||
|
if(uid){
|
||||||
|
const data={is_admin:is_admin,perms:perms};
|
||||||
|
if(password)data.password=password;
|
||||||
|
const r=await apiPut('/api/users/'+uid,data);
|
||||||
|
handleResult(r,()=>{closeModal();loadUsers();});
|
||||||
|
}else{
|
||||||
|
if(!username||!password){showToast('用户名和密码不能为空','error');return;}
|
||||||
|
const r=await apiPost('/api/users',{username:username,password:password,is_admin:is_admin,perms:perms});
|
||||||
|
handleResult(r,()=>{closeModal();loadUsers();});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser(uid){
|
||||||
|
if(!confirm('确认删除此用户吗?'))return;
|
||||||
|
const r=await apiDelete('/api/users/'+uid);
|
||||||
|
handleResult(r,()=>loadUsers());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ================== 初始化 ==================
|
||||||
|
initCsrf(); // 获取 CSRF token(非 GET 请求需携带)
|
||||||
|
loadMe(); // 获取当前用户权限,隐藏无权限的功能入口
|
||||||
|
showTab('monitor');
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
// 工具 Tab-应用管理:APK 上传/安装/删除 + 设备已装应用
|
||||||
|
// ================== Tab: 应用 ==================
|
||||||
|
let _apksCache=[];
|
||||||
|
let _installTimer=null;
|
||||||
|
let _devAppsData=[];
|
||||||
|
|
||||||
|
function fmtSize(b){
|
||||||
|
if(!b)return '0 B';
|
||||||
|
if(b<1024)return b+' B';
|
||||||
|
if(b<1048576)return (b/1024).toFixed(1)+' KB';
|
||||||
|
return (b/1048576).toFixed(1)+' MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadApks(){
|
||||||
|
const r=await apiGet('/api/apks');if(!r||!r.ok)return;
|
||||||
|
const apks=r.apks||[];
|
||||||
|
_apksCache=apks;
|
||||||
|
document.getElementById('apk-hint').textContent='共 '+apks.length+' 个应用';
|
||||||
|
setListPager('apks',{
|
||||||
|
data:()=>apks,
|
||||||
|
sortValue:(a,k)=>{ if(k==='size')return a.size||0; if(k==='upload_time')return a.upload_time||''; if(k==='package_name')return a.package_name||''; if(k==='version_name')return a.version_name||''; return a.display_name||''; },
|
||||||
|
filter:(a,kw)=>((a.display_name||'').toLowerCase().includes(kw)||(a.package_name||'').toLowerCase().includes(kw)),
|
||||||
|
render:renderApkRow,
|
||||||
|
tbody:'tb-apks',pager:'pager-apks',pageSize:10,
|
||||||
|
empty:'<tr><td colspan="6" class="empty">暂无APK,点击"上传APK"上传</td></tr>'
|
||||||
|
});
|
||||||
|
applyListPager('apks');
|
||||||
|
initListSearch('apks','search-apks');
|
||||||
|
await fillAppsDeviceList();
|
||||||
|
}
|
||||||
|
function renderApkRow(a){
|
||||||
|
return '<tr>'+
|
||||||
|
'<td><strong>'+esc(a.display_name||'(未命名)')+'</strong></td>'+
|
||||||
|
'<td style="font-family:monospace;font-size:12px;color:#64748b">'+esc(a.package_name||'-')+'</td>'+
|
||||||
|
'<td>'+(a.version_name?esc(a.version_name):'-')+'</td>'+
|
||||||
|
'<td>'+fmtSize(a.size)+'</td>'+
|
||||||
|
'<td style="font-size:12px;color:#94a3b8">'+esc(a.upload_time)+'</td>'+
|
||||||
|
'<td>'+
|
||||||
|
'<button class="btn btn-primary btn-xs" onclick="openInstallModal(\''+a.id+'\')">安装</button> '+
|
||||||
|
'<button class="btn btn-danger btn-xs" onclick="deleteApk(\''+a.id+'\')">删除</button>'+
|
||||||
|
'</td>'+
|
||||||
|
'</tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 填充"设备已装应用"的设备下拉(保留当前选择)
|
||||||
|
async function fillAppsDeviceList(){
|
||||||
|
const sr=await apiGet('/api/status');if(!sr||!sr.ok)return;
|
||||||
|
const sel=document.getElementById('apps-device');
|
||||||
|
if(!sel)return;
|
||||||
|
const cur=sel.value;
|
||||||
|
const devices=(sr.devices||[]).filter(d=>d.present);
|
||||||
|
sel.innerHTML='<option value="">选择设备...</option>'+devices.map(d=>
|
||||||
|
'<option value="'+esc(d.serial)+'">'+esc((d.serial||'').replace(/:5555$/,'')+' ('+(d.device_name||d.model||'未知设备')+')')+'</option>'
|
||||||
|
).join('');
|
||||||
|
if(cur)sel.value=cur;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看指定设备已安装应用
|
||||||
|
async function loadDeviceApps(){
|
||||||
|
const sel=document.getElementById('apps-device');
|
||||||
|
const serial=sel.value;
|
||||||
|
if(!serial){showToast('请先选择设备','error');return;}
|
||||||
|
const hint=document.getElementById('apps-hint');
|
||||||
|
const tb=document.getElementById('tb-device-apps');
|
||||||
|
hint.textContent='加载中...';
|
||||||
|
const r=await apiGet('/api/devices/'+encodeURIComponent(serial)+'/apps');
|
||||||
|
hint.textContent='';
|
||||||
|
if(!r||!r.ok){
|
||||||
|
tb.innerHTML='<tr><td colspan="4" class="empty">'+(r&&r.error?esc(r.error):'获取失败')+'</td></tr>';
|
||||||
|
showToast('获取失败','error');return;
|
||||||
|
}
|
||||||
|
const apps=r.apps||[];
|
||||||
|
if(!apps.length){tb.innerHTML='<tr><td colspan="4" class="empty">无应用</td></tr>';return;}
|
||||||
|
const userApps=apps.filter(a=>/^\/data\//.test(a.path||'')).length;
|
||||||
|
hint.textContent='共 '+apps.length+' 个应用(用户安装 '+userApps+')';
|
||||||
|
_devAppsData=apps;
|
||||||
|
setListPager('devapps',{
|
||||||
|
data:()=>_devAppsData||[],
|
||||||
|
filter:(a,kw)=>((a.label||a.package||'').toLowerCase().includes(kw)||(a.package||'').toLowerCase().includes(kw)||(a.path||'').toLowerCase().includes(kw)),
|
||||||
|
sortValue:(a,k)=>{ if(k==='version_code')return parseInt(a.version_code)||0; if(k==='version_name')return a.version_name||''; if(k==='label')return (a.label||a.package||''); return a[k]||''; },
|
||||||
|
render:renderDevAppRow,
|
||||||
|
tbody:'tb-device-apps',pager:'pager-devapps',pageSize:20,
|
||||||
|
empty:'<tr><td colspan="4" class="empty">无应用</td></tr>'
|
||||||
|
});
|
||||||
|
applyListPager('devapps');
|
||||||
|
initListSearch('devapps','search-devapps');
|
||||||
|
}
|
||||||
|
function renderDevAppRow(a){
|
||||||
|
return '<tr>'+
|
||||||
|
'<td><strong>'+esc(a.label||a.package)+'</strong>'+(a.label?' <span style="font-size:11px;color:#94a3b8;font-family:monospace">'+esc(a.package)+'</span>':'')+'</td>'+
|
||||||
|
'<td>'+(a.version_name?esc(a.version_name):'-')+'</td>'+
|
||||||
|
'<td style="font-size:12px;color:#94a3b8">'+(a.version_code!=null?esc(a.version_code):'-')+'</td>'+
|
||||||
|
'<td style="font-size:11px;color:#94a3b8;word-break:break-all">'+esc(a.path||'-')+'</td>'+
|
||||||
|
'</tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadApk(input){
|
||||||
|
if(!input.files||!input.files[0])return;
|
||||||
|
const file=input.files[0];
|
||||||
|
if(!file.name.toLowerCase().endsWith('.apk')){
|
||||||
|
showToast('请选择 .apk 文件','error');return;
|
||||||
|
}
|
||||||
|
const hint=document.getElementById('apk-hint');
|
||||||
|
const fd=new FormData();
|
||||||
|
fd.append('file',file);
|
||||||
|
// 用 XMLHttpRequest 才能拿到上传进度(fetch 不支持上传进度事件)
|
||||||
|
const xhr=new XMLHttpRequest();
|
||||||
|
xhr.open('POST','/api/apks/upload');
|
||||||
|
if(_csrfToken)xhr.setRequestHeader('X-CSRF-Token',_csrfToken);
|
||||||
|
xhr.upload.onprogress=function(e){
|
||||||
|
if(e.lengthComputable){
|
||||||
|
const pct=Math.round(e.loaded*100/e.total);
|
||||||
|
const loaded=(e.loaded/1048576).toFixed(1);
|
||||||
|
const total=(e.total/1048576).toFixed(1);
|
||||||
|
hint.innerHTML='<span style="color:#f59e0b">正在上传 '+esc(file.name)+' '+pct+'% ('+loaded+'/'+total+' MB)</span>'+
|
||||||
|
'<div class="progress" style="width:200px;height:6px;margin-top:4px"><div class="progress-bar" style="width:'+pct+'%"></div></div>';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.onload=function(){
|
||||||
|
if(xhr.status===401){window.location='/login';return;}
|
||||||
|
try{
|
||||||
|
const data=JSON.parse(xhr.responseText);
|
||||||
|
if(data.ok){
|
||||||
|
showToast(data.msg||'上传成功','success');
|
||||||
|
loadApks();
|
||||||
|
}else{
|
||||||
|
showToast(data.error||'上传失败','error');
|
||||||
|
hint.textContent='';
|
||||||
|
}
|
||||||
|
}catch(e){
|
||||||
|
showToast('解析响应失败','error');
|
||||||
|
hint.textContent='';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.onerror=function(){
|
||||||
|
showToast('上传失败:网络错误','error');
|
||||||
|
hint.textContent='';
|
||||||
|
};
|
||||||
|
xhr.send(fd);
|
||||||
|
input.value='';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteApk(id){
|
||||||
|
if(!confirm('确认删除此APK文件?'))return;
|
||||||
|
const r=await apiDelete('/api/apks/'+id);
|
||||||
|
handleResult(r,loadApks);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openInstallModal(apkId){
|
||||||
|
const apk=_apksCache.find(a=>a.id===apkId);
|
||||||
|
const apkName=apk?(apk.display_name||'APK'):'APK';
|
||||||
|
const r=await apiGet('/api/apks/install/devices');
|
||||||
|
const online=(r&&r.ok)?(r.devices||[]):[];
|
||||||
|
if(!online.length){
|
||||||
|
showToast('没有可用设备(STF 池或本机 adb 均无)','error');return;
|
||||||
|
}
|
||||||
|
const body=
|
||||||
|
'<div style="margin-bottom:12px">'+
|
||||||
|
'<strong>安装应用:</strong><span class="label label-primary">'+esc(apkName)+'</span>'+
|
||||||
|
' <span style="font-size:12px;color:#94a3b8">运行中设备将自动跳过</span>'+
|
||||||
|
'</div>'+
|
||||||
|
'<div style="margin-bottom:8px">'+
|
||||||
|
'<button class="btn btn-sm" onclick="document.querySelectorAll(\'#install-dev-list input[type=checkbox]\').forEach(c=>c.checked=true)">全选</button> '+
|
||||||
|
'<button class="btn btn-sm" onclick="document.querySelectorAll(\'#install-dev-list input[type=checkbox]\').forEach(c=>c.checked=false)">全不选</button> '+
|
||||||
|
'<button class="btn btn-sm btn-primary" onclick="selectIdleForInstall()">全选空闲</button>'+
|
||||||
|
'</div>'+
|
||||||
|
'<div id="install-dev-list" class="device-list">'+
|
||||||
|
online.map(d=>{
|
||||||
|
const isUsb=d.source==='usb';
|
||||||
|
const st='<span class="label label-'+(isUsb?'primary':'default')+'" style="margin-left:6px">'+(isUsb?'本地USB':(d.source==='stf'?'STF':'adb'))+'</span>';
|
||||||
|
// serial 唯一,显示 IP + 型号便于区分同型号设备
|
||||||
|
const label=(d.serial||'').replace(/:5555$/,'')+' · '+(d.model||(isUsb?'USB 有线设备':'设备'));
|
||||||
|
return '<label><input type="checkbox" value="'+esc(d.serial)+'" checked>'+esc(label)+st+'</label>';
|
||||||
|
}).join('')+
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
document.getElementById('modal-title').textContent='安装应用到设备';
|
||||||
|
document.getElementById('modal-body').innerHTML=body;
|
||||||
|
document.getElementById('modal-footer').innerHTML=
|
||||||
|
'<button class="btn" onclick="closeModal()">取消</button>'+
|
||||||
|
'<button class="btn btn-primary" onclick="startInstall(\''+apkId+'\')">开始安装</button>';
|
||||||
|
document.getElementById('modal-overlay').classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectIdleForInstall(){
|
||||||
|
document.querySelectorAll('#install-dev-list label').forEach(lbl=>{
|
||||||
|
const cb=lbl.querySelector('input[type=checkbox]');
|
||||||
|
const isRunning=lbl.querySelector('.label-success');
|
||||||
|
cb.checked=!isRunning;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startInstall(apkId){
|
||||||
|
const checked=document.querySelectorAll('#install-dev-list input[type=checkbox]:checked');
|
||||||
|
const serials=Array.from(checked).map(c=>c.value);
|
||||||
|
if(!serials.length){
|
||||||
|
showToast('请至少选择一台设备','error');return;
|
||||||
|
}
|
||||||
|
const r=await apiPost('/api/apks/install',{apk_id:apkId,serials:serials});
|
||||||
|
if(!r||!r.ok){showToast(r?r.error:'请求失败','error');return;}
|
||||||
|
showToast(r.msg||'安装已启动','success');
|
||||||
|
showInstallProgress();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showInstallProgress(){
|
||||||
|
document.getElementById('modal-title').textContent='安装进度';
|
||||||
|
document.getElementById('modal-body').innerHTML=
|
||||||
|
'<div id="install-progress" style="min-height:200px"><p class="text-muted">加载中...</p></div>';
|
||||||
|
document.getElementById('modal-footer').innerHTML=
|
||||||
|
'<button class="btn" onclick="closeInstallModal()" id="install-close-btn" style="display:none">关闭</button>';
|
||||||
|
pollInstallStatus();
|
||||||
|
if(_installTimer)clearInterval(_installTimer);
|
||||||
|
_installTimer=setInterval(pollInstallStatus,3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollInstallStatus(){
|
||||||
|
const r=await apiGet('/api/apks/install/status');
|
||||||
|
if(!r||!r.ok)return;
|
||||||
|
const st=r.status;
|
||||||
|
if(!st){
|
||||||
|
document.getElementById('install-progress').innerHTML='<p class="text-muted">无安装任务</p>';
|
||||||
|
if(_installTimer){clearInterval(_installTimer);_installTimer=null;}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const SS={pending:{t:'等待',c:'default'},installing:{t:'安装中',c:'warning'},success:{t:'成功',c:'success'},failed:{t:'失败',c:'danger'},skipped:{t:'跳过',c:'default'}};
|
||||||
|
const items=st.items||{};
|
||||||
|
const itemRows=Object.entries(items).map(([serial,info])=>{
|
||||||
|
const s=SS[info.status]||{t:info.status,c:'default'};
|
||||||
|
return '<tr><td>'+esc(info.name||serial)+'</td>'+
|
||||||
|
'<td style="font-family:monospace;font-size:11px;color:#94a3b8">'+esc(serial)+'</td>'+
|
||||||
|
'<td><span class="label label-'+s.c+'">'+s.t+'</span></td>'+
|
||||||
|
'<td style="font-size:12px;color:#64748b">'+esc(info.msg||'')+'</td></tr>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const pct=st.total>0?Math.round((st.success+st.failed+st.skipped)*100/st.total):0;
|
||||||
|
const summary=
|
||||||
|
'<div style="margin-bottom:12px">'+
|
||||||
|
'<div style="display:flex;gap:8px;margin-bottom:8px;font-size:13px;flex-wrap:wrap">'+
|
||||||
|
'<span>应用:<strong>'+esc(st.apk_name)+'</strong></span>'+
|
||||||
|
'<span class="label label-success">成功 '+st.success+'</span>'+
|
||||||
|
'<span class="label label-danger">失败 '+st.failed+'</span>'+
|
||||||
|
'<span class="label label-default">跳过 '+st.skipped+'</span>'+
|
||||||
|
'<span class="label label-warning">安装中 '+st.installing+'</span>'+
|
||||||
|
'<span class="label label-default">等待 '+st.pending+'</span>'+
|
||||||
|
'</div>'+
|
||||||
|
'<div class="progress" style="width:100%;height:8px"><div class="progress-bar" style="width:'+pct+'%"></div></div>'+
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
document.getElementById('install-progress').innerHTML=
|
||||||
|
summary+'<table class="table" style="margin-bottom:0"><thead><tr><th>设备</th><th>Serial</th><th>状态</th><th>详情</th></tr></thead><tbody>'+itemRows+'</tbody></table>';
|
||||||
|
|
||||||
|
if(st.finished){
|
||||||
|
if(_installTimer){clearInterval(_installTimer);_installTimer=null;}
|
||||||
|
document.getElementById('install-close-btn').style.display='';
|
||||||
|
showToast('安装完成:成功'+st.success+' 失败'+st.failed+' 跳过'+st.skipped,'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeInstallModal(){
|
||||||
|
if(_installTimer){clearInterval(_installTimer);_installTimer=null;}
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// 通用基础:esc/CSRF/权限/API/Toast、Tab 切换与页内子分栏、模态框、行内按钮操作
|
||||||
|
// ================== 通用工具 ==================
|
||||||
|
const ACTION_LABELS={like:'点赞',comment:'评论',follow:'关注',share:'分享'};
|
||||||
|
const STATUS_MAP={idle:{t:'未启动',c:'default'},connecting:{t:'连接中',c:'warning'},running:{t:'运行中',c:'success'},done:{t:'完成',c:'primary'},error:{t:'异常',c:'danger'},released:{t:'已释放',c:'default'},failed:{t:'失败',c:'danger'}};
|
||||||
|
const TARGET_MAP={all:'全部空闲',group:'按分组',serial:'指定设备'};
|
||||||
|
const APP_NAMES={'com.ss.android.ugc.aweme':'抖音','com.ss.android.ugc.aweme.lite':'抖音极速版','com.smile.gifmaker':'快手','com.kuaishou.nebula':'快手极速版','com.tencent.mm':'微信','com.tencent.mobileqq':'QQ','com.tencent.qqlive':'腾讯视频','com.eg.android.AlipayGphone':'支付宝','com.taobao.taobao':'淘宝','com.tmall.wireless':'天猫','com.jingdong.app.mall':'京东','com.xunmeng.pinduoduo':'拼多多','com.ss.android.article.news':'今日头条','com.zhihu.android':'知乎','com.miui.home':'桌面','com.android.launcher':'桌面','com.android.settings':'设置','com.android.systemui':'系统','com.github.uiautomator':'U2','com.github.uiautomator.test':'U2测试'};
|
||||||
|
|
||||||
|
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||||
|
|
||||||
|
// ===== CSRF =====
|
||||||
|
let _csrfToken='';
|
||||||
|
// 页面加载后调用:获取 CSRF token,非 GET 请求需携带
|
||||||
|
async function initCsrf(){
|
||||||
|
try{
|
||||||
|
const r=await fetch('/api/csrf');
|
||||||
|
const d=await r.json();
|
||||||
|
_csrfToken=d.token||'';
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
function _csrfHeaders(){
|
||||||
|
return _csrfToken?{'X-CSRF-Token':_csrfToken}:{};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 当前用户与权限 =====
|
||||||
|
// _me 来自 /api/me:{username, is_admin, perms:[tasks/devices/apks/logs]}
|
||||||
|
// 管理员 perms 恒为全部。无权限的功能入口(tab/按钮)用 data-perm 标记或 _can() 判断隐藏。
|
||||||
|
let _me={is_admin:false,perms:[],username:''};
|
||||||
|
async function loadMe(){
|
||||||
|
try{
|
||||||
|
const r=await apiGet('/api/me');
|
||||||
|
if(r&&r.ok)_me=r.user||{is_admin:false,perms:[],username:''};
|
||||||
|
const un=document.getElementById('me-username');
|
||||||
|
if(un)un.textContent=_me.username||'';
|
||||||
|
// 隐藏无权限的 tab 和按钮(data-perm="admin" 表示仅管理员)
|
||||||
|
document.querySelectorAll('[data-perm]').forEach(function(el){
|
||||||
|
const p=el.dataset.perm;
|
||||||
|
const ok=(p==='admin')?_me.is_admin:(_me.perms||[]).includes(p);
|
||||||
|
if(!ok)el.style.display='none';
|
||||||
|
});
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
function _can(perm){
|
||||||
|
return _me.is_admin||(_me.perms||[]).includes(perm);
|
||||||
|
}
|
||||||
|
const PERM_LABELS={tasks:'任务管理',devices:'设备控制',apks:'应用管理',logs:'日志查看'};
|
||||||
|
|
||||||
|
async function apiGet(url){
|
||||||
|
const r=await fetch(url);
|
||||||
|
if(r.status===401){window.location='/login';return null;}
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
async function apiPost(url,body){
|
||||||
|
const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json',..._csrfHeaders()},body:body?JSON.stringify(body):'{}'});
|
||||||
|
if(r.status===401){window.location='/login';return null;}
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
async function apiPut(url,body){
|
||||||
|
const r=await fetch(url,{method:'PUT',headers:{'Content-Type':'application/json',..._csrfHeaders()},body:body?JSON.stringify(body):'{}'});
|
||||||
|
if(r.status===401){window.location='/login';return null;}
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiDelete(url){
|
||||||
|
const r=await fetch(url,{method:'DELETE',headers:_csrfHeaders()});
|
||||||
|
if(r.status===401){window.location='/login';return null;}
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(msg,type){
|
||||||
|
const t=document.getElementById('toast');
|
||||||
|
t.className='toast show toast-'+(type||'success');
|
||||||
|
t.textContent=msg;
|
||||||
|
setTimeout(()=>t.className='toast',3000);
|
||||||
|
}
|
||||||
|
function handleResult(r,callback){
|
||||||
|
if(!r)return;
|
||||||
|
if(r.ok){showToast(r.msg||'操作成功','success');if(callback)callback();}
|
||||||
|
else{showToast(r.error||'操作失败','error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================== Tab 切换 ==================
|
||||||
|
let _activeTab='monitor';
|
||||||
|
let _monitorTimer=null;
|
||||||
|
let _logTimer=null;
|
||||||
|
|
||||||
|
function showTab(name){
|
||||||
|
_activeTab=name;
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.navbar .tab').forEach(t=>t.classList.remove('active'));
|
||||||
|
document.getElementById('tab-'+name).classList.add('active');
|
||||||
|
document.querySelectorAll('.navbar .tab')[['monitor','tasks','groups','logs','users','maintenance','tools'].indexOf(name)].classList.add('active');
|
||||||
|
// 停止所有定时器
|
||||||
|
if(_monitorTimer){clearInterval(_monitorTimer);_monitorTimer=null;}
|
||||||
|
if(_logTimer){clearInterval(_logTimer);_logTimer=null;}
|
||||||
|
// 按需加载数据
|
||||||
|
if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);}
|
||||||
|
if(name==='tasks'){showSubTab('tasks',_activeSubs.tasks);loadTasks();loadCustomActions();}
|
||||||
|
if(name==='tools'){showSubTab('tools',_activeSubs.tools);loadToolsDevices();loadAdbDevices();loadTailscaleDevices();loadApks();loadStfDevMgmt();}
|
||||||
|
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', 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================== 模态框 ==================
|
||||||
|
function showModal(){
|
||||||
|
document.getElementById('modal-overlay').classList.add('show');
|
||||||
|
}
|
||||||
|
function closeModal(){
|
||||||
|
// 先关闭弹窗(最重要的一步,确保一定执行)
|
||||||
|
const ov=document.getElementById('modal-overlay');
|
||||||
|
if(ov)ov.classList.remove('show');
|
||||||
|
// 再做清理工作,任何异常都不影响弹窗关闭
|
||||||
|
try{
|
||||||
|
stopShotTimer();
|
||||||
|
_shotSerial='';
|
||||||
|
const box=document.getElementById('modal-box');
|
||||||
|
if(box){box.classList.remove('shot-modal');box.classList.remove('wide-modal');}
|
||||||
|
}catch(e){console.warn('closeModal cleanup error:',e);}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭元素抓取弹窗(不影响任务编辑窗口)
|
||||||
|
function closeElPicker(){
|
||||||
|
var ov=document.getElementById('el-picker-overlay');
|
||||||
|
if(ov)ov.classList.remove('show');
|
||||||
|
// 清理状态
|
||||||
|
try{ _stepEditor._elData=null; _stepEditor._elSerial=''; }catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================== 行内按钮操作(停止/执行/启停等,自动 toast + 刷新)==================
|
||||||
|
async function apiAction(url,body){
|
||||||
|
const r=await apiPost(url,body);
|
||||||
|
if(!r)return;
|
||||||
|
if(r.ok){showToast(r.msg||'操作成功','success');if(_activeTab==='monitor')loadMonitor();if(_activeTab==='tasks')loadTasks();}
|
||||||
|
else{showToast(r.error||'操作失败','error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanForeground(){
|
||||||
|
const r=await apiPost('/api/scan_foreground');
|
||||||
|
if(!r)return;
|
||||||
|
if(r.ok){showToast('扫描已启动,请等待几秒...','success');loadMonitor();}
|
||||||
|
else{showToast(r.error||'扫描失败','error');}
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
|||||||
|
// 统一列表组件:setListPager 搜索 + 分页 + 排序(_lpSort)
|
||||||
|
// ================== 通用列表:搜索 + 分页 + 排序 ==================
|
||||||
|
// 用法:
|
||||||
|
// setListPager(key,{data:()=>array, filter:(item,kw)=>bool, render:(item)=>html,
|
||||||
|
// tbody:'tb-id', pager:'pager-id', pageSize:N,
|
||||||
|
// sortValue:(item,colKey)=>值, // 可排序列的取值函数
|
||||||
|
// empty:'<tr>...'});
|
||||||
|
// applyListPager(key); // 数据变化后重渲染(含搜索/翻页/排序状态)
|
||||||
|
// initListSearch(key,'search-id'); // 搜索框绑定
|
||||||
|
// _lpSort(key,'colKey'); // 表头排序:th 加 data-sort="colKey" + onclick,
|
||||||
|
// // 再点切换升降序,第三次取消;排序指示器自动更新
|
||||||
|
var _LIST_PAGERS={};
|
||||||
|
|
||||||
|
function _lp(key){ return _LIST_PAGERS[key] || (_LIST_PAGERS[key]={page:1,kw:'',cfg:null,sort:null}); }
|
||||||
|
|
||||||
|
function setListPager(key,cfg){
|
||||||
|
// 只更新配置,不重置 page/kw/sort —— 监控列表 5 秒自动刷新时保持用户当前状态
|
||||||
|
var st=_lp(key); st.cfg=cfg;
|
||||||
|
}
|
||||||
|
function initListSearch(key,inputId){
|
||||||
|
var input=document.getElementById(inputId);
|
||||||
|
if(!input||input.dataset.bound)return;
|
||||||
|
input.dataset.bound='1';
|
||||||
|
input.addEventListener('input',function(){
|
||||||
|
var st=_lp(key); st.kw=this.value.trim(); st.page=1; applyListPager(key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function _lpSort(key,colKey){
|
||||||
|
var st=_lp(key);
|
||||||
|
if(st.sort&&st.sort.key===colKey){
|
||||||
|
if(st.sort.dir===1)st.sort.dir=-1; // 升序 → 降序
|
||||||
|
else if(st.sort.dir===-1)st.sort=null; // 降序 → 取消排序
|
||||||
|
else st.sort={key:colKey,dir:1};
|
||||||
|
}else{
|
||||||
|
st.sort={key:colKey,dir:1};
|
||||||
|
}
|
||||||
|
applyListPager(key);
|
||||||
|
}
|
||||||
|
function applyListPager(key){
|
||||||
|
var st=_lp(key);
|
||||||
|
if(!st.cfg)return;
|
||||||
|
var cfg=st.cfg;
|
||||||
|
var all=(cfg.data&&cfg.data())||[];
|
||||||
|
var kw=st.kw.toLowerCase();
|
||||||
|
var filtered=kw?all.filter(function(x){return cfg.filter(x,kw);}):all;
|
||||||
|
// 排序:cfg.sortValue 取列值;空值/缺失恒排最后;字符串用中文数字感知比较(10 排在 9 后)
|
||||||
|
if(st.sort&&cfg.sortValue){
|
||||||
|
var sk=st.sort.key, dir=st.sort.dir;
|
||||||
|
filtered=filtered.slice().sort(function(a,b){
|
||||||
|
var va=cfg.sortValue(a,sk), vb=cfg.sortValue(b,sk);
|
||||||
|
if(va===vb)return 0;
|
||||||
|
if(va==null||va==='')return 1;
|
||||||
|
if(vb==null||vb==='')return -1;
|
||||||
|
if(typeof va==='number'&&typeof vb==='number')return (va-vb)*dir;
|
||||||
|
return String(va).localeCompare(String(vb),'zh-Hans-CN',{numeric:true})*dir;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var pageSize=cfg.pageSize||10;
|
||||||
|
var pages=Math.max(1,Math.ceil(filtered.length/pageSize));
|
||||||
|
if(st.page>pages)st.page=pages;
|
||||||
|
if(st.page<1)st.page=1;
|
||||||
|
var start=(st.page-1)*pageSize;
|
||||||
|
var slice=filtered.slice(start,start+pageSize);
|
||||||
|
var tb=document.getElementById(cfg.tbody);
|
||||||
|
if(tb){
|
||||||
|
tb.innerHTML=slice.map(cfg.render).join('')||(cfg.empty||'<tr><td colspan="20" class="empty">无数据</td></tr>');
|
||||||
|
// 表头排序指示器(th[data-sort],所有表格统一)
|
||||||
|
if(cfg.sortValue){
|
||||||
|
var table=tb.closest('table');
|
||||||
|
if(table)table.querySelectorAll('thead th[data-sort]').forEach(function(th){
|
||||||
|
th.classList.add('sortable');
|
||||||
|
th.classList.toggle('sort-asc',!!st.sort&&st.sort.key===th.dataset.sort&&st.sort.dir===1);
|
||||||
|
th.classList.toggle('sort-desc',!!st.sort&&st.sort.key===th.dataset.sort&&st.sort.dir===-1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var pg=document.getElementById(cfg.pager);
|
||||||
|
if(pg)pg.innerHTML=_lpPagerHtml(key,pages,st.page,filtered.length,pageSize);
|
||||||
|
}
|
||||||
|
function _lpPagerHtml(key,pages,cur,total,pageSize){
|
||||||
|
if(pages<=1)return total>0?'<div class="list-pager"><span class="lp-total">共 '+total+' 条</span></div>':'';
|
||||||
|
var h='<div class="list-pager"><span class="lp-total">共 '+total+' 条</span>';
|
||||||
|
h+='<button class="btn btn-xs" onclick="_lpGo(\''+key+'\','+(cur-1)+')"'+(cur<=1?' disabled':'')+'>‹ 上一页</button>';
|
||||||
|
for(var p=Math.max(1,cur-2);p<=Math.min(pages,cur+2);p++){
|
||||||
|
h+='<button class="btn btn-xs'+(p===cur?' btn-primary':'')+'" onclick="_lpGo(\''+key+'\','+p+')">'+p+'</button>';
|
||||||
|
}
|
||||||
|
h+='<button class="btn btn-xs" onclick="_lpGo(\''+key+'\','+(cur+1)+')"'+(cur>=pages?' disabled':'')+'>下一页 ›</button>';
|
||||||
|
h+='</div>';
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
function _lpGo(key,p){
|
||||||
|
var st=_lp(key); st.page=p; applyListPager(key);
|
||||||
|
}
|
||||||
|
|
||||||
+1
-2531
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,323 @@
|
|||||||
|
// 任务 Tab:任务计划 CRUD/调度解析/运行窗口 + 自定义动作管理
|
||||||
|
// ================== Tab 2: 任务 ==================
|
||||||
|
let _taskTypes=[];
|
||||||
|
let _groupsList=[];
|
||||||
|
let _devicesList=[];
|
||||||
|
|
||||||
|
async function loadTasks(){
|
||||||
|
const [jr,gr,dr,tr]=await Promise.all([
|
||||||
|
apiGet('/api/jobs'),apiGet('/api/groups'),apiGet('/api/devices'),apiGet('/api/task_types')
|
||||||
|
]);
|
||||||
|
if(!jr)return;
|
||||||
|
_jobsData=jr.jobs||[];
|
||||||
|
_taskTypes=tr?tr.task_types||[]:[];
|
||||||
|
_groupsList=gr?gr.groups||[]:[];
|
||||||
|
_devicesList=dr?dr.devices||[]:[];
|
||||||
|
renderTaskTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTaskTable(){
|
||||||
|
const jobs=_jobsData||[];
|
||||||
|
setListPager('tasks',{
|
||||||
|
data:()=>jobs,
|
||||||
|
filter:(j,kw)=>((j.name||'').toLowerCase().includes(kw)||(j.task_type||'').toLowerCase().includes(kw)||(j.id||'').toLowerCase().includes(kw)),
|
||||||
|
sortValue:(j,k)=>{ if(k==='id')return j.id||''; if(k==='enabled')return j.enabled?1:0; if(k==='next_run')return j.next_run||''; return j[k]||''; },
|
||||||
|
render:renderTaskRow,
|
||||||
|
tbody:'tb-tasks',pager:'pager-tasks',pageSize:10,
|
||||||
|
empty:'<tr><td colspan="9" class="empty">暂无任务,点击"新建任务"创建</td></tr>'
|
||||||
|
});
|
||||||
|
applyListPager('tasks');
|
||||||
|
initListSearch('tasks','search-tasks');
|
||||||
|
}
|
||||||
|
function renderTaskRow(j){
|
||||||
|
const tgt=TARGET_MAP[j.target.mode]||j.target.mode;
|
||||||
|
const tgtDetail=j.target.mode==='group'?(j.target.group_name||''):(j.target.mode==='serial'?(j.target.serial||''):'');
|
||||||
|
const sched=j.schedule.mode==='cron_stop'?('启停 '+cronToText(j.schedule.cron||'')+' → '+cronToText(j.schedule.stop_cron||'')):
|
||||||
|
j.schedule.mode==='cron'?('定时 '+cronToText(j.schedule.cron||'')):'手动';
|
||||||
|
const schedTxt=sched+((j.schedule.window&&j.schedule.window.start&&j.schedule.window.end)
|
||||||
|
?' [窗口 '+esc(j.schedule.window.start)+'-'+esc(j.schedule.window.end)+']':'');
|
||||||
|
const schedOut='<td>'+schedTxt+'</td>';
|
||||||
|
// 下次运行时间(后端已按运行窗口跳过窗口外触发点)
|
||||||
|
const nextRun=fmtNextRun(j.next_run);
|
||||||
|
const nextOut='<td>'+(nextRun||'<span class="text-muted">-</span>')+'</td>';
|
||||||
|
const retry=j.retry.max_attempts>1?(j.retry.max_attempts+'次/'+j.retry.delay+'s'):'不重试';
|
||||||
|
const en=j.enabled?'<span class="label label-success">启用</span>':'<span class="label label-default">停用</span>';
|
||||||
|
return '<tr>'+
|
||||||
|
'<td><code>'+esc(j.id)+'</code></td>'+
|
||||||
|
'<td><strong>'+esc(j.name)+'</strong></td>'+
|
||||||
|
'<td>'+esc(j.task_type)+'</td>'+
|
||||||
|
'<td>'+en+'</td>'+
|
||||||
|
'<td>'+esc(tgt)+(tgtDetail?'('+esc(tgtDetail)+')':'')+'</td>'+
|
||||||
|
schedOut+
|
||||||
|
nextOut+
|
||||||
|
'<td>'+retry+'</td>'+
|
||||||
|
'<td>'+
|
||||||
|
(_can('tasks')?(
|
||||||
|
'<button class="btn btn-primary btn-xs" onclick="apiAction(\'/api/jobs/'+j.id+'/run\')">执行</button>'+
|
||||||
|
'<button class="btn btn-xs" onclick="apiAction(\'/api/jobs/'+j.id+'/toggle\',{enabled:'+(!j.enabled)+'})">'+(j.enabled?'停用':'启用')+'</button>'+
|
||||||
|
'<button class="btn btn-xs" onclick="openTaskModal(\''+j.id+'\')">编辑</button>'+
|
||||||
|
'<button class="btn btn-danger btn-xs" onclick="deleteTask(\''+j.id+'\')">删除</button>'
|
||||||
|
):'<span class="text-muted">只读</span>')+
|
||||||
|
'</td>'+
|
||||||
|
'</tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTaskModal(jobId){
|
||||||
|
const job=jobId?(_jobsData||[]).find(j=>j.id===jobId):null;
|
||||||
|
const isEdit=!!job;
|
||||||
|
document.getElementById('modal-title').textContent=isEdit?'编辑任务':'新建任务';
|
||||||
|
// 任务编辑器含步骤编辑,加宽弹窗;关闭时在 closeModal 移除
|
||||||
|
document.getElementById('modal-box').classList.add('wide-modal');
|
||||||
|
|
||||||
|
const j=job||{name:'',task_type:'douyin_nurture',enabled:true,target:{mode:'all'},params:{},schedule:{mode:'once'},retry:{max_attempts:1,delay:60}};
|
||||||
|
const tt=_taskTypes.map(t=>'<option value="'+t.task_type+'"'+(t.task_type===j.task_type?' selected':'')+'>'+esc(t.name)+'</option>').join('');
|
||||||
|
const gl=_groupsList.map(g=>'<option value="'+g.name+'"'+(g.name===j.target.group_name?' selected':'')+'>'+esc(g.name)+'</option>').join('');
|
||||||
|
const dl=_devicesList.map(s=>'<option value="'+s+'"'+(s===j.target.serial?' selected':'')+'>'+esc(s)+'</option>').join('');
|
||||||
|
const sm=j.schedule.mode;
|
||||||
|
const startCron=(sm==='cron'||sm==='cron_stop')?(j.schedule.cron||''):'';
|
||||||
|
const stopCron=sm==='cron_stop'?(j.schedule.stop_cron||''):'';
|
||||||
|
// 运行窗口(每天时间段,窗口外不启动)
|
||||||
|
const win=j.schedule.window||null;
|
||||||
|
const winStart=win?(win.start||'09:00'):'09:00';
|
||||||
|
const winEnd=win?(win.end||'18:00'):'18:00';
|
||||||
|
// 简单调度:把已有 cron 解析回"频率+时间",编辑时回填;解析不了落到自定义
|
||||||
|
const sc=parseCronToFreq(startCron);
|
||||||
|
const stopF=parseCronToFreq(stopCron).time;
|
||||||
|
const weekdayOpts=[0,1,2,3,4,5,6].map(w=>
|
||||||
|
'<option value="'+w+'"'+(sc.weekday===w?' selected':'')+'>'+WEEKDAY_NAMES[w]+'</option>').join('');
|
||||||
|
|
||||||
|
document.getElementById('modal-body').innerHTML=
|
||||||
|
'<div class="form-row">'+
|
||||||
|
'<div class="form-group"><label>任务名</label><input type="text" class="form-control" id="f-name" value="'+esc(j.name)+'" placeholder="如:抖音养号-上午"></div>'+
|
||||||
|
'<div class="form-group"><label>任务类型</label><select class="form-control" id="f-task_type" onchange="onTaskTypeChange()">'+tt+'</select></div>'+
|
||||||
|
'</div>'+
|
||||||
|
'<div class="form-group form-check"><input type="checkbox" id="f-enabled" '+(j.enabled?'checked':'')+'><label for="f-enabled">启用此任务</label></div>'+
|
||||||
|
'<div class="form-group"><label>目标设备</label>'+
|
||||||
|
'<select class="form-control" id="f-target_mode" onchange="toggleTargetFields()">'+
|
||||||
|
'<option value="all"'+(j.target.mode==='all'?' selected':'')+'>全部空闲设备</option>'+
|
||||||
|
'<option value="group"'+(j.target.mode==='group'?' selected':'')+'>按分组</option>'+
|
||||||
|
'<option value="serial"'+(j.target.mode==='serial'?' selected':'')+'>指定设备</option>'+
|
||||||
|
'</select>'+
|
||||||
|
'<div id="f-target_group_wrap" class="form-group" style="margin-top:8px;margin-bottom:0">'+
|
||||||
|
'<select class="form-control" id="f-target_group"><option value="">选择分组...</option>'+gl+'</select>'+
|
||||||
|
'</div>'+
|
||||||
|
'<div id="f-target_serial_wrap" class="form-group" style="margin-top:8px;margin-bottom:0">'+
|
||||||
|
'<select class="form-control" id="f-target_serial"><option value="">选择设备...</option>'+dl+'</select>'+
|
||||||
|
'</div>'+
|
||||||
|
'</div>'+
|
||||||
|
'<div class="form-row">'+
|
||||||
|
'<div class="form-group"><label>调度方式</label>'+
|
||||||
|
'<select class="form-control" id="f-schedule_mode" onchange="toggleScheduleFields()">'+
|
||||||
|
'<option value="once"'+(sm==='once'?' selected':'')+'>手动(不自动启动)</option>'+
|
||||||
|
'<option value="cron"'+(sm==='cron'?' selected':'')+'>定时启动</option>'+
|
||||||
|
'<option value="cron_stop"'+(sm==='cron_stop'?' selected':'')+'>定时启动+停止</option>'+
|
||||||
|
'</select>'+
|
||||||
|
'</div>'+
|
||||||
|
'<div class="form-group" id="f-freq_wrap" style="display:none"><label>频率</label>'+
|
||||||
|
'<select class="form-control" id="f-schedule_freq" onchange="toggleScheduleFields()">'+
|
||||||
|
'<option value="daily"'+((sc.freq==='daily')?' selected':'')+'>每天</option>'+
|
||||||
|
'<option value="hourly"'+((sc.freq==='hourly')?' selected':'')+'>每小时(整点)</option>'+
|
||||||
|
'<option value="everyN"'+((sc.freq==='everyN')?' selected':'')+'>每隔 N 小时</option>'+
|
||||||
|
'<option value="weekly"'+((sc.freq==='weekly')?' selected':'')+'>每周</option>'+
|
||||||
|
'<option value="custom"'+((sc.freq==='custom')?' selected':'')+'>自定义 cron(高级)</option>'+
|
||||||
|
'</select>'+
|
||||||
|
'</div>'+
|
||||||
|
'<div class="form-group" id="f-time_wrap" style="display:none"><label>启动时间</label><input type="time" class="form-control" id="f-schedule_time" value="'+esc(sc.time||'09:00')+'"></div>'+
|
||||||
|
'<div class="form-group" id="f-interval_wrap" style="display:none"><label>间隔(小时)</label><input type="number" class="form-control" id="f-schedule_interval" value="'+(sc.interval||2)+'" min="1" max="12"><div class="help">从 0 点起算,如 2 = 0/2/4/6...点启动</div></div>'+
|
||||||
|
'<div class="form-group" id="f-weekday_wrap" style="display:none"><label>星期</label><select class="form-control" id="f-schedule_weekday">'+weekdayOpts+'</select></div>'+
|
||||||
|
'<div class="form-group" id="f-cron_wrap" style="display:none"><label>启动 Cron 表达式</label><input type="text" class="form-control" id="f-schedule_cron" value="'+esc(startCron)+'" placeholder="如 0 9 * * * = 每天9点启动" style="font-family:monospace"><div class="help">格式:分 时 日 月 周(0 0 * * 0 = 每周日零点)</div></div>'+
|
||||||
|
'<div class="form-group" id="f-stop_wrap" style="display:none"><label>停止时间(每天)</label><input type="time" class="form-control" id="f-schedule_stop_time" value="'+esc(stopF||'18:00')+'"><div class="help">到点自动停止所有运行中的 worker</div></div>'+
|
||||||
|
'</div>'+
|
||||||
|
'<div class="form-group form-check" style="margin-top:4px"><input type="checkbox" id="f-window_enable" '+(win?'checked':'')+' onchange="toggleWindowFields()"><label for="f-window_enable">启用运行窗口(只在指定时间段内运行)</label></div>'+
|
||||||
|
'<div class="form-row" id="f-window_wrap" style="display:'+(win?'flex':'none')+'">'+
|
||||||
|
'<div class="form-group"><label>允许运行·开始</label><input type="time" class="form-control" id="f-window_start" value="'+esc(winStart)+'"></div>'+
|
||||||
|
'<div class="form-group"><label>允许运行·结束</label><input type="time" class="form-control" id="f-window_end" value="'+esc(winEnd)+'"></div>'+
|
||||||
|
'</div>'+
|
||||||
|
'<div class="help" id="f-window_help" style="display:'+(win?'block':'none')+'">窗口外任务不会启动(定时触发和手动执行都受约束)。跨午夜示例:21:00-09:00 = 晚 9 点到次日早 9 点允许运行</div>'+
|
||||||
|
'<div class="form-row">'+
|
||||||
|
'<div class="form-group"><label>重试次数</label><input type="number" class="form-control" id="f-retry_attempts" value="'+j.retry.max_attempts+'" min="1" max="10"></div>'+
|
||||||
|
'<div class="form-group"><label>重试间隔(秒)</label><input type="number" class="form-control" id="f-retry_delay" value="'+j.retry.delay+'" min="10" max="3600"></div>'+
|
||||||
|
'</div>'+
|
||||||
|
'<div class="form-group" id="f-params_wrap"><label>任务参数 (JSON)</label><textarea class="form-control" id="f-params" rows="6">'+esc(JSON.stringify(j.params||{},null,2))+'</textarea><div class="help">可留空使用默认参数。抖音任务可设 max_duration(秒,如 1800=30分钟)限制单设备运行时长</div></div>'+
|
||||||
|
'<div class="step-editor" id="step-editor">'+
|
||||||
|
'<div class="se-head"><span>步骤编辑器</span><span style="font-size:11px;color:var(--text-light);flex:1;margin-left:12px">用步骤卡片上的 ☑ 选中多个步骤,可打包成自定义动作</span>'+
|
||||||
|
(_can('tasks')?'<button class="btn btn-xs btn-primary" id="se-pack-btn" disabled onclick="_stepEditor.saveAsCustomAction()">打包选中步骤</button>':'')+
|
||||||
|
'</div>'+
|
||||||
|
'<div class="se-body">'+
|
||||||
|
'<div class="se-lib" id="se-lib"><div class="lib-title">操作库</div></div>'+
|
||||||
|
'<div class="se-canvas" id="se-canvas"></div>'+
|
||||||
|
'</div>'+
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
// 初始化步骤编辑器(generic_steps 时显示,其他隐藏);jobId 作为草稿 key
|
||||||
|
_stepEditor.init(j.params&&j.params.steps?j.params.steps:[],j.params&&j.params.max_duration?j.params.max_duration:0,jobId||'');
|
||||||
|
|
||||||
|
document.getElementById('modal-footer').innerHTML=
|
||||||
|
'<button class="btn" onclick="closeModal()">取消</button>'+
|
||||||
|
'<button class="btn btn-primary" onclick="saveTask('+(isEdit?'\''+jobId+'\'':'null')+')">'+(isEdit?'保存':'创建')+'</button>';
|
||||||
|
|
||||||
|
toggleTargetFields();
|
||||||
|
toggleScheduleFields();
|
||||||
|
onTaskTypeChange();
|
||||||
|
showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTargetFields(){
|
||||||
|
const mode=document.getElementById('f-target_mode').value;
|
||||||
|
document.getElementById('f-target_group_wrap').style.display=mode==='group'?'block':'none';
|
||||||
|
document.getElementById('f-target_serial_wrap').style.display=mode==='serial'?'block':'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 简单调度:cron(5段) ↔ 频率+时间 互转 =====
|
||||||
|
const WEEKDAY_NAMES=['周日','周一','周二','周三','周四','周五','周六'];
|
||||||
|
// cron → 简单设置 {freq: daily|hourly|everyN|weekly|custom, time:"HH:MM", interval, weekday(0-6)}
|
||||||
|
function parseCronToFreq(cron){
|
||||||
|
const r={freq:'custom',time:'',interval:2,weekday:1};
|
||||||
|
const p=(cron||'').trim().split(/\s+/);
|
||||||
|
if(p.length!==5)return r;
|
||||||
|
const [mi,h,d,m,w]=p;
|
||||||
|
if(mi==='0'&&h==='*'&&d==='*'&&m==='*'&&w==='*'){r.freq='hourly';return r;}
|
||||||
|
const n=(h.match(/^\*\/(\d+)$/)||[])[1]; // 每隔N小时:时位是 */N
|
||||||
|
if(n&&mi==='0'&&d==='*'&&m==='*'&&w==='*'){r.freq='everyN';r.interval=parseInt(n)||2;return r;}
|
||||||
|
if(/^\d{1,2}$/.test(mi)&&/^\d{1,2}$/.test(h)&&d==='*'&&m==='*'&&w==='*'){
|
||||||
|
r.freq='daily';r.time=h.padStart(2,'0')+':'+mi.padStart(2,'0');return r;
|
||||||
|
}
|
||||||
|
if(/^\d{1,2}$/.test(mi)&&/^\d{1,2}$/.test(h)&&d==='*'&&m==='*'&&/^[0-7]$/.test(w)){
|
||||||
|
r.freq='weekly';r.time=h.padStart(2,'0')+':'+mi.padStart(2,'0');r.weekday=parseInt(w)%7;return r;
|
||||||
|
}
|
||||||
|
return r; // 解析不了 → 落到自定义
|
||||||
|
}
|
||||||
|
// 表单 → cron(5段)
|
||||||
|
function buildCronFromFreq(){
|
||||||
|
const freq=document.getElementById('f-schedule_freq').value;
|
||||||
|
if(freq==='custom')return document.getElementById('f-schedule_cron').value.trim();
|
||||||
|
if(freq==='hourly')return '0 * * * *';
|
||||||
|
if(freq==='everyN'){
|
||||||
|
const n=parseInt(document.getElementById('f-schedule_interval').value)||2;
|
||||||
|
return '0 */'+Math.min(Math.max(n,1),12)+' * * *';
|
||||||
|
}
|
||||||
|
const t=(document.getElementById('f-schedule_time').value||'09:00').split(':');
|
||||||
|
const mi=parseInt(t[1])||0, h=parseInt(t[0])||0;
|
||||||
|
if(freq==='weekly'){
|
||||||
|
const w=parseInt(document.getElementById('f-schedule_weekday').value)||0;
|
||||||
|
return mi+' '+h+' * * '+w;
|
||||||
|
}
|
||||||
|
return mi+' '+h+' * * *'; // daily
|
||||||
|
}
|
||||||
|
// cron(5段) → 中文描述(任务列表显示)
|
||||||
|
function cronToText(cron){
|
||||||
|
const r=parseCronToFreq(cron||'');
|
||||||
|
if(r.freq==='hourly')return '每小时';
|
||||||
|
if(r.freq==='everyN')return '每'+r.interval+'小时';
|
||||||
|
if(r.freq==='daily')return '每天 '+r.time;
|
||||||
|
if(r.freq==='weekly')return WEEKDAY_NAMES[r.weekday]+' '+r.time;
|
||||||
|
return 'cron: '+(cron||'-');
|
||||||
|
}
|
||||||
|
function toggleScheduleFields(){
|
||||||
|
const mode=document.getElementById('f-schedule_mode').value;
|
||||||
|
const showSched=(mode==='cron'||mode==='cron_stop');
|
||||||
|
document.getElementById('f-freq_wrap').style.display=showSched?'block':'none';
|
||||||
|
document.getElementById('f-stop_wrap').style.display=mode==='cron_stop'?'block':'none';
|
||||||
|
if(showSched)showFreqFields();
|
||||||
|
}
|
||||||
|
function toggleWindowFields(){
|
||||||
|
const on=document.getElementById('f-window_enable').checked;
|
||||||
|
document.getElementById('f-window_wrap').style.display=on?'flex':'none';
|
||||||
|
document.getElementById('f-window_help').style.display=on?'block':'none';
|
||||||
|
}
|
||||||
|
function showFreqFields(){
|
||||||
|
const freq=document.getElementById('f-schedule_freq').value;
|
||||||
|
document.getElementById('f-time_wrap').style.display=(freq==='daily'||freq==='weekly')?'block':'none';
|
||||||
|
document.getElementById('f-interval_wrap').style.display=freq==='everyN'?'block':'none';
|
||||||
|
document.getElementById('f-weekday_wrap').style.display=freq==='weekly'?'block':'none';
|
||||||
|
document.getElementById('f-cron_wrap').style.display=freq==='custom'?'block':'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTaskTypeChange(){
|
||||||
|
const tt=document.getElementById('f-task_type').value;
|
||||||
|
const isGeneric=tt==='generic_steps';
|
||||||
|
document.getElementById('step-editor').classList.toggle('show',isGeneric);
|
||||||
|
document.getElementById('f-params_wrap').style.display=isGeneric?'none':'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================== 自定义动作管理 ==================
|
||||||
|
async function loadCustomActions(){
|
||||||
|
const r=await apiGet('/api/custom_actions'); if(!r||!r.ok)return;
|
||||||
|
_customActions=r.actions||[];
|
||||||
|
setListPager('actions',{
|
||||||
|
data:()=>_customActions,
|
||||||
|
filter:(a,kw)=>((a.name||'').toLowerCase().includes(kw)),
|
||||||
|
sortValue:(a,k)=>{ if(k==='steps')return (a.steps||[]).length; if(k==='created_at')return a.created_at||''; return a[k]||''; },
|
||||||
|
render:renderActionRow,
|
||||||
|
tbody:'tb-actions',pager:'pager-actions',pageSize:10,
|
||||||
|
empty:'<tr><td colspan="5" class="empty">暂无自定义动作,可点击"新建动作"或用任务编辑器☑选中步骤打包</td></tr>'
|
||||||
|
});
|
||||||
|
applyListPager('actions');
|
||||||
|
initListSearch('actions','search-actions');
|
||||||
|
_stepEditor._renderLib(); // 刷新步骤编辑器左侧动作库
|
||||||
|
}
|
||||||
|
function renderActionRow(a){
|
||||||
|
const n=(a.steps||[]).length;
|
||||||
|
return '<tr>'+
|
||||||
|
'<td style="font-size:20px">'+esc(a.icon||'📦')+'</td>'+
|
||||||
|
'<td><strong>'+esc(a.name)+'</strong></td>'+
|
||||||
|
'<td>'+n+'</td>'+
|
||||||
|
'<td style="font-size:12px;color:#94a3b8">'+esc(a.created_at||'-')+'</td>'+
|
||||||
|
'<td>'+
|
||||||
|
(_can('tasks')?(
|
||||||
|
'<button class="btn btn-xs" onclick="openActionModal(\''+a.id+'\')">编辑</button> '+
|
||||||
|
'<button class="btn btn-danger btn-xs" onclick="deleteAction(\''+a.id+'\')">删除</button>'
|
||||||
|
):'<span class="text-muted">只读</span>')+
|
||||||
|
'</td>'+
|
||||||
|
'</tr>';
|
||||||
|
}
|
||||||
|
function openActionModal(actionId){
|
||||||
|
const action=actionId?_customActions.find(a=>a.id===actionId):null;
|
||||||
|
const isEdit=!!action;
|
||||||
|
document.getElementById('action-modal-title').textContent=isEdit?'编辑动作':'新建动作';
|
||||||
|
document.getElementById('action-modal-body').innerHTML=
|
||||||
|
'<div class="form-row">'+
|
||||||
|
'<div class="form-group"><label>动作名称</label><input type="text" class="form-control" id="a-name" value="'+esc(action?action.name:'')+'" placeholder="如:点赞动作"></div>'+
|
||||||
|
'<div class="form-group"><label>图标</label><input type="text" class="form-control" id="a-icon" value="'+esc(action?(action.icon&&action.icon!=='📦'?action.icon:''):'')+'" placeholder="📦" style="width:80px"></div>'+
|
||||||
|
'</div>'+
|
||||||
|
'<div class="help" style="margin-bottom:8px">用下方步骤编辑器编排动作内容,保存后可拖入任务步骤编辑器复用</div>'+
|
||||||
|
'<div class="step-editor" style="display:block">'+
|
||||||
|
'<div class="se-head"><span>动作步骤</span><span style="font-size:11px;color:var(--text-light);margin-left:12px">支持循环/分组/概率触发/拖拽排序</span>'+
|
||||||
|
(_can('tasks')?'<button class="btn btn-xs btn-primary" id="se-pack-btn2" disabled onclick="_stepEditor.saveAsCustomAction()">打包选中步骤</button>':'')+'</div>'+
|
||||||
|
'<div class="se-body">'+
|
||||||
|
'<div class="se-lib" id="action-lib"><div class="lib-title">操作库</div></div>'+
|
||||||
|
'<div class="se-canvas" id="action-canvas"></div>'+
|
||||||
|
'</div>'+
|
||||||
|
'</div>';
|
||||||
|
document.getElementById('action-modal-footer').innerHTML=
|
||||||
|
'<button class="btn" onclick="closeActionModal()">取消</button>'+
|
||||||
|
'<button class="btn btn-primary" onclick="saveAction('+(isEdit?'\''+actionId+'\'':'null')+')">保存动作</button>';
|
||||||
|
// 用独立画布初始化步骤编辑器(不与任务编辑器冲突)
|
||||||
|
_stepEditor.init(action&&action.steps?action.steps:[], 0, 'action:'+(actionId||'new'), 'action-canvas', 'action-lib');
|
||||||
|
document.getElementById('action-modal-overlay').classList.add('show');
|
||||||
|
}
|
||||||
|
async function saveAction(actionId){
|
||||||
|
const name=document.getElementById('a-name').value.trim();
|
||||||
|
if(!name){showToast('动作名不能为空','error');return;}
|
||||||
|
const icon=document.getElementById('a-icon').value.trim()||'📦';
|
||||||
|
const steps=_stepEditor.collect();
|
||||||
|
_stripIds(steps); // 入库前去掉 id,避免重复使用后 id 冲突
|
||||||
|
if(!steps.length){showToast('至少需要1个步骤','error');return;}
|
||||||
|
const data={name:name,icon:icon,steps:steps};
|
||||||
|
if(actionId){
|
||||||
|
const r=await apiPut('/api/custom_actions/'+actionId,data);
|
||||||
|
handleResult(r,()=>{_stepEditor.clearDraft();closeActionModal();loadCustomActions();});
|
||||||
|
}else{
|
||||||
|
const r=await apiPost('/api/custom_actions',data);
|
||||||
|
handleResult(r,()=>{_stepEditor.clearDraft();closeActionModal();loadCustomActions();});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function deleteAction(id){
|
||||||
|
if(!confirm('确认删除此动作?'))return;
|
||||||
|
const r=await apiDelete('/api/custom_actions/'+id);
|
||||||
|
handleResult(r,()=>loadCustomActions());
|
||||||
|
}
|
||||||
|
function closeActionModal(){
|
||||||
|
document.getElementById('action-modal-overlay').classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
// 工具 Tab:剪贴板注入/应用版本/STF设备管理 + 维护(重启STF) + adb终端 + Tailscale管理
|
||||||
|
// ================== 工具:剪贴板注入 ==================
|
||||||
|
let _clipDevices = []; // [{serial, state}],来源 /api/adb/devices(本地 adb + STF 池)
|
||||||
|
let _clipSelected = new Set();
|
||||||
|
|
||||||
|
// 本地 adb 设备列表共享缓存(剪贴板/终端两个面板同源,10 秒内复用避免重复请求;force 强制刷新)
|
||||||
|
let _adbDevicesCache = null; // {ts, data}
|
||||||
|
|
||||||
|
async function _fetchAdbDevices(force){
|
||||||
|
const now = Date.now();
|
||||||
|
if(!force && _adbDevicesCache && now - _adbDevicesCache.ts < 10000){
|
||||||
|
return _adbDevicesCache.data;
|
||||||
|
}
|
||||||
|
const r = await apiGet('/api/adb/devices');
|
||||||
|
if(!r || !r.ok) return null;
|
||||||
|
_adbDevicesCache = {ts: now, data: r.devices || []};
|
||||||
|
return _adbDevicesCache.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadToolsDevices(force){
|
||||||
|
const list = await _fetchAdbDevices(force);
|
||||||
|
const status = document.getElementById('clip-status');
|
||||||
|
if(list === null){status.textContent='加载失败';showToast('加载设备失败','error');return;}
|
||||||
|
_clipDevices = list;
|
||||||
|
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="'+esc(d.serial)+'">'
|
||||||
|
+'<input type="checkbox" value="'+esc(d.serial)+'"'+( _clipSelected.has(d.serial)?' checked':'')+' onchange="toggleClipDevice(this)">'
|
||||||
|
+'<span style="font-family:monospace;flex:1;overflow:hidden;text-overflow:ellipsis">'+esc(d.serial)+'</span>'
|
||||||
|
+'<span class="label '+stCls+'">'+st+'</span></label>';
|
||||||
|
}).join('');
|
||||||
|
status.textContent = '共 '+_clipDevices.length+' 台设备';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleClipDevice(cb){
|
||||||
|
if(cb.checked)_clipSelected.add(cb.value);
|
||||||
|
else _clipSelected.delete(cb.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================== 工具:应用版本管理 ==================
|
||||||
|
let _appVerRows = []; // [{serial, installed, version_name, version_code, error}]
|
||||||
|
|
||||||
|
async function queryAppVers(){
|
||||||
|
const pkg = document.getElementById('appver-pkg').value.trim();
|
||||||
|
if(!pkg){showToast('请输入包名','error');return;}
|
||||||
|
const btn = document.querySelector('#tools-sub-appver .btn-primary');
|
||||||
|
btn.disabled = true; const old = btn.textContent; btn.textContent = '查询中...';
|
||||||
|
try{
|
||||||
|
const r = await apiPost('/api/tools/appver', {pkg});
|
||||||
|
if(!r || !r.ok){showToast('查询失败: '+(r&&r.error||''),'error');return;}
|
||||||
|
_appVerRows = Object.entries(r.results||{}).map(([serial,v])=>({serial, ...v}));
|
||||||
|
setListPager('appver',{
|
||||||
|
data:()=>_appVerRows,
|
||||||
|
filter:(x,kw)=>((x.serial||'').toLowerCase().includes(kw)||(x.version_name||'').toLowerCase().includes(kw)||(x.version_code||'').toLowerCase().includes(kw)),
|
||||||
|
sortValue:(x,k)=>{
|
||||||
|
if(k==='state'){ if(x.error)return 2; return x.installed?0:1; }
|
||||||
|
if(k==='code')return parseInt(x.version_code)||0;
|
||||||
|
if(k==='version')return x.version_name||'';
|
||||||
|
return x.serial||'';
|
||||||
|
},
|
||||||
|
render:renderAppVerRow,
|
||||||
|
tbody:'tb-appver',pager:'pager-appver',pageSize:10,
|
||||||
|
empty:'<tr><td colspan="4" class="empty">无可用设备</td></tr>'
|
||||||
|
});
|
||||||
|
applyListPager('appver');
|
||||||
|
initListSearch('appver','search-appver');
|
||||||
|
document.getElementById('appver-status').textContent = '共 '+(r.total||0)+' 台设备'+(r.fail?','+r.fail+' 台查询失败':'');
|
||||||
|
if(r.fail)showToast(r.fail+' 台设备查询失败,详见表格','error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false; btn.textContent = old;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function renderAppVerRow(x){
|
||||||
|
if(x.error)return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'+
|
||||||
|
'<td><span class="label label-warning">查询失败</span></td><td colspan="2">'+esc(x.error)+'</td></tr>';
|
||||||
|
if(x.installed)return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'+
|
||||||
|
'<td><span class="label label-success">已安装</span></td>'+
|
||||||
|
'<td>'+esc(x.version_name||'')+'</td><td style="font-family:monospace">'+esc(x.version_code||'')+'</td></tr>';
|
||||||
|
return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'+
|
||||||
|
'<td><span class="label label-default">未安装</span></td><td>-</td><td>-</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================== 工具:STF 设备管理 ==================
|
||||||
|
let _stfDevRows = []; // [{ip, inCfg, state}]
|
||||||
|
|
||||||
|
async function loadStfDevMgmt(){
|
||||||
|
const status = document.getElementById('stfdev-status');
|
||||||
|
const r = await apiGet('/api/stf/devmgmt/status');
|
||||||
|
if(!r || !r.ok){status.textContent='加载失败';showToast('加载失败: '+(r&&r.error||''),'error');return;}
|
||||||
|
const configured = r.configured || []; // 脚本 DEVICES 里的 IP(裸 IP)
|
||||||
|
const connected = {}; // 裸 IP -> state(220 adb devices 的 serial 带 :5555,归一化)
|
||||||
|
(r.connected || []).forEach(d=>{
|
||||||
|
const s = d.serial;
|
||||||
|
connected[s.endsWith(':5555') ? s.slice(0,-5) : s] = d.state;
|
||||||
|
});
|
||||||
|
_stfDevRows = Array.from(new Set(configured.concat(Object.keys(connected)))).map(ip=>({
|
||||||
|
ip, inCfg: configured.includes(ip), state: connected[ip]||''
|
||||||
|
}));
|
||||||
|
setListPager('stfdev',{
|
||||||
|
data:()=>_stfDevRows,
|
||||||
|
filter:(x,kw)=>x.ip.toLowerCase().includes(kw),
|
||||||
|
sortValue:(x,k)=>{
|
||||||
|
if(k==='cfg')return x.inCfg?1:0;
|
||||||
|
if(k==='state'){ const o={device:0,offline:1,unauthorized:2,'':9}; return (o[x.state]!==undefined)?o[x.state]:9; }
|
||||||
|
return x.ip||'';
|
||||||
|
},
|
||||||
|
render:renderStfDevRow,
|
||||||
|
tbody:'tb-stfdev',pager:'pager-stfdev',pageSize:10,
|
||||||
|
empty:'<tr><td colspan="4" class="empty">无设备(脚本未配置、220 adb 也无连接)</td></tr>'
|
||||||
|
});
|
||||||
|
applyListPager('stfdev');
|
||||||
|
initListSearch('stfdev','search-stfdev');
|
||||||
|
status.textContent = '脚本配置 '+configured.length+' 台,已连接 '+Object.keys(connected).length+' 台';
|
||||||
|
}
|
||||||
|
function renderStfDevRow(x){
|
||||||
|
const stHtml = x.state==='device'
|
||||||
|
? '<span class="label label-success">已连接</span>'
|
||||||
|
: (x.state ? '<span class="label label-warning">'+esc(x.state)+'</span>' : '<span class="label label-default">未连接</span>');
|
||||||
|
return '<tr><td style="font-family:monospace">'+esc(x.ip)+'</td>'+
|
||||||
|
'<td>'+(x.inCfg?'<span class="label label-info">脚本中</span>':'<span class="label label-default">未配置</span>')+'</td>'+
|
||||||
|
'<td>'+stHtml+'</td>'+
|
||||||
|
'<td><button class="btn btn-xs btn-danger" onclick="removeStfDev(\''+esc(x.ip)+'\')">移除(断开+删脚本)</button></td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addStfDev(){
|
||||||
|
const ip = document.getElementById('stfdev-ip').value.trim();
|
||||||
|
if(!ip){showToast('请输入设备 IP','error');return;}
|
||||||
|
const r = await apiPost('/api/stf/devmgmt/add', {ip:ip});
|
||||||
|
if(!r)return;
|
||||||
|
if(r.ok){
|
||||||
|
showToast((r.msgs||[]).join(';'),'success');
|
||||||
|
document.getElementById('stfdev-ip').value='';
|
||||||
|
loadStfDevMgmt();
|
||||||
|
}else showToast('添加失败: '+(r.error||''),'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeStfDev(ip){
|
||||||
|
if(!confirm('确定移除 '+ip+'?\n\n将从 connect_devices.sh 删除并断开 220 的 adb 连接,\n设备会从 OpenSTF 设备池下线(任务不再分配到它)。'))return;
|
||||||
|
const r = await apiPost('/api/stf/devmgmt/remove', {ip:ip});
|
||||||
|
if(!r)return;
|
||||||
|
if(r.ok){
|
||||||
|
showToast((r.msgs||[]).join(';'),'success');
|
||||||
|
loadStfDevMgmt();
|
||||||
|
}else showToast('移除失败: '+(r.error||''),'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function injectClipboard(){
|
||||||
|
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">'+esc(s)+'</td><td>'+(v.ok?'<span class="label label-success">成功</span>':'<span class="label label-danger">失败</span> '+esc(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================== Tab: 维护(仅管理员) ==================
|
||||||
|
// 服务器类命令(不需要 -s 目标设备)
|
||||||
|
const _NO_DEVICE_CMDS=['devices','version','help','start-server','connect','kill-server','disconnect'];
|
||||||
|
function _selectedSerial(){
|
||||||
|
const sel=document.getElementById('adb-device');
|
||||||
|
return sel?sel.value:'';
|
||||||
|
}
|
||||||
|
// 选中目标设备时自动附加 -s <serial>;服务器类命令保持原样
|
||||||
|
function _attachDevice(cmd){
|
||||||
|
const serial=_selectedSerial();
|
||||||
|
if(!serial)return cmd;
|
||||||
|
if(/^-s\s/.test(cmd))return cmd; // 已手写 -s 不再重复附加
|
||||||
|
const first=(cmd.split(/\s+/)[0]||'').toLowerCase();
|
||||||
|
if(_NO_DEVICE_CMDS.indexOf(first)>=0)return cmd;
|
||||||
|
return '-s '+serial+' '+cmd;
|
||||||
|
}
|
||||||
|
async function loadAdbDevices(force){
|
||||||
|
const sel=document.getElementById('adb-device');
|
||||||
|
if(!sel)return;
|
||||||
|
const list=await _fetchAdbDevices(force);
|
||||||
|
if(list===null)return;
|
||||||
|
const cur=sel.value;
|
||||||
|
const stateTxt={device:'在线',offline:'离线',stf:'STF池(未连接)'};
|
||||||
|
const opts=['<option value="">(不指定设备)</option>'].concat(
|
||||||
|
list.map(d=>
|
||||||
|
'<option value="'+esc(d.serial)+'">'+esc(d.serial)+' ('+(stateTxt[d.state]||d.state)+')</option>'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
sel.innerHTML=opts.join('');
|
||||||
|
if(cur)sel.value=cur;
|
||||||
|
}
|
||||||
|
function fillAdbCmd(cmd){
|
||||||
|
document.getElementById('adb-cmd-input').value=cmd;
|
||||||
|
}
|
||||||
|
async function _runAdbFull(full){
|
||||||
|
const out=document.getElementById('adb-output');
|
||||||
|
out.textContent='$ '+full+'\n执行中...';
|
||||||
|
const r=await apiPost('/api/adb/cmd',{cmd:full});
|
||||||
|
if(!r)return;
|
||||||
|
if(!r.ok){out.textContent='$ '+full+'\n⚠ '+r.error;return;}
|
||||||
|
out.textContent='$ '+full+'\n\n'+(r.stdout||'')+(r.stderr?'\n[stderr] '+r.stderr:'')+'\n\n[exit '+r.code+']';
|
||||||
|
out.scrollTop=out.scrollHeight;
|
||||||
|
}
|
||||||
|
async function runAdbCmd(){
|
||||||
|
const input=document.getElementById('adb-cmd-input');
|
||||||
|
let cmd=(input.value||'').trim();
|
||||||
|
if(!cmd){showToast('请输入 adb 命令','error');return;}
|
||||||
|
cmd=cmd.replace(/^adb\s+/i,''); // 兼容手输/快捷按钮带的 "adb " 前缀,避免拼成 "adb adb devices"
|
||||||
|
cmd=_attachDevice(cmd);
|
||||||
|
await _runAdbFull('adb '+cmd);
|
||||||
|
}
|
||||||
|
function uninstallStfAgent(){
|
||||||
|
const serial=_selectedSerial();
|
||||||
|
if(!serial){showToast('请先选择目标设备','error');return;}
|
||||||
|
if(!confirm('确定卸载 '+serial+' 上的 STF agent(jp.co.cyberagent.stf)?\n\n卸载后 STF 会在设备重连时自动重新推送安装。'))return;
|
||||||
|
_runAdbFull('adb -s '+serial+' uninstall jp.co.cyberagent.stf');
|
||||||
|
}
|
||||||
|
function reconnectAdbDevice(){
|
||||||
|
const serial=_selectedSerial();
|
||||||
|
if(!serial){showToast('请先选择目标设备','error');return;}
|
||||||
|
_runAdbFull('adb connect '+serial);
|
||||||
|
}
|
||||||
|
function checkStfAgent(){
|
||||||
|
const serial=_selectedSerial();
|
||||||
|
if(!serial){showToast('请先选择目标设备','error');return;}
|
||||||
|
_runAdbFull('adb -s '+serial+' shell pm list packages | grep cyberagent');
|
||||||
|
}
|
||||||
|
async function restartStf(){
|
||||||
|
if(!confirm('确定重启 STF 容器?\n\n影响:STF 约 30 秒不可用,期间所有设备占用状态重置,正在执行的任务会受影响。\n\n确认继续吗?'))return;
|
||||||
|
const st=document.getElementById('stf-restart-status');
|
||||||
|
st.textContent='正在重启 STF 容器(SSH 执行中,最长 60s)...';
|
||||||
|
const r=await apiPost('/api/stf/restart',{});
|
||||||
|
if(!r)return;
|
||||||
|
if(r.ok){st.textContent='✅ '+r.msg+(r.detail?' ['+r.detail+']':'');showToast(r.msg,'success');}
|
||||||
|
else{st.textContent='❌ '+r.error;showToast(r.error,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试此步骤弹窗关闭
|
||||||
|
function closeTestModal(){
|
||||||
|
const ov=document.getElementById('test-overlay');
|
||||||
|
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)),
|
||||||
|
sortValue:(d,k)=>{
|
||||||
|
if(k==='ip')return (d.addresses||[])[0]||'';
|
||||||
|
if(k==='online'){ if(d.online===true)return 0; if(d.online===false)return 2; return 1; } // null=最近12h在线
|
||||||
|
if(k==='authorized')return d.authorized?1:0;
|
||||||
|
if(k==='key_expiry')return d.key_expiry_disabled?1:0;
|
||||||
|
return d[k]||'';
|
||||||
|
},
|
||||||
|
render:renderTsRow,
|
||||||
|
tbody:'tb-tailscale',pager:'pager-tailscale',pageSize:10,
|
||||||
|
empty:'<tr><td colspan="8" class="empty">tailnet 暂无设备</td></tr>'
|
||||||
|
});
|
||||||
|
applyListPager('tailscale');
|
||||||
|
initListSearch('tailscale','search-tailscale');
|
||||||
|
if(st)st.textContent='共 '+_tsDevices.length+' 台设备';
|
||||||
|
}
|
||||||
|
function renderTsRow(d){
|
||||||
|
const ip=(d.addresses||[])[0]||'-';
|
||||||
|
return '<tr>'+
|
||||||
|
'<td><code>'+esc(d.hostname||'-')+'</code></td>'+
|
||||||
|
'<td>'+esc(d.name||'-')+'</td>'+
|
||||||
|
'<td style="font-family:monospace">'+esc(ip)+'</td>'+
|
||||||
|
'<td>'+esc(d.os||'-')+'</td>'+
|
||||||
|
'<td>'+((d.online===true)?'<span class="label label-success">在线</span>':(d.online===false?'<span class="label label-default">离线</span>':'<span class="label label-warning" title="12 小时内见过但当前未上报">最近在线</span>'))+'</td>'+
|
||||||
|
'<td>'+(d.authorized?'<span class="label label-success">已授权</span>':'<span class="label label-danger">未授权</span>')+'</td>'+
|
||||||
|
'<td>'+(d.key_expiry_disabled?'<span class="label label-warning">永不过期</span>':'<span class="label label-default">会过期</span>')+'</td>'+
|
||||||
|
'<td style="white-space:nowrap">'+
|
||||||
|
'<button class="btn btn-xs" onclick="renameTailscaleDevice(\''+esc(d.id)+'\',\''+esc(d.name||'')+'\')">改名</button> '+
|
||||||
|
'<button class="btn btn-xs" onclick="setTsIp(\''+esc(d.id)+'\',\''+esc((d.addresses||[])[0]||'')+'\')" title="修改设备的 Tailscale IPv4(会断开当前连接,平台 serial 随之变化)">设置IP</button> '+
|
||||||
|
'<button class="btn btn-xs" onclick="toggleTsAuth(\''+esc(d.id)+'\','+(!d.authorized)+')">'+(d.authorized?'取消授权':'授权')+'</button> '+
|
||||||
|
'<button class="btn btn-xs" onclick="toggleTsKeyExpiry(\''+esc(d.id)+'\','+(!d.key_expiry_disabled)+')">'+(d.key_expiry_disabled?'恢复过期':'密钥不过期')+'</button> '+
|
||||||
|
'<button class="btn btn-danger btn-xs" onclick="deleteTailscaleDevice(\''+esc(d.id)+'\')">移除</button>'+
|
||||||
|
'</td></tr>';
|
||||||
|
}
|
||||||
|
function renameTailscaleDevice(id, current){
|
||||||
|
const name=prompt('修改显示名(只改名称,不影响主机名):', current||'');
|
||||||
|
if(name===null)return;
|
||||||
|
apiPost('/api/tailscale/devices/'+id,{name:name}).then(r=>{
|
||||||
|
if(!r)return;
|
||||||
|
if(r.ok){showToast(r.msg,'success');loadTailscaleDevices();}
|
||||||
|
else showToast(r.error,'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function setTsIp(id, current){
|
||||||
|
const ip=prompt('设置新的 Tailscale IPv4 地址(如 100.100.10.16):\n\n⚠ 改 IP 会断开设备当前 tailscale 连接,且平台设备池(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='已复制';
|
||||||
|
}
|
||||||
|
|
||||||
@@ -715,6 +715,14 @@ select.form-control{cursor:pointer}
|
|||||||
<!-- ========== Toast ========== -->
|
<!-- ========== Toast ========== -->
|
||||||
<div id="toast" class="toast"></div>
|
<div id="toast" class="toast"></div>
|
||||||
|
|
||||||
|
<!-- 前端模块(按依赖顺序加载:base → list → 页面模块 → admin(含初始化)) -->
|
||||||
|
<script src="/static/admin/base.js"></script>
|
||||||
|
<script src="/static/admin/list.js"></script>
|
||||||
<script src="/static/admin/monitor.js"></script>
|
<script src="/static/admin/monitor.js"></script>
|
||||||
|
<script src="/static/admin/editor.js"></script>
|
||||||
|
<script src="/static/admin/tasks.js"></script>
|
||||||
|
<script src="/static/admin/tools.js"></script>
|
||||||
|
<script src="/static/admin/apps.js"></script>
|
||||||
|
<script src="/static/admin/admin.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ from flask_login import (LoginManager, login_user, logout_user,
|
|||||||
|
|
||||||
from core.stf_client import STFClient
|
from core.stf_client import STFClient
|
||||||
from core.task_manager import TaskManager
|
from core.task_manager import TaskManager
|
||||||
from core.device_worker import clear_worker_error, clear_all_worker_errors
|
from core.device_worker import clear_worker_error, clear_all_worker_errors, get_all_worker_status
|
||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user