From 060157ea1a0724501655cf9eaa074fd6d18488c0 Mon Sep 17 00:00:00 2001
From: butubb <1422726308@qq.com>
Date: Tue, 11 Aug 2026 14:53:04 +0800
Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=89=8D=E7=AB=AFJS=E6=8B=86?=
=?UTF-8?q?=E5=88=86=E4=B8=BA8=E4=B8=AA=E6=A8=A1=E5=9D=97=EF=BC=88base/lis?=
=?UTF-8?q?t/monitor/editor/tasks/tools/apps/admin=EF=BC=8C=E6=8C=89?=
=?UTF-8?q?=E4=BE=9D=E8=B5=96=E9=A1=BA=E5=BA=8F=E5=8A=A0=E8=BD=BD=EF=BC=89?=
=?UTF-8?q?+=20=E5=88=A0=E9=99=A4=E5=BA=9F=E5=BC=83=E6=96=87=E4=BB=B6?=
=?UTF-8?q?=EF=BC=88start=5Fweb.bat/main.py/fix=5Fweb.bat/cleanup.py?=
=?UTF-8?q?=EF=BC=8Cweb=E5=B7=B2=E5=85=A8=E8=A6=86=E7=9B=96=EF=BC=89+=20?=
=?UTF-8?q?=E6=96=87=E6=A1=A3=E5=90=8C=E6=AD=A5=20+=20=E4=BF=AE=E5=A4=8D/a?=
=?UTF-8?q?pi/summary=E7=BC=BA=E5=AF=BC=E5=85=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 66 +-
doc/DEPLOY.md | 15 +-
doc/DEVELOPMENT.md | 2 +-
doc/TASK_DEV.md | 3 +-
main.py | 43 -
scripts/cleanup.py | 48 -
scripts/fix_web.bat | 57 -
scripts/pack.py | 2 +-
start_web.bat | 34 -
static/admin/admin.js | 220 +++
static/admin/apps.js | 264 ++++
static/admin/base.js | 154 +++
static/admin/editor.js | 1096 +++++++++++++++
static/admin/list.js | 94 ++
static/admin/monitor.js | 2532 +---------------------------------
static/admin/tasks.js | 323 +++++
static/admin/tools.js | 388 ++++++
templates/admin/monitor.html | 8 +
web_server.py | 2 +-
19 files changed, 2584 insertions(+), 2767 deletions(-)
delete mode 100644 main.py
delete mode 100644 scripts/cleanup.py
delete mode 100644 scripts/fix_web.bat
delete mode 100644 start_web.bat
create mode 100644 static/admin/admin.js
create mode 100644 static/admin/apps.js
create mode 100644 static/admin/base.js
create mode 100644 static/admin/editor.js
create mode 100644 static/admin/list.js
create mode 100644 static/admin/tasks.js
create mode 100644 static/admin/tools.js
diff --git a/README.md b/README.md
index fa18bab..5500c1d 100644
--- a/README.md
+++ b/README.md
@@ -45,8 +45,6 @@ python web_server.py
启动后访问 **http://localhost:18050/**,默认账号 `admin` / `admin123`。
-> Windows 用户也可双击 `start_web.bat` 启动(自动提权 + 添加防火墙规则,支持局域网访问)。
-
### 验证启动
控制台看到以下日志即表示启动成功:
@@ -73,11 +71,9 @@ python web_server.py
```
platform-tools/
-├── config.py # 根配置(STF地址/token、adb路径、web端口)
+├── config.py # 根配置(部署配置统一从 .env 读,模板见 .env.example)
├── web_server.py # Flask 入口(API + 登录 + 单页应用)
-├── main.py # 命令行入口(单设备跑一次,不走 web)
├── requirements.txt # Python 依赖清单
-├── start_web.bat # Windows 一键启动(提权 + 防火墙)
│
├── core/ # 核心基础设施层
│ ├── logger.py # 统一日志(分文件、10MB 滚动)
@@ -88,49 +84,50 @@ platform-tools/
│ ├── u2_helper.py # uiautomator2 通用辅助函数
│ ├── uiauto_helper.py # uiautodev 元素抓取客户端
│ ├── 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 数据模型 + 数据库初始化
-│ └── actions/
-│ ├── __init__.py # 全局 Action 框架导出
-│ └── base.py # BaseAction 基类 + 注册机制
+│ └── actions/ # 全局 Action 框架
│
├── tasks/ # 任务定义层(每个 App 一个子包)
│ ├── __init__.py # 全局任务注册表
│ ├── base.py # BaseTask 基类 + @register_task 装饰器
│ ├── douyin/ # 抖音养号任务
-│ │ ├── __init__.py
-│ │ ├── task.py # DEFAULT_PARAMS + Worker + Task
-│ │ └── actions/ # 抖音专属操作(点赞等)
-│ │ ├── __init__.py
-│ │ ├── base.py # 抖音 ACTIONS 注册表
-│ │ └── like.py # 点赞操作(找红心/双击回退)
│ └── generic/ # 通用步骤任务(可视化编辑器编排)
-│ ├── __init__.py
-│ └── task.py # 步骤执行引擎(open_app/click/swipe/loop...)
+│ └── task.py # 步骤执行引擎(open_app/click/swipe/if_el...)
│
├── templates/admin/ # 前端页面
-│ ├── monitor.html # 单页应用(监控/任务/分组/日志/用户 5 Tab)
+│ ├── monitor.html # 单页应用(监控/任务/分组/日志/用户/维护/工具)
│ └── login.html # 登录页
│
-├── static/admin/
-│ ├── custom.css # 自定义样式
-│ └── monitor.js # 前端 JS(已从 monitor.html 拆分)
+├── static/admin/ # 前端 JS 模块(monitor.html 按依赖顺序加载)
+│ ├── base.js # 通用基础:API/CSRF/权限/Tab切换/子分栏/模态框
+│ ├── 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/ # 运行时数据
│ ├── users.db # SQLite(用户/分组/任务/自定义动作/APK记录)
│ └── apks/ # 上传的 APK 文件存储
│
├── logs/ # 日志文件(自动生成,10MB 滚动保留 5 份)
-│ ├── core.log # 核心程序日志
-│ ├── task.log # 任务执行日志
-│ ├── web.log # Web 请求日志
-│ └── action.log # 操作执行日志
-│
├── bin/adb/ # adb 可执行文件(Windows: adb.exe + dll)
├── scripts/ # 实用脚本
-│ ├── cleanup.py # 清理 STF 残留设备占用
-│ └── pack.py # 打包项目为 zip(排除运行时产物)
+│ ├── pack.py # 打包项目为 zip(排除运行时产物)
+│ └── supervise.sh # 进程守护(崩溃自动重启)
└── doc/ # 项目文档
- ├── TASK_DEV.md # 任务开发指南(新增 App 任务模板)
+ ├── API.md # API 文档
+ ├── ARCHITECTURE.md # 架构详解
+ ├── DEPLOY.md # 部署指南
+ ├── DEVELOPMENT.md # 开发指南
+ └── TASK_DEV.md # 任务开发指南(新增 App 任务模板)
├── ARCHITECTURE.md # 架构详解
├── DEPLOY.md # 部署指南
└── API.md # API 接口文档
@@ -382,10 +379,8 @@ Web 后台"日志"Tab 可实时查看各模块日志。
| 脚本 | 用途 |
|------|------|
| `python web_server.py` | 启动 Web 管理后台 |
-| `python main.py` | 命令行跑一次抖音养号(单设备,不走 web) |
-| `python scripts/cleanup.py` | 清理 STF 残留设备占用(强杀进程后用) |
| `python scripts/pack.py` | 打包项目为 zip(排除日志/数据库/APK) |
-| `start_web.bat` | Windows 一键启动(提权 + 防火墙规则) |
+| `bash scripts/supervise.sh` | 进程守护(web_server 崩溃自动重启) |
---
@@ -407,13 +402,8 @@ STF 状态有缓存,`present=True` 不代表设备真在线。用"扫描前台
### 设备一直被占用无法释放
-运行清理脚本:
-
-```bash
-python scripts/cleanup.py
-```
-
-会释放当前账户占用的所有设备。
+监控页点"**强制释放占用**"按钮释放当前账户占用的所有设备;
+无人值守场景可在 `.env` 设置 `AUTO_RELEASE_STALE_OCCUPY=true`(单实例)启动自动清理。
### u2.connect 卡死
diff --git a/doc/DEPLOY.md b/doc/DEPLOY.md
index e4d599d..5c85d95 100644
--- a/doc/DEPLOY.md
+++ b/doc/DEPLOY.md
@@ -123,18 +123,13 @@ STF_URL = "http://你的STF地址:端口"
### 2.4 启动服务
-**方式一:命令行启动**
+**命令行启动**
```bash
python web_server.py
```
-**方式二:Windows 一键启动**
-
-双击 `start_web.bat`:
-- 自动提权到管理员
-- 添加防火墙规则(支持局域网访问)
-- 启动 web_server
+(推荐配合 `scripts/supervise.sh` 进程守护,见 3.1 节)
### 2.5 验证部署
@@ -254,7 +249,7 @@ server {
| 7100 | STF | STF 服务端口(STF 自己的配置) |
| 5555 | adb | 设备 adb 网络端口(设备端) |
-### 4.2 Windows 端口问题
+### 4.2 Windows 端口问题(仅 Windows)
Windows 可能将某些端口范围划为动态排除范围,导致绑定失败(WinError 10013)。
@@ -265,12 +260,10 @@ netsh interface ipv4 show excludedportrange protocol=tcp
如果 18050 在排除范围内,修改 `config.py` 的 `WEB_PORT` 到一个不在排除范围内的端口。
-或运行 `scripts/fix_web.bat`(关闭系统代理 + 添加防火墙规则 + 刷新 DNS)。
-
### 4.3 局域网访问
- `WEB_HOST = "0.0.0.0"` 允许局域网访问
-- 需要添加防火墙入站规则(`start_web.bat` 会自动处理)
+- 需要添加防火墙入站规则(Windows 手动添加,或参考 `netsh advfirewall firewall add rule`)
- 局域网其他机器访问 `http://部署机IP:18050/`
---
diff --git a/doc/DEVELOPMENT.md b/doc/DEVELOPMENT.md
index 43b9373..32de8b1 100644
--- a/doc/DEVELOPMENT.md
+++ b/doc/DEVELOPMENT.md
@@ -151,7 +151,7 @@ with sync_playwright() as p:
- **看日志**:`logs/` 下 `core.log` / `task.log` / `web.log` / `action.log`(10MB 滚动,保留 5 份)
- **看设备/任务状态**:浏览器监控页,或 `GET /api/status`、`GET /api/health`
-- **清理 STF 残留占用**:`python scripts/cleanup.py`(或前端"强制释放占用")
+- **清理 STF 残留占用**:前端监控页"强制释放占用"(或 `.env` 配 `AUTO_RELEASE_STALE_OCCUPY=true` 启动自动清理)
- **打包项目**:`python scripts/pack.py`
---
diff --git a/doc/TASK_DEV.md b/doc/TASK_DEV.md
index edcca90..98d559c 100644
--- a/doc/TASK_DEV.md
+++ b/doc/TASK_DEV.md
@@ -26,9 +26,8 @@
```
platform-tools/
-├── config.py # 根配置(STF/adb/web,不放任务参数)
+├── config.py # 根配置(部署值从 .env 读,不放任务参数)
├── web_server.py # Flask 入口(JSON API + 登录页 + 单页应用)
-├── main.py # 根目录入口
├── core/ # 核心程序层
│ ├── __init__.py
│ ├── logger.py # 日志器(分文件、10MB 滚动)
diff --git a/main.py b/main.py
deleted file mode 100644
index 53d36ef..0000000
--- a/main.py
+++ /dev/null
@@ -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()
diff --git a/scripts/cleanup.py b/scripts/cleanup.py
deleted file mode 100644
index 6c07cba..0000000
--- a/scripts/cleanup.py
+++ /dev/null
@@ -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')}")
diff --git a/scripts/fix_web.bat b/scripts/fix_web.bat
deleted file mode 100644
index f658794..0000000
--- a/scripts/fix_web.bat
+++ /dev/null
@@ -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
diff --git a/scripts/pack.py b/scripts/pack.py
index cdf21cd..310d1e9 100644
--- a/scripts/pack.py
+++ b/scripts/pack.py
@@ -15,7 +15,7 @@ SKIP_EXTS = {".pyc", ".pyo"}
# 路径片段包含这些的跳过
SKIP_PARTS = {"__pycache__"}
# 文件名完全匹配这些的跳过
-SKIP_FILES = {"fix_web.bat"}
+SKIP_FILES = {}
# 文件名后缀匹配这些的跳过
SKIP_SUFFIX = (".migrated",)
# 特定目录下特定后缀跳过
diff --git a/start_web.bat b/start_web.bat
deleted file mode 100644
index f321a01..0000000
--- a/start_web.bat
+++ /dev/null
@@ -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
diff --git a/static/admin/admin.js b/static/admin/admin.js
new file mode 100644
index 0000000..98ccec7
--- /dev/null
+++ b/static/admin/admin.js
@@ -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:'
| 暂无分组,点击"新建分组"创建 |
'
+ });
+ applyListPager('groups');
+ initListSearch('groups','search-groups');
+}
+function renderGroupRow(g){
+ const serials=g.serials||[];
+ return ''+
+ '| '+esc(g.name)+' | '+
+ ''+esc(g.description||'-')+' | '+
+ ''+serials.length+' | '+
+ ''+(serials.length?serials.map(esc).join(', '):'-')+' | '+
+ ''+
+ (_can('tasks')?(
+ ''+
+ ''
+ ):'只读')+
+ ' | '+
+ '
';
+}
+
+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=>
+ ''
+ ).join('');
+
+ document.getElementById('modal-body').innerHTML=
+ ''+
+ ''+
+ '';
+
+ document.getElementById('modal-footer').innerHTML=
+ ''+
+ '';
+
+ 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,'[ERROR]')
+ .replace(/\[WARNING\]/g,'[WARNING]')
+ .replace(/\[INFO\]/g,'[INFO]');
+ document.getElementById('log-content').innerHTML=colored||'(空)';
+ 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:'| 暂无用户 |
'
+ });
+ applyListPager('users');
+ initListSearch('users','search-users');
+}
+function renderUserRow(u){
+ const admin=u.is_admin?'管理员':'普通';
+ const perms=(u.perms||[]).map(p=>''+(PERM_LABELS[p]||p)+'').join(' ')
+ ||'-';
+ return ''+
+ '| '+u.id+' | '+
+ ''+esc(u.username)+' | '+
+ ''+admin+' | '+
+ ''+perms+' | '+
+ ''+
+ ''+
+ (u.username==='admin'?'':'')+
+ ' | '+
+ '
';
+}
+
+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=>
+ ''
+ ).join('');
+ document.getElementById('modal-body').innerHTML=
+ ''+
+ ''+
+ ''+
+ '';
+
+ document.getElementById('modal-footer').innerHTML=
+ ''+
+ '';
+
+ 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');
diff --git a/static/admin/apps.js b/static/admin/apps.js
new file mode 100644
index 0000000..ce730e0
--- /dev/null
+++ b/static/admin/apps.js
@@ -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:'| 暂无APK,点击"上传APK"上传 |
'
+ });
+ applyListPager('apks');
+ initListSearch('apks','search-apks');
+ await fillAppsDeviceList();
+}
+function renderApkRow(a){
+ return ''+
+ '| '+esc(a.display_name||'(未命名)')+' | '+
+ ''+esc(a.package_name||'-')+' | '+
+ ''+(a.version_name?esc(a.version_name):'-')+' | '+
+ ''+fmtSize(a.size)+' | '+
+ ''+esc(a.upload_time)+' | '+
+ ''+
+ ' '+
+ ''+
+ ' | '+
+ '
';
+}
+
+// 填充"设备已装应用"的设备下拉(保留当前选择)
+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=''+devices.map(d=>
+ ''
+ ).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='| '+(r&&r.error?esc(r.error):'获取失败')+' |
';
+ showToast('获取失败','error');return;
+ }
+ const apps=r.apps||[];
+ if(!apps.length){tb.innerHTML='| 无应用 |
';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:'| 无应用 |
'
+ });
+ applyListPager('devapps');
+ initListSearch('devapps','search-devapps');
+}
+function renderDevAppRow(a){
+ return ''+
+ '| '+esc(a.label||a.package)+''+(a.label?' '+esc(a.package)+'':'')+' | '+
+ ''+(a.version_name?esc(a.version_name):'-')+' | '+
+ ''+(a.version_code!=null?esc(a.version_code):'-')+' | '+
+ ''+esc(a.path||'-')+' | '+
+ '
';
+}
+
+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='正在上传 '+esc(file.name)+' '+pct+'% ('+loaded+'/'+total+' MB)'+
+ '';
+ }
+ };
+ 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=
+ ''+
+ '安装应用:'+esc(apkName)+''+
+ ' 运行中设备将自动跳过'+
+ '
'+
+ ''+
+ ' '+
+ ' '+
+ ''+
+ '
'+
+ ''+
+ online.map(d=>{
+ const isUsb=d.source==='usb';
+ const st=''+(isUsb?'本地USB':(d.source==='stf'?'STF':'adb'))+'';
+ // serial 唯一,显示 IP + 型号便于区分同型号设备
+ const label=(d.serial||'').replace(/:5555$/,'')+' · '+(d.model||(isUsb?'USB 有线设备':'设备'));
+ return '';
+ }).join('')+
+ '
';
+
+ document.getElementById('modal-title').textContent='安装应用到设备';
+ document.getElementById('modal-body').innerHTML=body;
+ document.getElementById('modal-footer').innerHTML=
+ ''+
+ '';
+ 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=
+ '';
+ document.getElementById('modal-footer').innerHTML=
+ '';
+ 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='无安装任务
';
+ 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 '| '+esc(info.name||serial)+' | '+
+ ''+esc(serial)+' | '+
+ ''+s.t+' | '+
+ ''+esc(info.msg||'')+' |
';
+ }).join('');
+
+ const pct=st.total>0?Math.round((st.success+st.failed+st.skipped)*100/st.total):0;
+ const summary=
+ ''+
+ '
'+
+ '应用:'+esc(st.apk_name)+''+
+ '成功 '+st.success+''+
+ '失败 '+st.failed+''+
+ '跳过 '+st.skipped+''+
+ '安装中 '+st.installing+''+
+ '等待 '+st.pending+''+
+ '
'+
+ '
'+
+ '
';
+
+ document.getElementById('install-progress').innerHTML=
+ summary+'';
+
+ 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();
+}
+
+
diff --git a/static/admin/base.js b/static/admin/base.js
new file mode 100644
index 0000000..cdd96f7
--- /dev/null
+++ b/static/admin/base.js
@@ -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');}
+}
+
diff --git a/static/admin/editor.js b/static/admin/editor.js
new file mode 100644
index 0000000..065b95b
--- /dev/null
+++ b/static/admin/editor.js
@@ -0,0 +1,1096 @@
+// 步骤编辑器:STEP_LIB/拖拽/参数表单/条件判断/元素抓取/测试此步骤
+// ================== 步骤编辑器 ==================
+var STEP_LIB=[
+ // cat:interact=交互操作 / screen=屏幕与App / flow=流程控制(操作库按此分组展示)
+ {type:'click',label:'点击元素',icon:'✦',cat:'interact',params:{selector_type:'xpath',selector_value:'',wait_timeout:2}},
+ {type:'click_xy',label:'点击坐标',icon:'🎯',cat:'interact',params:{x:50,y:50}},
+ {type:'long_click',label:'长按元素',icon:'👆',cat:'interact',params:{selector_type:'xpath',selector_value:'',duration:1.0,wait_timeout:2}},
+ {type:'swipe',label:'滑动',icon:'↕',cat:'interact',params:{direction:'up',duration_min:0.25,duration_max:0.50}},
+ {type:'swipe_until',label:'滑动直到元素',icon:'🔍',cat:'interact',params:{selector_type:'xpath',selector_value:'',direction:'up',max_swipes:8,click_when_found:true}},
+ {type:'input_text',label:'输入文字',icon:'⌨',cat:'interact',params:{mode:'random',texts:'你好\n有趣\n支持',fixed_text:'',clear_first:true}},
+ {type:'key_event',label:'按键',icon:'⌨️',cat:'interact',params:{key:'back'}},
+ {type:'open_app',label:'打开App',icon:'▶',cat:'screen',params:{package:'',wait_home:false,home_feature:''}},
+ {type:'stop_app',label:'结束App',icon:'⏹',cat:'screen',params:{package:''}},
+ {type:'screen_on',label:'亮屏',icon:'💡',cat:'screen',params:{}},
+ {type:'screen_off',label:'息屏',icon:'🌙',cat:'screen',params:{}},
+ {type:'keep_screen',label:'保持亮屏',icon:'🔆',cat:'screen',params:{mode:'on'}},
+ {type:'wait',label:'等待',icon:'⏱',cat:'flow',params:{min:1.0,max:3.0}},
+ {type:'wait_el',label:'等待元素',icon:'⏳',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:10}},
+ {type:'loop',label:'循环块',icon:'↻',cat:'flow',params:{loop_mode:'rounds',max_iterations:10,loop_duration:600,children:[]}},
+ {type:'group',label:'动作组',icon:'📦',cat:'flow',params:{children:[]}},
+ {type:'if_el',label:'条件判断',icon:'❓',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:3,ocr_click:false,then:[],else:[]}}
+];
+var STEP_LIB_CATS=[['interact','交互操作'],['screen','屏幕与App'],['flow','流程控制']];
+var _customActions=[]; // 后端加载的自定义动作
+var _stepIdCounter=0;
+function _newStepId(){_stepIdCounter++;return 'step_'+Date.now().toString(36)+'_'+_stepIdCounter;}
+// 步骤的所有子步骤数组(loop/group 的 children、if_el 的 then/else 分支)
+function _childArrs(s){
+ var arrs=[];
+ if(s&&s.params){
+ if(Array.isArray(s.params.children))arrs.push(s.params.children);
+ if(Array.isArray(s.params.then))arrs.push(s.params.then);
+ if(Array.isArray(s.params.else))arrs.push(s.params.else);
+ }
+ return arrs;
+}
+// 递归去掉步骤 id(保存到自定义动作库/拖入画布时重新生成,避免重复拖入后 id 冲突导致多选错乱)
+function _stripIds(steps){
+ steps.forEach(function(s){
+ delete s.id;
+ _childArrs(s).forEach(_stripIds);
+ });
+}
+function _findLib(type){return STEP_LIB.find(function(s){return s.type===type;});}
+// 选择器组件(类型下拉 + 值输入 + 抓取元素 + 测试按钮),click/long_click/swipe_until/wait_el/if_el 复用
+// withOcr=true 时额外提供 OCR识别 选项(图片/画布里的文字,仅条件判断支持)
+function _selRowHtml(p, extra, withOcr){
+ var types=withOcr?['xpath','description','text','resourceId','descriptionContains','className','ocr']:
+ ['xpath','description','text','resourceId','descriptionContains','className'];
+ var labels={xpath:'xpath',description:'description',text:'text',resourceId:'resourceId',
+ descriptionContains:'descriptionContains',className:'className',ocr:'OCR识别'};
+ var opts=types.map(function(d){
+ return '';}).join('');
+ var toggle=withOcr?' onchange="_stepEditor._toggleOcrMode(this)"':'';
+ var pick=_can('devices')?'':'';
+ var test=_can('devices')?'':'';
+ return ''+(extra||'');
+}
+
+// 加载自定义动作库
+function _loadCustomActions(){
+ fetch('/api/custom_actions').then(function(r){return r.json();}).then(function(res){
+ if(res.ok)_customActions=res.actions||[];
+ if(typeof _stepEditor!=='undefined')_stepEditor._renderLib();
+ }).catch(function(){});
+}
+
+var _stepEditor={
+ _steps:[],
+ _selected:[],
+ _dragSuppressClick:false,
+ init:function(steps,maxDuration,draftKey,canvasId,libId){
+ this._draftKey=draftKey||'new';
+ this._selected=[];
+ this._updatePackBtns();
+ this._dragSuppressClick=false;
+ // 可指定编辑容器(任务编辑器 vs 动作编辑器),默认任务编辑器
+ this._canvas=document.getElementById(canvasId||'se-canvas');
+ this._lib=document.getElementById(libId||'se-lib');
+ // 嵌套结构数组注册表:arr 唯一 id → 数组引用(修复多层级循环内移动错位)
+ this._arrCounter=0;
+ this._arrById={};
+ this._arrIdMap=new WeakMap();
+ // 恢复上次未保存的草稿(实时保存);没有则用传入的步骤
+ var draft=this._loadDraft();
+ if(draft&&Array.isArray(draft)){
+ this._steps=draft;
+ }else{
+ this._steps=steps.length?JSON.parse(JSON.stringify(steps)):[];
+ }
+ this._ensureIds(this._steps);
+ this._renderLib();
+ this._renderCanvas();
+ _loadCustomActions();
+ // 实时同步:画布内任何输入(含下拉/勾选)都同步回 _steps 并自动保存
+ var canvas=this._canvas;
+ if(canvas&&!canvas.dataset.seBound){
+ canvas.dataset.seBound='1';
+ var self=this;
+ canvas.addEventListener('input',function(){self._syncFromDom();});
+ }
+ if(draft&&Array.isArray(draft)){
+ showToast('已恢复上次未保存的步骤编辑','success');
+ }
+ // max_duration 输入框(只在任务编辑器注入,动作编辑不需要)
+ var head=document.querySelector('#step-editor .se-head');
+ if(head&&!document.getElementById('f-max_duration')){
+ var md=document.createElement('span');
+ md.style.cssText='font-size:11px;display:flex;align-items:center;gap:4px';
+ md.innerHTML='运行时长上限(秒,0=不限) ';
+ head.appendChild(md);
+ }
+ },
+ _ensureIds:function(steps){
+ var self=this;
+ steps.forEach(function(s){
+ if(!s.id)s.id=_newStepId();
+ _childArrs(s).forEach(function(a){self._ensureIds(a);});
+ });
+ },
+ _renderLib:function(){
+ var lib=this._lib;
+ if(!lib)return; // 未初始化编辑器(如仅加载动作列表时)跳过
+ // 按分类分组展示(STEP_LIB_CATS 定义分类顺序)
+ var items=STEP_LIB_CATS.map(function(c){
+ var list=STEP_LIB.filter(function(s){return s.cat===c[0];}).map(function(s){
+ return ''+
+ ''+s.icon+''+s.label+'
';
+ }).join('');
+ return '';
+ }).join('');
+ // 自定义动作区
+ var customHtml='';
+ if(_customActions.length){
+ customHtml='自定义动作
';
+ customHtml+=_customActions.map(function(a){
+ return '
'+
+ ''+(a.icon||'📦')+''+esc(a.name)+''+
+ '
';
+ }).join('');
+ customHtml+='
';
+ }
+ lib.innerHTML=items+customHtml;
+ var self=this;
+ // 绑定拖拽 + 点击(系统步骤)—— 用 _dragSuppressClick 防止拖拽后重复触发 click
+ lib.querySelectorAll('.lib-item[data-type]').forEach(function(item){
+ var type=item.dataset.type;
+ item.addEventListener('dragstart',function(e){
+ self._dragSuppressClick=true;
+ e.dataTransfer.setData('text/plain','type:'+type);
+ e.dataTransfer.effectAllowed='copy';
+ });
+ item.addEventListener('click',function(e){
+ if(self._dragSuppressClick){self._dragSuppressClick=false;return;}
+ self.addStep(type);
+ });
+ });
+ // 绑定拖拽 + 点击(自定义动作)
+ lib.querySelectorAll('.lib-item[data-action-id]').forEach(function(item){
+ var actionId=item.dataset.actionId;
+ item.addEventListener('dragstart',function(e){
+ self._dragSuppressClick=true;
+ e.dataTransfer.setData('text/plain','action:'+actionId);
+ e.dataTransfer.effectAllowed='copy';
+ });
+ item.addEventListener('click',function(e){
+ if(e.target.classList.contains('del-btn'))return;
+ if(self._dragSuppressClick){self._dragSuppressClick=false;return;}
+ self.addCustomAction(actionId);
+ });
+ });
+ },
+ _renderCanvas:function(){
+ if(!this._canvas)return;
+ this._renderStepList(this._canvas,this._steps);
+ },
+ _renderStepList:function(container,steps){
+ var self=this;
+ if(!steps.length){
+ container.innerHTML='';
+ this._bindDropZone(container,steps); // 空时也要能接收拖入
+ return;
+ }
+ container.innerHTML='';
+ steps.forEach(function(step,idx){
+ container.appendChild(self._renderCard(step,idx,steps));
+ });
+ this._bindDropZone(container,steps);
+ },
+ _renderCard:function(step,idx,parentArr){
+ var self=this;
+ var lib=_findLib(step.type)||{icon:'?',label:step.type};
+ var card=document.createElement('div');
+ card.className='step-card'+(step.type==='loop'?' loop-block':'')+(step.type==='group'?' group-block':'')+(step.type==='if_el'?' if-block':'');
+ card.draggable=true;
+ card.dataset.idx=idx;
+ card.dataset.stepId=step.id||'';
+ card.dataset.stepType=step.type;
+ if(this._selected&&this._selected.indexOf(step.id)>=0)card.classList.add('selected');
+ // head
+ var head=document.createElement('div');
+ head.className='sc-head';
+ head.innerHTML=''+lib.icon+''+
+ ''+esc(step.label||lib.label)+''+
+ ''+step.type+''+
+ ''+
+ ''+
+ ''+
+ ''+
+ ''+
+ ''+
+ ''+
+ '';
+ head.addEventListener('click',function(e){
+ if(e.target.tagName==='BUTTON')return;
+ self.toggleExpand(head.querySelector('button'));
+ });
+ card.appendChild(head);
+ // body(参数表单)
+ var body=document.createElement('div');
+ body.className='sc-body';
+ body.innerHTML=this._renderParams(step);
+ card.appendChild(body);
+ // 拖拽排序
+ card.addEventListener('dragstart',function(e){
+ e.dataTransfer.setData('text/plain','move:'+idx+':'+self._arrId(parentArr));
+ e.dataTransfer.effectAllowed='move';
+ card.classList.add('dragging');
+ });
+ card.addEventListener('dragend',function(){card.classList.remove('dragging');});
+ card.addEventListener('dragover',function(e){
+ e.preventDefault();
+ card.classList.add('drag-over');
+ });
+ card.addEventListener('dragleave',function(){card.classList.remove('drag-over');});
+ card.addEventListener('drop',function(e){
+ e.preventDefault();
+ e.stopPropagation(); // 关键:阻止冒泡到外层卡片/容器,避免重复添加
+ card.classList.remove('drag-over');
+ self._handleDrop(e,parentArr,idx);
+ });
+ // loop/group 子步骤
+ if(step.type==='loop'||step.type==='group'){
+ if(step.params&&step.params.children){
+ var childWrap=document.createElement('div');
+ childWrap.className='step-children';
+ this._renderStepList(childWrap,step.params.children);
+ card.appendChild(childWrap);
+ }
+ }
+ // if_el 双分支:找到时(then) / 未找到时(else),各自独立容器接收拖入
+ if(step.type==='if_el'&&step.params){
+ [['then','✅ 找到时执行','if-then'],['else','❌ 未找到时执行','if-else']].forEach(function(b){
+ var branch=document.createElement('div');
+ branch.className='if-branch';
+ var title=document.createElement('div');
+ title.className='branch-title '+b[0];
+ title.textContent=b[1];
+ var wrap=document.createElement('div');
+ wrap.className='step-children '+b[2];
+ self._renderStepList(wrap,step.params[b[0]]||[]);
+ branch.appendChild(title);
+ branch.appendChild(wrap);
+ card.appendChild(branch);
+ });
+ }
+ return card;
+ },
+ _path:function(arr){
+ // 用数组长度作为标识(顶层是 _steps,子层是某 loop 的 children)
+ // 这里简化:顶层 path='root',子层 path='loop:'
+ return arr===this._steps?'root':'child';
+ },
+ _renderParams:function(step){
+ var p=step.params||{};
+ var h='';
+ if(step.type==='open_app'){
+ h+='';
+ h+='';
+ }else if(step.type==='stop_app'){
+ h+='';
+ h+='强制结束该 App(am force-stop),清除后台进程,下次打开为冷启动。常配合"打开App"使用:结束→重新打开
';
+ }else if(step.type==='screen_on'){
+ h+='亮屏(息屏时唤醒并滑动解锁)。任务执行前用——息屏状态下 u2 无法点击操作
';
+ }else if(step.type==='screen_off'){
+ h+='息屏。任务结束后用,避免长时间亮屏导致烧屏/发热
';
+ }else if(step.type==='keep_screen'){
+ h+='';
+ h+='长任务(如养号看视频几小时)放"保持亮屏"防屏幕超时熄灭;任务结束前放"恢复自动息屏"
';
+ }else if(step.type==='swipe'){
+ h+='';
+ }else if(step.type==='click'){
+ h+=_selRowHtml(p,'');
+ }else if(step.type==='long_click'){
+ h+=_selRowHtml(p,'');
+ }else if(step.type==='swipe_until'){
+ h+=_selRowHtml(p,''+
+ ''+
+ '上滑/下滑直到目标元素出现(每次滑动后检查一次),找不到则跳过继续
');
+ }else if(step.type==='wait_el'){
+ h+=_selRowHtml(p,''+
+ '等待目标元素出现(替代固定秒数等待),超时则继续
');
+ }else if(step.type==='click_xy'){
+ h+=''+
+ '屏幕百分比坐标,50/50=屏幕中心。无选择器时的兜底点击
';
+ }else if(step.type==='key_event'){
+ h+='';
+ h+='返回键用于退出评论/返回上一页,比结束App轻量
';
+ }else if(step.type==='wait'){
+ h+='';
+ }else if(step.type==='input_text'){
+ const im=p.mode==='fixed'?'fixed':'random';
+ h+='';
+ h+='';
+ h+='';
+ h+='';
+ h+='需先用"点击元素"步骤定位到输入框,本步骤只负责输入文字
';
+ }else if(step.type==='loop'){
+ const lm=p.loop_mode==='time'?'time':'rounds';
+ h+='';
+ h+='将下方子步骤(拖拽到本卡片内)循环执行指定轮次或时长;按时间时配合"运行时长上限"看预计结束时间
';
+ }else if(step.type==='group'){
+ h+='动作组:按顺序执行下方子步骤一次。把多个步骤打包成一个可复用的整体。
';
+ h+=' 把本组步骤保存到左侧自定义动作库,方便复用
';
+ }else if(step.type==='if_el'){
+ h+=_selRowHtml(p,
+ ''+
+ ''+
+ '
'+
+ '超时内找到元素 → 执行下方"✅ 找到时"分支;未找到 → 执行"❌ 未找到时"分支。两分支可放任意步骤,也可再嵌套条件判断/循环;'+
+ 'OCR识别可匹配图片/画布里的文字(截屏识别,跨平台)
', true);
+ }
+ // 共享:触发概率(所有步骤通用,0~100)
+ h+='';
+ // 可编辑 label
+ h+='';
+ return h;
+ },
+ _bindDropZone:function(container,arr){
+ // 常驻容器(#se-canvas)多次重渲染会累积 drop 监听 → 拖一次添加多个。
+ // 用 dataset 标记只绑定一次;数组引用用 WeakMap 每次更新。
+ if(!this._dropArrs)this._dropArrs=new WeakMap();
+ this._dropArrs.set(container,arr);
+ if(container.dataset.dropBound)return;
+ container.dataset.dropBound='1';
+ var self=this;
+ // 画布空白处也能接收 drop,拖入时高亮提示
+ container.addEventListener('dragover',function(e){
+ e.preventDefault();
+ container.classList.add('drag-over');
+ });
+ container.addEventListener('dragleave',function(e){
+ if(!container.contains(e.relatedTarget))container.classList.remove('drag-over');
+ });
+ container.addEventListener('drop',function(e){
+ e.preventDefault();
+ e.stopPropagation(); // 阻止冒泡到外层卡片/容器,避免重复添加
+ container.classList.remove('drag-over');
+ // 子卡片都会 stopPropagation,能冒泡到这里的 drop 一定落在空白区
+ // (含 .se-empty 的提示文字),直接追加到数组末尾
+ self._handleDrop(e,self._dropArrs.get(container),-1);
+ });
+ },
+ _handleDrop:function(e,arr,idx){
+ var self=this;
+ var data=e.dataTransfer.getData('text/plain');
+ if(!data)return;
+ // drop 处理中同步重建 DOM 会打断拖拽事件,可能被浏览器重发 drop 导致重复添加。
+ // 延迟到下一帧渲染,保证一次拖入只加一次。
+ function rerender(){ setTimeout(function(){self._renderCanvas();},0); }
+ if(data.startsWith('move:')){
+ // 拖拽排序:同层级排序 / 跨层级移入(把步骤拖进循环块、循环套循环)
+ var parts=data.split(':');
+ var fromIdx=parseInt(parts[1]);
+ var fromPath=parts[2];
+ var srcArr=this._resolveArr(fromPath);
+ if(srcArr){
+ if(srcArr===arr){
+ // 同层级排序
+ if(fromIdx===idx)return;
+ this._syncFromDom();
+ var moved=arr.splice(fromIdx,1)[0];
+ if(idx<0)arr.push(moved);
+ else if(fromIdx=arr.length)arr.push(moved2);
+ else arr.splice(idx,0,moved2);
+ rerender();
+ }
+ }
+ }
+ }else if(data.startsWith('action:')){
+ // 从自定义动作库拖入 → 展开为 group 步骤
+ var actionId=data.substring(7);
+ var action=_customActions.find(function(a){return a.id===actionId;});
+ if(action&&action.steps&&action.steps.length){
+ var ns=this._makeStep({type:'group',label:action.name,icon:action.icon||'📦',params:{children:[]}});
+ ns.label=action.name;
+ ns.params.children=JSON.parse(JSON.stringify(action.steps));
+ _stripIds(ns.params.children); // 重新生成 id,避免同一动作拖入多次后 id 冲突
+ this._ensureIds(ns.params.children);
+ if(idx<0||idx>=arr.length)arr.push(ns);
+ else arr.splice(idx,0,ns);
+ rerender();
+ }
+ }else if(data.startsWith('type:')){
+ // 从系统操作库拖入
+ var type=data.substring(5);
+ var lib=_findLib(type);
+ if(lib){
+ var ns2=this._makeStep(lib);
+ if(idx<0||idx>=arr.length)arr.push(ns2);
+ else arr.splice(idx,0,ns2);
+ rerender();
+ }
+ }
+ },
+ _makeStep:function(lib){
+ return {id:_newStepId(),type:lib.type,label:lib.label,params:JSON.parse(JSON.stringify(lib.params))};
+ },
+ // 测试此步骤:在选中设备上试执行当前步骤,验证选择器
+ _testStep:function(btn){
+ var card=btn.closest('.step-card');
+ if(!card)return;
+ var type=card.dataset.stepType||'';
+ var params={};
+ card.querySelectorAll('[data-key]').forEach(function(inp){
+ if(inp.type==='checkbox')params[inp.dataset.key]=inp.checked;
+ else if(inp.type==='number')params[inp.dataset.key]=parseFloat(inp.value)||0;
+ else params[inp.dataset.key]=inp.value;
+ });
+ this._pendingTest={type:type,label:'测试',params:params};
+ this._showTestDevicePicker();
+ },
+ _showTestDevicePicker:function(){
+ document.getElementById('test-modal-title').textContent='测试步骤 - 选择设备';
+ document.getElementById('test-modal-body').innerHTML=
+ '';
+ document.getElementById('test-overlay').classList.add('show');
+ var self=this;
+ apiGet('/api/uiauto/devices').then(function(r){
+ var list=document.getElementById('test-device-list');
+ if(!list)return;
+ if(!r||!r.ok){list.innerHTML=''+esc((r&&r.error)||'获取设备失败')+'
';return;}
+ list.innerHTML=(r.devices||[]).map(function(d){
+ return ''+
+ '
📱
'+
+ '
'+esc(d.serial)+'
'+
+ '
'+esc(d.model||d.product||'')+'
';
+ }).join('')||'无可用设备
';
+ });
+ },
+ _runTestStep:async function(serial){
+ var step=this._pendingTest;
+ var body=document.getElementById('test-modal-body');
+ if(!step||!body)return;
+ body.innerHTML='正在 '+esc(serial)+' 上执行(adb + u2 连接约需几秒)...
请稍候,期间不要操作该设备
';
+ var r=await apiPost('/api/steps/test',{serial:serial,step:step});
+ if(!r)return;
+ var icon=r.result==='命中'?'✅':(r.result==='未找到'?'❌':'▶');
+ body.innerHTML=''+icon+'
'+esc(r.result||'已执行')+''+
+ '
'+esc(r.msg||'')+'
'+
+ '
';
+ },
+ addStep:function(type){
+ var lib=_findLib(type);
+ if(!lib)return;
+ this._syncFromDom();
+ this._steps.push(this._makeStep(lib));
+ this._renderCanvas();
+ },
+ addCustomAction:function(actionId){
+ var action=_customActions.find(function(a){return a.id===actionId;});
+ if(!action||!action.steps||!action.steps.length)return;
+ this._syncFromDom();
+ var ns=this._makeStep({type:'group',label:action.name,icon:action.icon||'📦',params:{children:[]}});
+ ns.label=action.name;
+ ns.params.children=JSON.parse(JSON.stringify(action.steps));
+ _stripIds(ns.params.children); // 重新生成 id,避免同一动作拖入多次后 id 冲突
+ this._ensureIds(ns.params.children);
+ this._steps.push(ns);
+ this._renderCanvas();
+ },
+ delCustomAction:function(actionId){
+ if(!confirm('确定删除这个自定义动作?'))return;
+ apiDelete('/api/custom_actions/'+actionId).then(function(res){
+ if(!res)return;
+ if(res.ok){_loadCustomActions();showToast('已删除','success');}
+ else showToast(res.error||'删除失败','error');
+ });
+ },
+ toggleSelect:function(stepId){
+ this._syncFromDom();
+ if(!this._selected)this._selected=[];
+ var i=this._selected.indexOf(stepId);
+ if(i>=0)this._selected.splice(i,1);
+ else this._selected.push(stepId);
+ this._renderCanvas();
+ this._updatePackBtns();
+ },
+ // 同步所有"打包选中步骤"按钮的可用状态(任务编辑器 + 动作编辑器)
+ _updatePackBtns:function(){
+ var n=this._selected&&this._selected.length;
+ ['se-pack-btn','se-pack-btn2'].forEach(function(id){
+ var bar=document.getElementById(id);
+ if(bar)bar.disabled=n<1;
+ });
+ },
+ saveAsCustomAction:function(stepId){
+ // 从 group 步骤的"存为自定义动作"按钮调用时传入该 group 的 id:
+ // 优先收集该 group 的子步骤;否则收集 ☑ 选中的步骤
+ var stepsToSave=[];
+ if(stepId){
+ var group=this._findStepById(this._steps,stepId);
+ if(group&&group.params&&group.params.children&&group.params.children.length){
+ stepsToSave=JSON.parse(JSON.stringify(group.params.children));
+ _stripIds(stepsToSave);
+ }
+ }
+ if(!stepsToSave.length&&this._selected&&this._selected.length){
+ // 从选中步骤收集
+ stepsToSave=this._collectByIds(this._steps,this._selected);
+ }
+ if(!stepsToSave.length){
+ showToast('请先用 ☑ 选中要打包的步骤','error');
+ return;
+ }
+ var name=prompt('输入动作名称:','我的动作');
+ if(!name)return;
+ var self=this;
+ apiPost('/api/custom_actions',{name:name,steps:stepsToSave})
+ .then(function(res){
+ if(!res)return;
+ if(res.ok){
+ showToast('已保存到自定义动作库','success');
+ self._selected=[];
+ _loadCustomActions();
+ self._renderCanvas();
+ self._updatePackBtns();
+ }else showToast(res.error||'保存失败','error');
+ });
+ },
+ // 在嵌套结构中按 id 查找步骤(group/loop 的子步骤、if_el 的分支步骤也找得到)
+ _findStepById:function(steps,id){
+ var self=this;
+ for(var i=0;i=0){
+ var copy=JSON.parse(JSON.stringify(s));
+ _stripIds([copy]);
+ result.push(copy); // 容器选中时子步骤已包含在拷贝里,不再单独收集
+ }else{
+ _childArrs(s).forEach(function(a){
+ result=result.concat(self._collectByIds(a,ids));
+ });
+ }
+ });
+ return result;
+ },
+ toggleExpand:function(btn){
+ var card=btn.closest('.step-card');
+ card.classList.toggle('expanded');
+ },
+ _toggleInputMode:function(sel){
+ var body=sel.closest('.sc-body');
+ var mode=sel.value;
+ body.querySelectorAll('[data-mode]').forEach(function(g){
+ g.style.display=g.dataset.mode===mode?'block':'none';
+ });
+ },
+ _toggleLoopMode:function(sel){
+ var body=sel.closest('.sc-body');
+ var mode=sel.value;
+ body.querySelectorAll('[data-lmode]').forEach(function(g){
+ g.style.display=g.dataset.lmode===mode?'block':'none';
+ });
+ },
+ // 条件判断:选择器类型切到 OCR 时显示"命中后点击"开关
+ _toggleOcrMode:function(sel){
+ var card=sel.closest('.step-card');
+ var f=card&&card.querySelector('[data-ocr-field]');
+ if(f)f.style.display=sel.value==='ocr'?'block':'none';
+ },
+ // 给数组分配唯一 id,用于 move/duplicate/remove/拖拽定位到正确的父数组
+ _arrId:function(arr){
+ if(arr===this._steps)return 'root';
+ if(!this._arrIdMap)this._arrIdMap=new WeakMap();
+ if(!this._arrById)this._arrById={};
+ if(!this._arrIdMap.has(arr)){
+ var id='arr_'+(++this._arrCounter);
+ this._arrIdMap.set(arr,id);
+ this._arrById[id]=arr;
+ }
+ return this._arrIdMap.get(arr);
+ },
+ _resolveArr:function(path){
+ if(path==='root')return this._steps;
+ return this._arrById&&this._arrById[path];
+ },
+ // 判断 step 的子孙数组中是否包含 arr(防循环块套进自己导致渲染无限递归)
+ _containsArr:function(step,arr){
+ var arrs=_childArrs(step);
+ if(!arrs.length)return false;
+ if(arrs.indexOf(arr)>=0)return true;
+ for(var i=0;i=arr.length)return;
+ var tmp=arr[ni];arr[ni]=arr[idx];arr[idx]=tmp;
+ this._renderCanvas();
+ },
+ duplicate:function(idx,path){
+ this._syncFromDom();
+ var arr=this._resolveArr(path);
+ if(!arr)return;
+ var copy=JSON.parse(JSON.stringify(arr[idx]));
+ copy.id=_newStepId();
+ this._ensureIds([copy]);
+ arr.splice(idx+1,0,copy);
+ this._renderCanvas();
+ },
+ remove:function(idx,path){
+ this._syncFromDom();
+ var arr=this._resolveArr(path);
+ if(!arr)return;
+ arr.splice(idx,1);
+ this._renderCanvas();
+ },
+ collect:function(){
+ // 先把当前 DOM 上的参数值同步回 _steps
+ var self=this;
+ this._syncParams(this._canvas,this._steps);
+ return JSON.parse(JSON.stringify(this._steps));
+ },
+ validate:function(steps){
+ // 校验步骤配置,返回警告列表
+ var warnings=[];
+ function check(arr,prefix){
+ arr.forEach(function(s,i){
+ var name=prefix?(prefix+'.'+(i+1)):('步骤'+(i+1));
+ var label=s.label||s.type;
+ if(s.type==='click'){
+ if(!s.params.selector_value){
+ warnings.push(name+' "'+label+'": 选择器值为空,点击步骤会跳过。请用"抓取元素"按钮选择目标。');
+ }
+ }else if(s.type==='long_click'||s.type==='swipe_until'||s.type==='wait_el'){
+ if(!s.params.selector_value){
+ warnings.push(name+' "'+label+'": 选择器值为空,该步骤会跳过。请用"抓取元素"或"测试"按钮选择目标。');
+ }
+ }else if(s.type==='click_xy'){
+ const x=parseFloat(s.params.x), y=parseFloat(s.params.y);
+ if(isNaN(x)||isNaN(y)||x<0||x>100||y<0||y>100){
+ warnings.push(name+' "'+label+'": 坐标需在 0-100 之间(屏幕百分比)。');
+ }
+ }else if(s.type==='open_app'||s.type==='stop_app'){
+ if(!s.params.package){
+ warnings.push(name+' "'+label+'": 包名为空,无法'+(s.type==='open_app'?'启动':'结束')+'App');
+ }
+ }else if(s.type==='input_text'){
+ if(s.params.mode==='fixed'&&!s.params.fixed_text){
+ warnings.push(name+' "'+label+'": 指定文字为空');
+ }else if(s.params.mode==='random'&&!s.params.texts){
+ warnings.push(name+' "'+label+'": 候选文字为空');
+ }
+ }else if(s.type==='if_el'){
+ if(!s.params.selector_value){
+ warnings.push(name+' "'+label+'": 条件判断的选择器为空,会直接走"未找到"分支。');
+ }
+ }
+ _childArrs(s).forEach(function(a){
+ if(a.length)check(a,name+' 子步骤');
+ });
+ });
+ }
+ check(steps,'');
+ return warnings;
+ },
+ _syncParams:function(container,steps){
+ var self=this;
+ var cards=container.querySelectorAll(':scope > .step-card');
+ cards.forEach(function(card,i){
+ if(!steps[i])return;
+ // 只读本卡片直接子级的 .sc-body,避免把嵌套子步骤的输入误写进本步骤 params
+ var inputs=card.querySelectorAll(':scope > .sc-body [data-key]');
+ inputs.forEach(function(inp){
+ var key=inp.dataset.key;
+ if(inp.type==='checkbox'){
+ steps[i].params[key]=inp.checked;
+ }else if(inp.type==='number'){
+ steps[i].params[key]=parseFloat(inp.value)||0;
+ }else{
+ if(key==='_label'){
+ steps[i].label=inp.value||steps[i].label;
+ }else{
+ steps[i].params[key]=inp.value;
+ }
+ }
+ });
+ // 递归子步骤:loop/group 的 children
+ var childWrap=card.querySelector(':scope > .step-children');
+ if(childWrap&&steps[i].params&&steps[i].params.children){
+ self._syncParams(childWrap,steps[i].params.children);
+ }
+ // if_el 双分支(then/else 在 .if-branch 内,不会与上面的直接子级选择器冲突)
+ var thenWrap=card.querySelector(':scope > .if-branch .step-children.if-then');
+ if(thenWrap&&steps[i].params&&Array.isArray(steps[i].params.then)){
+ self._syncParams(thenWrap,steps[i].params.then);
+ }
+ var elseWrap=card.querySelector(':scope > .if-branch .step-children.if-else');
+ if(elseWrap&&steps[i].params&&Array.isArray(steps[i].params.else)){
+ self._syncParams(elseWrap,steps[i].params.else);
+ }
+ });
+ },
+ // 把画布 DOM 上的参数同步回 _steps,并自动保存草稿(操作前调用防丢失)
+ _syncFromDom:function(){
+ this._syncParams(this._canvas,this._steps);
+ this._autosave();
+ },
+ _autosave:function(){
+ try{localStorage.setItem('se_draft_'+this._draftKey,JSON.stringify(this._steps));}catch(e){}
+ },
+ _loadDraft:function(){
+ try{
+ var d=localStorage.getItem('se_draft_'+this._draftKey);
+ if(d)return JSON.parse(d);
+ }catch(e){}
+ return null;
+ },
+ clearDraft:function(){
+ try{localStorage.removeItem('se_draft_'+this._draftKey);}catch(e){}
+ },
+ // ================== 元素抓取 ==================
+ openPicker:function(btn){
+ var input=btn.previousElementSibling;
+ this._elTargetInput=input;
+ // 直接弹设备选择页(独立模态框,不影响任务编辑窗口)
+ this._showDevicePicker();
+ },
+ // 步骤1:设备选择页
+ _showDevicePicker:function(){
+ document.getElementById('el-picker-title').textContent='抓取元素 - 选择设备';
+ document.getElementById('el-picker-body').innerHTML='';
+ document.getElementById('el-picker-footer').innerHTML='';
+ document.getElementById('el-picker-overlay').classList.add('show');
+ fetch('/api/uiauto/devices').then(function(r){return r.json();}).then(function(res){
+ var list=document.getElementById('el-device-list');
+ if(!res.ok){list.innerHTML=''+esc(res.error||'获取设备失败')+'
';return;}
+ var devices=res.devices||[];
+ if(!devices.length){list.innerHTML='uiauto2 未发现已连接设备
';return;}
+ list.innerHTML=devices.map(function(d){
+ return ''+
+ '
📱
'+
+ '
'+esc(d.serial)+'
'+
+ '
'+esc(d.model||d.product||'')+'
'+
+ '
'+esc(d.status||'device')+' ';
+ }).join('');
+ }).catch(function(e){
+ document.getElementById('el-device-list').innerHTML='请求失败: '+esc(e)+'
';
+ });
+ },
+ // 步骤2:选中设备后,显示截图 + 元素树
+ _showElPicker:function(serial){
+ var self=this;
+ self._elSerial=serial;
+ document.getElementById('el-picker-title').textContent='抓取元素 - '+serial;
+ document.getElementById('el-picker-body').innerHTML=
+ ''+
+ '
'+
+ '
'+
+ ''+
+ '加载中...'+
+ '
'+
+ '
'+
+ '
'+
+ '
'+
+ '
';
+ document.getElementById('el-picker-footer').innerHTML=
+ ''+
+ '';
+ // 加载截图和元素树(_refreshShot 内部同时刷新两者)
+ self._refreshShot();
+ },
+ // 拉取元素树(进入页面和刷新时共用;带时间戳防缓存)
+ _loadElData:function(){
+ var serial=this._elSerial;
+ if(!serial)return;
+ var listEl=document.getElementById('el-list');
+ if(listEl)listEl.innerHTML='加载元素树...
';
+ var self=this;
+ fetch('/api/uiauto/elements?serial='+encodeURIComponent(serial)+'&_='+Date.now())
+ .then(function(r){return r.json();})
+ .then(function(data){
+ var le=document.getElementById('el-list');
+ if(!le)return;
+ if(!data.ok){
+ le.innerHTML=''+esc(data.error||'抓取失败')+'
';
+ return;
+ }
+ self._elData=data.elements||[];
+ self._renderEl(self._elData);
+ // 刷新后保持搜索过滤状态
+ var search=document.getElementById('el-search');
+ if(search&&search.value)self._filterEl(search.value);
+ self._renderOverlays();
+ })
+ .catch(function(e){
+ var le=document.getElementById('el-list');
+ if(le)le.innerHTML='请求失败: '+esc(e)+'
';
+ });
+ },
+ // 刷新截图
+ _refreshShot:function(){
+ var serial=this._elSerial;
+ if(!serial)return;
+ var container=document.getElementById('el-shot-container');
+ if(!container)return;
+ container.innerHTML='正在获取截图...
';
+ var info=document.getElementById('el-shot-info');
+ if(info)info.textContent='加载中...';
+ var self=this;
+ var img=new Image();
+ img.onload=function(){
+ container.innerHTML='';
+ container.appendChild(img);
+ var infoEl=document.getElementById('el-shot-info');
+ if(infoEl)infoEl.textContent=img.naturalWidth+'x'+img.naturalHeight;
+ // 截图加载完后重新渲染元素边界框
+ self._renderOverlays();
+ };
+ img.onerror=function(){
+ container.innerHTML='截图加载失败
';
+ var infoEl=document.getElementById('el-shot-info');
+ if(infoEl)infoEl.textContent='失败';
+ };
+ img.src='/api/uiauto/screenshot?serial='+encodeURIComponent(serial)+'&_='+Date.now();
+ // 同时重新拉取元素树——只刷截图会导致"截图是新的、元素框是旧的"错位
+ this._loadElData();
+ },
+ // 在截图上渲染元素边界框(可点击)
+ _renderOverlays:function(){
+ var container=document.getElementById('el-shot-container');
+ var img=container.querySelector('img');
+ if(!img||!this._elData)return;
+ var self=this;
+ function draw(){
+ // 清除旧 overlay 和旧点击处理
+ container.querySelectorAll('.el-shot-overlay').forEach(function(o){o.remove();});
+ container.onclick=null;
+ var scaleX=img.clientWidth/img.naturalWidth;
+ var scaleY=img.clientHeight/img.naturalHeight;
+ // 图片在 container 里的偏移
+ var imgOffsetX=img.offsetLeft;
+ var imgOffsetY=img.offsetTop;
+ var imgW=img.naturalWidth, imgH=img.naturalHeight;
+ self._elData.forEach(function(el,i){
+ if(!el.bounds)return;
+ // bounds 格式 "[x1,y1][x2,y2]"
+ var m=el.bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
+ if(!m)return;
+ var x1=parseInt(m[1]),y1=parseInt(m[2]),x2=parseInt(m[3]),y2=parseInt(m[4]);
+ var w=(x2-x1)*scaleX;
+ var h=(y2-y1)*scaleY;
+ if(w<4||h<4)return; // 太小不画
+ // 跳过占满屏幕的容器:面积>90%屏幕,没有选择价值,还会盖住小元素
+ if((x2-x1)*(y2-y1) > imgW*imgH*0.9)return;
+ var ov=document.createElement('div');
+ ov.className='el-shot-overlay';
+ ov.setAttribute('data-idx',i);
+ ov.style.left=(imgOffsetX+x1*scaleX)+'px';
+ ov.style.top=(imgOffsetY+y1*scaleY)+'px';
+ ov.style.width=w+'px';
+ ov.style.height=h+'px';
+ ov.title=el.name||el.class||'';
+ container.appendChild(ov);
+ });
+ // 点击截图:按 bounds 命中测试,选中最小的(最深的)元素,
+ // 而不是被 DOM 层叠中的大容器/大兄弟挡住
+ container.onclick=function(e){
+ var im=container.querySelector('img');
+ if(!im)return;
+ var r=im.getBoundingClientRect();
+ var px=(e.clientX-r.left)/scaleX; // 换算回原始分辨率坐标
+ var py=(e.clientY-r.top)/scaleY;
+ var best=-1,bestArea=Infinity;
+ self._elData.forEach(function(el,i){
+ var mb=el.bounds&&el.bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
+ if(!mb)return;
+ var a=parseInt(mb[1]),b=parseInt(mb[2]),c=parseInt(mb[3]),dd=parseInt(mb[4]);
+ if(px>=a&&px<=c&&py>=b&&py<=dd){
+ var area=(c-a)*(dd-b);
+ if(area=0)self._highlightEl(best);
+ };
+ }
+ if(img.complete&&img.naturalWidth>0)draw();
+ else img.onload=draw;
+ },
+ // 高亮元素(截图框 + 列表项联动)
+ _highlightEl:function(i){
+ // 清除旧高亮(只在 el-picker-overlay 范围内查找)
+ var picker=document.getElementById('el-picker-overlay');
+ picker.querySelectorAll('.el-shot-overlay.active').forEach(function(o){o.classList.remove('active');});
+ picker.querySelectorAll('.el-picker-item.active').forEach(function(o){o.classList.remove('active');});
+ // 高亮当前(overlay 用 data-idx 查找,渲染时跳过了全屏容器,索引不再一一对应)
+ var overlay=picker.querySelector('.el-shot-overlay[data-idx="'+i+'"]');
+ if(overlay)overlay.classList.add('active');
+ // 列表项用 data-idx 查找(过滤后顺序可能不同)
+ var item=picker.querySelector('.el-picker-item[data-idx="'+i+'"]');
+ if(item){
+ item.classList.add('active');
+ item.scrollIntoView({block:'nearest',behavior:'smooth'});
+ }
+ // 信息栏显示将填入的选择器,确认是否精确命中目标
+ var infoEl=document.getElementById('el-shot-info');
+ if(infoEl&&this._elData&&this._elData[i]){
+ var sg=this._elData[i].suggested||{};
+ infoEl.textContent='已选中: '+(sg.type||'')+' '+(sg.value||'');
+ }
+ },
+ _renderEl:function(list){
+ var self=this;
+ var html=list.map(function(el){
+ var idx=self._elData.indexOf(el);
+ var attrs=[];
+ if(el.resource_id)attrs.push('id:'+esc(el.resource_id)+'');
+ if(el.text)attrs.push('text:'+esc(el.text)+'');
+ if(el.description)attrs.push('desc:'+esc(el.description)+'');
+ var indent=' '.repeat(Math.min(el.depth||0,6));
+ var sg=el.suggested||{};
+ return ''+
+ '
'+(el.depth||0)+''+
+ '
'+indent+esc(el.name||el.class||'')+'
'+
+ '
'+attrs.join('')+'
'+
+ '
'+esc(sg.type)+''+
+ (sg.indexed?'
#'+sg.occ+'/'+sg.total+'':'')+
+ (sg.broad?'
⚠宽泛':'')+
+ '
';
+ }).join('');
+ document.getElementById('el-list').innerHTML=html||'无元素
';
+ },
+ _filterEl:function(kw){
+ if(!kw){this._renderEl(this._elData);this._renderOverlays();return;}
+ kw=kw.toLowerCase();
+ var f=this._elData.filter(function(el){
+ return (el.resource_id&&el.resource_id.toLowerCase().includes(kw))||
+ (el.text&&el.text.toLowerCase().includes(kw))||
+ (el.description&&el.description.toLowerCase().includes(kw));
+ });
+ this._renderEl(f);
+ },
+ _pickEl:function(i){
+ var el=this._elData[i];
+ if(!el||!this._elTargetInput)return;
+ // 填入推荐选择器值,并切换选择器类型
+ this._elTargetInput.value=el.suggested.value;
+ var sel=this._elTargetInput.closest('.sc-body').querySelector('[data-key="selector_type"]');
+ if(sel)sel.value=el.suggested.type;
+ // 程序赋值不会触发 input 事件,手动同步回 _steps 防丢失
+ this._syncFromDom();
+ // 只关闭抓取元素弹窗,不影响任务编辑窗口
+ closeElPicker();
+ showToast('已填入: '+el.suggested.type+'='+el.suggested.value,'success');
+ }
+};
+
+async function saveTask(jobId){
+ const name=document.getElementById('f-name').value.trim();
+ if(!name){showToast('任务名不能为空','error');return;}
+ let params={};
+ const tt=document.getElementById('f-task_type').value;
+ if(tt==='generic_steps'){
+ // 通用步骤任务:从步骤编辑器收集
+ const steps=_stepEditor.collect();
+ if(!steps.length){showToast('请至少添加一个步骤','error');return;}
+ // 校验:click 步骤的 selector_value 不能为空
+ var warnings=_stepEditor.validate(steps);
+ if(warnings.length){
+ if(!confirm('步骤配置有问题:\n\n'+warnings.join('\n')+'\n\n确定要保存吗?'))return;
+ }
+ const mdEl=document.getElementById('f-max_duration');
+ params={max_duration:mdEl?parseInt(mdEl.value)||0:0,steps:steps};
+ }else{
+ try{params=JSON.parse(document.getElementById('f-params').value||'{}');}
+ catch(e){showToast('参数 JSON 格式错误','error');return;}
+ }
+
+ const targetMode=document.getElementById('f-target_mode').value;
+ const target={mode:targetMode};
+ if(targetMode==='group')target.group_name=document.getElementById('f-target_group').value;
+ if(targetMode==='serial')target.serial=document.getElementById('f-target_serial').value;
+
+ const schedMode=document.getElementById('f-schedule_mode').value;
+ let schedule={mode:'once'};
+ if(schedMode==='cron'){
+ schedule={mode:'cron',cron:buildCronFromFreq()};
+ }else if(schedMode==='cron_stop'){
+ const st=(document.getElementById('f-schedule_stop_time').value||'18:00').split(':');
+ schedule={mode:'cron_stop',
+ cron:buildCronFromFreq(),
+ stop_cron:(parseInt(st[1])||0)+' '+(parseInt(st[0])||0)+' * * *'};
+ }
+ // 运行窗口:窗口外(定时触发 + 手动执行)不启动
+ if(document.getElementById('f-window_enable').checked){
+ schedule.window={
+ start:document.getElementById('f-window_start').value||'09:00',
+ end:document.getElementById('f-window_end').value||'18:00',
+ };
+ }
+
+ const data={
+ name:name,
+ task_type:document.getElementById('f-task_type').value,
+ enabled:document.getElementById('f-enabled').checked,
+ target:target,
+ params:params,
+ schedule:schedule,
+ retry:{max_attempts:Math.max(1,parseInt(document.getElementById('f-retry_attempts').value)||1),
+ delay:Math.max(10,parseInt(document.getElementById('f-retry_delay').value)||60)}
+ };
+
+ if(jobId){
+ const r=await apiPut('/api/jobs/'+jobId,data);
+ handleResult(r,()=>{_stepEditor.clearDraft();closeModal();loadTasks();});
+ }else{
+ const r=await apiPost('/api/jobs',data);
+ handleResult(r,()=>{_stepEditor.clearDraft();closeModal();loadTasks();});
+ }
+}
+
+async function deleteTask(jobId){
+ if(!confirm('确认删除此任务吗?'))return;
+ const r=await apiDelete('/api/jobs/'+jobId);
+ handleResult(r,()=>loadTasks());
+}
+
diff --git a/static/admin/list.js b/static/admin/list.js
new file mode 100644
index 0000000..638090c
--- /dev/null
+++ b/static/admin/list.js
@@ -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:'...'});
+// 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||'
| 无数据 |
');
+ // 表头排序指示器(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?'':'';
+ var h='';
+ return h;
+}
+function _lpGo(key,p){
+ var st=_lp(key); st.page=p; applyListPager(key);
+}
+
diff --git a/static/admin/monitor.js b/static/admin/monitor.js
index 4593dd0..9580eed 100644
--- a/static/admin/monitor.js
+++ b/static/admin/monitor.js
@@ -1,64 +1,4 @@
-// ================== 通用工具 ==================
-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();
-}
-
+// 监控页:设备状态表(排序/多选/截图/异常汇总/任务运行概况)
// ================== 设备列表排序与多选 ==================
let _selectedSerials=new Set(); // 已勾选的 serial 集合
@@ -231,322 +171,6 @@ async function refreshShot(){
_shotLoading=false;
}
}
-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));
-}
-
-// ================== 工具:剪贴板注入 ==================
-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='无可用设备(本地 adb 或 STF 池均无)
';
- 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 '';
- }).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:'| 无可用设备 |
'
- });
- 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 '| '+esc(x.serial)+' | '+
- '查询失败 | '+esc(x.error)+' |
';
- if(x.installed)return '| '+esc(x.serial)+' | '+
- '已安装 | '+
- ''+esc(x.version_name||'')+' | '+esc(x.version_code||'')+' |
';
- return '| '+esc(x.serial)+' | '+
- '未安装 | - | - |
';
-}
-
-// ================== 工具: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:'| 无设备(脚本未配置、220 adb 也无连接) |
'
- });
- applyListPager('stfdev');
- initListSearch('stfdev','search-stfdev');
- status.textContent = '脚本配置 '+configured.length+' 台,已连接 '+Object.keys(connected).length+' 台';
-}
-function renderStfDevRow(x){
- const stHtml = x.state==='device'
- ? '已连接'
- : (x.state ? ''+esc(x.state)+'' : '未连接');
- return '| '+esc(x.ip)+' | '+
- ''+(x.inCfg?'脚本中':'未配置')+' | '+
- ''+stHtml+' | '+
- ' |
';
-}
-
-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])=>
- '| '+esc(s)+' | '+(v.ok?'成功':'失败 '+esc(v.msg))+' |
').join('');
- document.getElementById('clip-result').innerHTML = rows;
- document.getElementById('clip-result-wrap').style.display = 'block';
- showToast('注入完成:成功 '+r.ok_count+' 台,失败 '+r.fail_count+' 台', r.fail_count?'error':'success');
- } finally {
- btn.disabled = false; btn.textContent = old;
- }
-}
-
-// ================== 通用列表:搜索 + 分页 + 排序 ==================
-// 用法:
-// setListPager(key,{data:()=>array, filter:(item,kw)=>bool, render:(item)=>html,
-// tbody:'tb-id', pager:'pager-id', pageSize:N,
-// sortValue:(item,colKey)=>值, // 可排序列的取值函数
-// empty:'...'});
-// 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||'
| 无数据 |
');
- // 表头排序指示器(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?'':'';
- var h='';
- return h;
-}
-function _lpGo(key,p){
- var st=_lp(key); st.page=p; applyListPager(key);
-}
-
// ================== Tab 1: 监控 ==================
let _statusData=null;
let _jobsData=null;
@@ -812,2157 +436,3 @@ function renderJobCards(){
}).join(''):'暂无任务,到"任务"Tab 新建
';
}
-// ================== 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:'| 暂无任务,点击"新建任务"创建 |
'
- });
- 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=''+schedTxt+' | ';
- // 下次运行时间(后端已按运行窗口跳过窗口外触发点)
- const nextRun=fmtNextRun(j.next_run);
- const nextOut=''+(nextRun||'-')+' | ';
- const retry=j.retry.max_attempts>1?(j.retry.max_attempts+'次/'+j.retry.delay+'s'):'不重试';
- const en=j.enabled?'启用':'停用';
- return ''+
- ''+esc(j.id)+' | '+
- ''+esc(j.name)+' | '+
- ''+esc(j.task_type)+' | '+
- ''+en+' | '+
- ''+esc(tgt)+(tgtDetail?'('+esc(tgtDetail)+')':'')+' | '+
- schedOut+
- nextOut+
- ''+retry+' | '+
- ''+
- (_can('tasks')?(
- ''+
- ''+
- ''+
- ''
- ):'只读')+
- ' | '+
- '
';
-}
-
-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=>'').join('');
- const gl=_groupsList.map(g=>'').join('');
- const dl=_devicesList.map(s=>'').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=>
- '').join('');
-
- document.getElementById('modal-body').innerHTML=
- ''+
- ''+
- ''+
- ''+
- ''+
- ''+
- '窗口外任务不会启动(定时触发和手动执行都受约束)。跨午夜示例:21:00-09:00 = 晚 9 点到次日早 9 点允许运行
'+
- ''+
- ''+
- ''+
- '
步骤编辑器用步骤卡片上的 ☑ 选中多个步骤,可打包成自定义动作'+
- (_can('tasks')?'':'')+
- '
'+
- '
'+
- '
';
-
- // 初始化步骤编辑器(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=
- ''+
- '';
-
- 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';
-}
-
-// ================== 步骤编辑器 ==================
-var STEP_LIB=[
- // cat:interact=交互操作 / screen=屏幕与App / flow=流程控制(操作库按此分组展示)
- {type:'click',label:'点击元素',icon:'✦',cat:'interact',params:{selector_type:'xpath',selector_value:'',wait_timeout:2}},
- {type:'click_xy',label:'点击坐标',icon:'🎯',cat:'interact',params:{x:50,y:50}},
- {type:'long_click',label:'长按元素',icon:'👆',cat:'interact',params:{selector_type:'xpath',selector_value:'',duration:1.0,wait_timeout:2}},
- {type:'swipe',label:'滑动',icon:'↕',cat:'interact',params:{direction:'up',duration_min:0.25,duration_max:0.50}},
- {type:'swipe_until',label:'滑动直到元素',icon:'🔍',cat:'interact',params:{selector_type:'xpath',selector_value:'',direction:'up',max_swipes:8,click_when_found:true}},
- {type:'input_text',label:'输入文字',icon:'⌨',cat:'interact',params:{mode:'random',texts:'你好\n有趣\n支持',fixed_text:'',clear_first:true}},
- {type:'key_event',label:'按键',icon:'⌨️',cat:'interact',params:{key:'back'}},
- {type:'open_app',label:'打开App',icon:'▶',cat:'screen',params:{package:'',wait_home:false,home_feature:''}},
- {type:'stop_app',label:'结束App',icon:'⏹',cat:'screen',params:{package:''}},
- {type:'screen_on',label:'亮屏',icon:'💡',cat:'screen',params:{}},
- {type:'screen_off',label:'息屏',icon:'🌙',cat:'screen',params:{}},
- {type:'keep_screen',label:'保持亮屏',icon:'🔆',cat:'screen',params:{mode:'on'}},
- {type:'wait',label:'等待',icon:'⏱',cat:'flow',params:{min:1.0,max:3.0}},
- {type:'wait_el',label:'等待元素',icon:'⏳',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:10}},
- {type:'loop',label:'循环块',icon:'↻',cat:'flow',params:{loop_mode:'rounds',max_iterations:10,loop_duration:600,children:[]}},
- {type:'group',label:'动作组',icon:'📦',cat:'flow',params:{children:[]}},
- {type:'if_el',label:'条件判断',icon:'❓',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:3,ocr_click:false,then:[],else:[]}}
-];
-var STEP_LIB_CATS=[['interact','交互操作'],['screen','屏幕与App'],['flow','流程控制']];
-var _customActions=[]; // 后端加载的自定义动作
-var _stepIdCounter=0;
-function _newStepId(){_stepIdCounter++;return 'step_'+Date.now().toString(36)+'_'+_stepIdCounter;}
-// 步骤的所有子步骤数组(loop/group 的 children、if_el 的 then/else 分支)
-function _childArrs(s){
- var arrs=[];
- if(s&&s.params){
- if(Array.isArray(s.params.children))arrs.push(s.params.children);
- if(Array.isArray(s.params.then))arrs.push(s.params.then);
- if(Array.isArray(s.params.else))arrs.push(s.params.else);
- }
- return arrs;
-}
-// 递归去掉步骤 id(保存到自定义动作库/拖入画布时重新生成,避免重复拖入后 id 冲突导致多选错乱)
-function _stripIds(steps){
- steps.forEach(function(s){
- delete s.id;
- _childArrs(s).forEach(_stripIds);
- });
-}
-function _findLib(type){return STEP_LIB.find(function(s){return s.type===type;});}
-// 选择器组件(类型下拉 + 值输入 + 抓取元素 + 测试按钮),click/long_click/swipe_until/wait_el/if_el 复用
-// withOcr=true 时额外提供 OCR识别 选项(图片/画布里的文字,仅条件判断支持)
-function _selRowHtml(p, extra, withOcr){
- var types=withOcr?['xpath','description','text','resourceId','descriptionContains','className','ocr']:
- ['xpath','description','text','resourceId','descriptionContains','className'];
- var labels={xpath:'xpath',description:'description',text:'text',resourceId:'resourceId',
- descriptionContains:'descriptionContains',className:'className',ocr:'OCR识别'};
- var opts=types.map(function(d){
- return '';}).join('');
- var toggle=withOcr?' onchange="_stepEditor._toggleOcrMode(this)"':'';
- var pick=_can('devices')?'':'';
- var test=_can('devices')?'':'';
- return ''+(extra||'');
-}
-
-// 加载自定义动作库
-function _loadCustomActions(){
- fetch('/api/custom_actions').then(function(r){return r.json();}).then(function(res){
- if(res.ok)_customActions=res.actions||[];
- if(typeof _stepEditor!=='undefined')_stepEditor._renderLib();
- }).catch(function(){});
-}
-
-var _stepEditor={
- _steps:[],
- _selected:[],
- _dragSuppressClick:false,
- init:function(steps,maxDuration,draftKey,canvasId,libId){
- this._draftKey=draftKey||'new';
- this._selected=[];
- this._updatePackBtns();
- this._dragSuppressClick=false;
- // 可指定编辑容器(任务编辑器 vs 动作编辑器),默认任务编辑器
- this._canvas=document.getElementById(canvasId||'se-canvas');
- this._lib=document.getElementById(libId||'se-lib');
- // 嵌套结构数组注册表:arr 唯一 id → 数组引用(修复多层级循环内移动错位)
- this._arrCounter=0;
- this._arrById={};
- this._arrIdMap=new WeakMap();
- // 恢复上次未保存的草稿(实时保存);没有则用传入的步骤
- var draft=this._loadDraft();
- if(draft&&Array.isArray(draft)){
- this._steps=draft;
- }else{
- this._steps=steps.length?JSON.parse(JSON.stringify(steps)):[];
- }
- this._ensureIds(this._steps);
- this._renderLib();
- this._renderCanvas();
- _loadCustomActions();
- // 实时同步:画布内任何输入(含下拉/勾选)都同步回 _steps 并自动保存
- var canvas=this._canvas;
- if(canvas&&!canvas.dataset.seBound){
- canvas.dataset.seBound='1';
- var self=this;
- canvas.addEventListener('input',function(){self._syncFromDom();});
- }
- if(draft&&Array.isArray(draft)){
- showToast('已恢复上次未保存的步骤编辑','success');
- }
- // max_duration 输入框(只在任务编辑器注入,动作编辑不需要)
- var head=document.querySelector('#step-editor .se-head');
- if(head&&!document.getElementById('f-max_duration')){
- var md=document.createElement('span');
- md.style.cssText='font-size:11px;display:flex;align-items:center;gap:4px';
- md.innerHTML='运行时长上限(秒,0=不限) ';
- head.appendChild(md);
- }
- },
- _ensureIds:function(steps){
- var self=this;
- steps.forEach(function(s){
- if(!s.id)s.id=_newStepId();
- _childArrs(s).forEach(function(a){self._ensureIds(a);});
- });
- },
- _renderLib:function(){
- var lib=this._lib;
- if(!lib)return; // 未初始化编辑器(如仅加载动作列表时)跳过
- // 按分类分组展示(STEP_LIB_CATS 定义分类顺序)
- var items=STEP_LIB_CATS.map(function(c){
- var list=STEP_LIB.filter(function(s){return s.cat===c[0];}).map(function(s){
- return ''+
- ''+s.icon+''+s.label+'
';
- }).join('');
- return '';
- }).join('');
- // 自定义动作区
- var customHtml='';
- if(_customActions.length){
- customHtml='自定义动作
';
- customHtml+=_customActions.map(function(a){
- return '
'+
- ''+(a.icon||'📦')+''+esc(a.name)+''+
- '
';
- }).join('');
- customHtml+='
';
- }
- lib.innerHTML=items+customHtml;
- var self=this;
- // 绑定拖拽 + 点击(系统步骤)—— 用 _dragSuppressClick 防止拖拽后重复触发 click
- lib.querySelectorAll('.lib-item[data-type]').forEach(function(item){
- var type=item.dataset.type;
- item.addEventListener('dragstart',function(e){
- self._dragSuppressClick=true;
- e.dataTransfer.setData('text/plain','type:'+type);
- e.dataTransfer.effectAllowed='copy';
- });
- item.addEventListener('click',function(e){
- if(self._dragSuppressClick){self._dragSuppressClick=false;return;}
- self.addStep(type);
- });
- });
- // 绑定拖拽 + 点击(自定义动作)
- lib.querySelectorAll('.lib-item[data-action-id]').forEach(function(item){
- var actionId=item.dataset.actionId;
- item.addEventListener('dragstart',function(e){
- self._dragSuppressClick=true;
- e.dataTransfer.setData('text/plain','action:'+actionId);
- e.dataTransfer.effectAllowed='copy';
- });
- item.addEventListener('click',function(e){
- if(e.target.classList.contains('del-btn'))return;
- if(self._dragSuppressClick){self._dragSuppressClick=false;return;}
- self.addCustomAction(actionId);
- });
- });
- },
- _renderCanvas:function(){
- if(!this._canvas)return;
- this._renderStepList(this._canvas,this._steps);
- },
- _renderStepList:function(container,steps){
- var self=this;
- if(!steps.length){
- container.innerHTML='';
- this._bindDropZone(container,steps); // 空时也要能接收拖入
- return;
- }
- container.innerHTML='';
- steps.forEach(function(step,idx){
- container.appendChild(self._renderCard(step,idx,steps));
- });
- this._bindDropZone(container,steps);
- },
- _renderCard:function(step,idx,parentArr){
- var self=this;
- var lib=_findLib(step.type)||{icon:'?',label:step.type};
- var card=document.createElement('div');
- card.className='step-card'+(step.type==='loop'?' loop-block':'')+(step.type==='group'?' group-block':'')+(step.type==='if_el'?' if-block':'');
- card.draggable=true;
- card.dataset.idx=idx;
- card.dataset.stepId=step.id||'';
- card.dataset.stepType=step.type;
- if(this._selected&&this._selected.indexOf(step.id)>=0)card.classList.add('selected');
- // head
- var head=document.createElement('div');
- head.className='sc-head';
- head.innerHTML=''+lib.icon+''+
- ''+esc(step.label||lib.label)+''+
- ''+step.type+''+
- ''+
- ''+
- ''+
- ''+
- ''+
- ''+
- ''+
- '';
- head.addEventListener('click',function(e){
- if(e.target.tagName==='BUTTON')return;
- self.toggleExpand(head.querySelector('button'));
- });
- card.appendChild(head);
- // body(参数表单)
- var body=document.createElement('div');
- body.className='sc-body';
- body.innerHTML=this._renderParams(step);
- card.appendChild(body);
- // 拖拽排序
- card.addEventListener('dragstart',function(e){
- e.dataTransfer.setData('text/plain','move:'+idx+':'+self._arrId(parentArr));
- e.dataTransfer.effectAllowed='move';
- card.classList.add('dragging');
- });
- card.addEventListener('dragend',function(){card.classList.remove('dragging');});
- card.addEventListener('dragover',function(e){
- e.preventDefault();
- card.classList.add('drag-over');
- });
- card.addEventListener('dragleave',function(){card.classList.remove('drag-over');});
- card.addEventListener('drop',function(e){
- e.preventDefault();
- e.stopPropagation(); // 关键:阻止冒泡到外层卡片/容器,避免重复添加
- card.classList.remove('drag-over');
- self._handleDrop(e,parentArr,idx);
- });
- // loop/group 子步骤
- if(step.type==='loop'||step.type==='group'){
- if(step.params&&step.params.children){
- var childWrap=document.createElement('div');
- childWrap.className='step-children';
- this._renderStepList(childWrap,step.params.children);
- card.appendChild(childWrap);
- }
- }
- // if_el 双分支:找到时(then) / 未找到时(else),各自独立容器接收拖入
- if(step.type==='if_el'&&step.params){
- [['then','✅ 找到时执行','if-then'],['else','❌ 未找到时执行','if-else']].forEach(function(b){
- var branch=document.createElement('div');
- branch.className='if-branch';
- var title=document.createElement('div');
- title.className='branch-title '+b[0];
- title.textContent=b[1];
- var wrap=document.createElement('div');
- wrap.className='step-children '+b[2];
- self._renderStepList(wrap,step.params[b[0]]||[]);
- branch.appendChild(title);
- branch.appendChild(wrap);
- card.appendChild(branch);
- });
- }
- return card;
- },
- _path:function(arr){
- // 用数组长度作为标识(顶层是 _steps,子层是某 loop 的 children)
- // 这里简化:顶层 path='root',子层 path='loop:'
- return arr===this._steps?'root':'child';
- },
- _renderParams:function(step){
- var p=step.params||{};
- var h='';
- if(step.type==='open_app'){
- h+='';
- h+='';
- }else if(step.type==='stop_app'){
- h+='';
- h+='强制结束该 App(am force-stop),清除后台进程,下次打开为冷启动。常配合"打开App"使用:结束→重新打开
';
- }else if(step.type==='screen_on'){
- h+='亮屏(息屏时唤醒并滑动解锁)。任务执行前用——息屏状态下 u2 无法点击操作
';
- }else if(step.type==='screen_off'){
- h+='息屏。任务结束后用,避免长时间亮屏导致烧屏/发热
';
- }else if(step.type==='keep_screen'){
- h+='';
- h+='长任务(如养号看视频几小时)放"保持亮屏"防屏幕超时熄灭;任务结束前放"恢复自动息屏"
';
- }else if(step.type==='swipe'){
- h+='';
- }else if(step.type==='click'){
- h+=_selRowHtml(p,'');
- }else if(step.type==='long_click'){
- h+=_selRowHtml(p,'');
- }else if(step.type==='swipe_until'){
- h+=_selRowHtml(p,''+
- ''+
- '上滑/下滑直到目标元素出现(每次滑动后检查一次),找不到则跳过继续
');
- }else if(step.type==='wait_el'){
- h+=_selRowHtml(p,''+
- '等待目标元素出现(替代固定秒数等待),超时则继续
');
- }else if(step.type==='click_xy'){
- h+=''+
- '屏幕百分比坐标,50/50=屏幕中心。无选择器时的兜底点击
';
- }else if(step.type==='key_event'){
- h+='';
- h+='返回键用于退出评论/返回上一页,比结束App轻量
';
- }else if(step.type==='wait'){
- h+='';
- }else if(step.type==='input_text'){
- const im=p.mode==='fixed'?'fixed':'random';
- h+='';
- h+='';
- h+='';
- h+='';
- h+='需先用"点击元素"步骤定位到输入框,本步骤只负责输入文字
';
- }else if(step.type==='loop'){
- const lm=p.loop_mode==='time'?'time':'rounds';
- h+='';
- h+='将下方子步骤(拖拽到本卡片内)循环执行指定轮次或时长;按时间时配合"运行时长上限"看预计结束时间
';
- }else if(step.type==='group'){
- h+='动作组:按顺序执行下方子步骤一次。把多个步骤打包成一个可复用的整体。
';
- h+=' 把本组步骤保存到左侧自定义动作库,方便复用
';
- }else if(step.type==='if_el'){
- h+=_selRowHtml(p,
- ''+
- ''+
- '
'+
- '超时内找到元素 → 执行下方"✅ 找到时"分支;未找到 → 执行"❌ 未找到时"分支。两分支可放任意步骤,也可再嵌套条件判断/循环;'+
- 'OCR识别可匹配图片/画布里的文字(截屏识别,跨平台)
', true);
- }
- // 共享:触发概率(所有步骤通用,0~100)
- h+='';
- // 可编辑 label
- h+='';
- return h;
- },
- _bindDropZone:function(container,arr){
- // 常驻容器(#se-canvas)多次重渲染会累积 drop 监听 → 拖一次添加多个。
- // 用 dataset 标记只绑定一次;数组引用用 WeakMap 每次更新。
- if(!this._dropArrs)this._dropArrs=new WeakMap();
- this._dropArrs.set(container,arr);
- if(container.dataset.dropBound)return;
- container.dataset.dropBound='1';
- var self=this;
- // 画布空白处也能接收 drop,拖入时高亮提示
- container.addEventListener('dragover',function(e){
- e.preventDefault();
- container.classList.add('drag-over');
- });
- container.addEventListener('dragleave',function(e){
- if(!container.contains(e.relatedTarget))container.classList.remove('drag-over');
- });
- container.addEventListener('drop',function(e){
- e.preventDefault();
- e.stopPropagation(); // 阻止冒泡到外层卡片/容器,避免重复添加
- container.classList.remove('drag-over');
- // 子卡片都会 stopPropagation,能冒泡到这里的 drop 一定落在空白区
- // (含 .se-empty 的提示文字),直接追加到数组末尾
- self._handleDrop(e,self._dropArrs.get(container),-1);
- });
- },
- _handleDrop:function(e,arr,idx){
- var self=this;
- var data=e.dataTransfer.getData('text/plain');
- if(!data)return;
- // drop 处理中同步重建 DOM 会打断拖拽事件,可能被浏览器重发 drop 导致重复添加。
- // 延迟到下一帧渲染,保证一次拖入只加一次。
- function rerender(){ setTimeout(function(){self._renderCanvas();},0); }
- if(data.startsWith('move:')){
- // 拖拽排序:同层级排序 / 跨层级移入(把步骤拖进循环块、循环套循环)
- var parts=data.split(':');
- var fromIdx=parseInt(parts[1]);
- var fromPath=parts[2];
- var srcArr=this._resolveArr(fromPath);
- if(srcArr){
- if(srcArr===arr){
- // 同层级排序
- if(fromIdx===idx)return;
- this._syncFromDom();
- var moved=arr.splice(fromIdx,1)[0];
- if(idx<0)arr.push(moved);
- else if(fromIdx=arr.length)arr.push(moved2);
- else arr.splice(idx,0,moved2);
- rerender();
- }
- }
- }
- }else if(data.startsWith('action:')){
- // 从自定义动作库拖入 → 展开为 group 步骤
- var actionId=data.substring(7);
- var action=_customActions.find(function(a){return a.id===actionId;});
- if(action&&action.steps&&action.steps.length){
- var ns=this._makeStep({type:'group',label:action.name,icon:action.icon||'📦',params:{children:[]}});
- ns.label=action.name;
- ns.params.children=JSON.parse(JSON.stringify(action.steps));
- _stripIds(ns.params.children); // 重新生成 id,避免同一动作拖入多次后 id 冲突
- this._ensureIds(ns.params.children);
- if(idx<0||idx>=arr.length)arr.push(ns);
- else arr.splice(idx,0,ns);
- rerender();
- }
- }else if(data.startsWith('type:')){
- // 从系统操作库拖入
- var type=data.substring(5);
- var lib=_findLib(type);
- if(lib){
- var ns2=this._makeStep(lib);
- if(idx<0||idx>=arr.length)arr.push(ns2);
- else arr.splice(idx,0,ns2);
- rerender();
- }
- }
- },
- _makeStep:function(lib){
- return {id:_newStepId(),type:lib.type,label:lib.label,params:JSON.parse(JSON.stringify(lib.params))};
- },
- // 测试此步骤:在选中设备上试执行当前步骤,验证选择器
- _testStep:function(btn){
- var card=btn.closest('.step-card');
- if(!card)return;
- var type=card.dataset.stepType||'';
- var params={};
- card.querySelectorAll('[data-key]').forEach(function(inp){
- if(inp.type==='checkbox')params[inp.dataset.key]=inp.checked;
- else if(inp.type==='number')params[inp.dataset.key]=parseFloat(inp.value)||0;
- else params[inp.dataset.key]=inp.value;
- });
- this._pendingTest={type:type,label:'测试',params:params};
- this._showTestDevicePicker();
- },
- _showTestDevicePicker:function(){
- document.getElementById('test-modal-title').textContent='测试步骤 - 选择设备';
- document.getElementById('test-modal-body').innerHTML=
- '';
- document.getElementById('test-overlay').classList.add('show');
- var self=this;
- apiGet('/api/uiauto/devices').then(function(r){
- var list=document.getElementById('test-device-list');
- if(!list)return;
- if(!r||!r.ok){list.innerHTML=''+esc((r&&r.error)||'获取设备失败')+'
';return;}
- list.innerHTML=(r.devices||[]).map(function(d){
- return ''+
- '
📱
'+
- '
'+esc(d.serial)+'
'+
- '
'+esc(d.model||d.product||'')+'
';
- }).join('')||'无可用设备
';
- });
- },
- _runTestStep:async function(serial){
- var step=this._pendingTest;
- var body=document.getElementById('test-modal-body');
- if(!step||!body)return;
- body.innerHTML='正在 '+esc(serial)+' 上执行(adb + u2 连接约需几秒)...
请稍候,期间不要操作该设备
';
- var r=await apiPost('/api/steps/test',{serial:serial,step:step});
- if(!r)return;
- var icon=r.result==='命中'?'✅':(r.result==='未找到'?'❌':'▶');
- body.innerHTML=''+icon+'
'+esc(r.result||'已执行')+''+
- '
'+esc(r.msg||'')+'
'+
- '
';
- },
- addStep:function(type){
- var lib=_findLib(type);
- if(!lib)return;
- this._syncFromDom();
- this._steps.push(this._makeStep(lib));
- this._renderCanvas();
- },
- addCustomAction:function(actionId){
- var action=_customActions.find(function(a){return a.id===actionId;});
- if(!action||!action.steps||!action.steps.length)return;
- this._syncFromDom();
- var ns=this._makeStep({type:'group',label:action.name,icon:action.icon||'📦',params:{children:[]}});
- ns.label=action.name;
- ns.params.children=JSON.parse(JSON.stringify(action.steps));
- _stripIds(ns.params.children); // 重新生成 id,避免同一动作拖入多次后 id 冲突
- this._ensureIds(ns.params.children);
- this._steps.push(ns);
- this._renderCanvas();
- },
- delCustomAction:function(actionId){
- if(!confirm('确定删除这个自定义动作?'))return;
- apiDelete('/api/custom_actions/'+actionId).then(function(res){
- if(!res)return;
- if(res.ok){_loadCustomActions();showToast('已删除','success');}
- else showToast(res.error||'删除失败','error');
- });
- },
- toggleSelect:function(stepId){
- this._syncFromDom();
- if(!this._selected)this._selected=[];
- var i=this._selected.indexOf(stepId);
- if(i>=0)this._selected.splice(i,1);
- else this._selected.push(stepId);
- this._renderCanvas();
- this._updatePackBtns();
- },
- // 同步所有"打包选中步骤"按钮的可用状态(任务编辑器 + 动作编辑器)
- _updatePackBtns:function(){
- var n=this._selected&&this._selected.length;
- ['se-pack-btn','se-pack-btn2'].forEach(function(id){
- var bar=document.getElementById(id);
- if(bar)bar.disabled=n<1;
- });
- },
- saveAsCustomAction:function(stepId){
- // 从 group 步骤的"存为自定义动作"按钮调用时传入该 group 的 id:
- // 优先收集该 group 的子步骤;否则收集 ☑ 选中的步骤
- var stepsToSave=[];
- if(stepId){
- var group=this._findStepById(this._steps,stepId);
- if(group&&group.params&&group.params.children&&group.params.children.length){
- stepsToSave=JSON.parse(JSON.stringify(group.params.children));
- _stripIds(stepsToSave);
- }
- }
- if(!stepsToSave.length&&this._selected&&this._selected.length){
- // 从选中步骤收集
- stepsToSave=this._collectByIds(this._steps,this._selected);
- }
- if(!stepsToSave.length){
- showToast('请先用 ☑ 选中要打包的步骤','error');
- return;
- }
- var name=prompt('输入动作名称:','我的动作');
- if(!name)return;
- var self=this;
- apiPost('/api/custom_actions',{name:name,steps:stepsToSave})
- .then(function(res){
- if(!res)return;
- if(res.ok){
- showToast('已保存到自定义动作库','success');
- self._selected=[];
- _loadCustomActions();
- self._renderCanvas();
- self._updatePackBtns();
- }else showToast(res.error||'保存失败','error');
- });
- },
- // 在嵌套结构中按 id 查找步骤(group/loop 的子步骤、if_el 的分支步骤也找得到)
- _findStepById:function(steps,id){
- var self=this;
- for(var i=0;i=0){
- var copy=JSON.parse(JSON.stringify(s));
- _stripIds([copy]);
- result.push(copy); // 容器选中时子步骤已包含在拷贝里,不再单独收集
- }else{
- _childArrs(s).forEach(function(a){
- result=result.concat(self._collectByIds(a,ids));
- });
- }
- });
- return result;
- },
- toggleExpand:function(btn){
- var card=btn.closest('.step-card');
- card.classList.toggle('expanded');
- },
- _toggleInputMode:function(sel){
- var body=sel.closest('.sc-body');
- var mode=sel.value;
- body.querySelectorAll('[data-mode]').forEach(function(g){
- g.style.display=g.dataset.mode===mode?'block':'none';
- });
- },
- _toggleLoopMode:function(sel){
- var body=sel.closest('.sc-body');
- var mode=sel.value;
- body.querySelectorAll('[data-lmode]').forEach(function(g){
- g.style.display=g.dataset.lmode===mode?'block':'none';
- });
- },
- // 条件判断:选择器类型切到 OCR 时显示"命中后点击"开关
- _toggleOcrMode:function(sel){
- var card=sel.closest('.step-card');
- var f=card&&card.querySelector('[data-ocr-field]');
- if(f)f.style.display=sel.value==='ocr'?'block':'none';
- },
- // 给数组分配唯一 id,用于 move/duplicate/remove/拖拽定位到正确的父数组
- _arrId:function(arr){
- if(arr===this._steps)return 'root';
- if(!this._arrIdMap)this._arrIdMap=new WeakMap();
- if(!this._arrById)this._arrById={};
- if(!this._arrIdMap.has(arr)){
- var id='arr_'+(++this._arrCounter);
- this._arrIdMap.set(arr,id);
- this._arrById[id]=arr;
- }
- return this._arrIdMap.get(arr);
- },
- _resolveArr:function(path){
- if(path==='root')return this._steps;
- return this._arrById&&this._arrById[path];
- },
- // 判断 step 的子孙数组中是否包含 arr(防循环块套进自己导致渲染无限递归)
- _containsArr:function(step,arr){
- var arrs=_childArrs(step);
- if(!arrs.length)return false;
- if(arrs.indexOf(arr)>=0)return true;
- for(var i=0;i=arr.length)return;
- var tmp=arr[ni];arr[ni]=arr[idx];arr[idx]=tmp;
- this._renderCanvas();
- },
- duplicate:function(idx,path){
- this._syncFromDom();
- var arr=this._resolveArr(path);
- if(!arr)return;
- var copy=JSON.parse(JSON.stringify(arr[idx]));
- copy.id=_newStepId();
- this._ensureIds([copy]);
- arr.splice(idx+1,0,copy);
- this._renderCanvas();
- },
- remove:function(idx,path){
- this._syncFromDom();
- var arr=this._resolveArr(path);
- if(!arr)return;
- arr.splice(idx,1);
- this._renderCanvas();
- },
- collect:function(){
- // 先把当前 DOM 上的参数值同步回 _steps
- var self=this;
- this._syncParams(this._canvas,this._steps);
- return JSON.parse(JSON.stringify(this._steps));
- },
- validate:function(steps){
- // 校验步骤配置,返回警告列表
- var warnings=[];
- function check(arr,prefix){
- arr.forEach(function(s,i){
- var name=prefix?(prefix+'.'+(i+1)):('步骤'+(i+1));
- var label=s.label||s.type;
- if(s.type==='click'){
- if(!s.params.selector_value){
- warnings.push(name+' "'+label+'": 选择器值为空,点击步骤会跳过。请用"抓取元素"按钮选择目标。');
- }
- }else if(s.type==='long_click'||s.type==='swipe_until'||s.type==='wait_el'){
- if(!s.params.selector_value){
- warnings.push(name+' "'+label+'": 选择器值为空,该步骤会跳过。请用"抓取元素"或"测试"按钮选择目标。');
- }
- }else if(s.type==='click_xy'){
- const x=parseFloat(s.params.x), y=parseFloat(s.params.y);
- if(isNaN(x)||isNaN(y)||x<0||x>100||y<0||y>100){
- warnings.push(name+' "'+label+'": 坐标需在 0-100 之间(屏幕百分比)。');
- }
- }else if(s.type==='open_app'||s.type==='stop_app'){
- if(!s.params.package){
- warnings.push(name+' "'+label+'": 包名为空,无法'+(s.type==='open_app'?'启动':'结束')+'App');
- }
- }else if(s.type==='input_text'){
- if(s.params.mode==='fixed'&&!s.params.fixed_text){
- warnings.push(name+' "'+label+'": 指定文字为空');
- }else if(s.params.mode==='random'&&!s.params.texts){
- warnings.push(name+' "'+label+'": 候选文字为空');
- }
- }else if(s.type==='if_el'){
- if(!s.params.selector_value){
- warnings.push(name+' "'+label+'": 条件判断的选择器为空,会直接走"未找到"分支。');
- }
- }
- _childArrs(s).forEach(function(a){
- if(a.length)check(a,name+' 子步骤');
- });
- });
- }
- check(steps,'');
- return warnings;
- },
- _syncParams:function(container,steps){
- var self=this;
- var cards=container.querySelectorAll(':scope > .step-card');
- cards.forEach(function(card,i){
- if(!steps[i])return;
- // 只读本卡片直接子级的 .sc-body,避免把嵌套子步骤的输入误写进本步骤 params
- var inputs=card.querySelectorAll(':scope > .sc-body [data-key]');
- inputs.forEach(function(inp){
- var key=inp.dataset.key;
- if(inp.type==='checkbox'){
- steps[i].params[key]=inp.checked;
- }else if(inp.type==='number'){
- steps[i].params[key]=parseFloat(inp.value)||0;
- }else{
- if(key==='_label'){
- steps[i].label=inp.value||steps[i].label;
- }else{
- steps[i].params[key]=inp.value;
- }
- }
- });
- // 递归子步骤:loop/group 的 children
- var childWrap=card.querySelector(':scope > .step-children');
- if(childWrap&&steps[i].params&&steps[i].params.children){
- self._syncParams(childWrap,steps[i].params.children);
- }
- // if_el 双分支(then/else 在 .if-branch 内,不会与上面的直接子级选择器冲突)
- var thenWrap=card.querySelector(':scope > .if-branch .step-children.if-then');
- if(thenWrap&&steps[i].params&&Array.isArray(steps[i].params.then)){
- self._syncParams(thenWrap,steps[i].params.then);
- }
- var elseWrap=card.querySelector(':scope > .if-branch .step-children.if-else');
- if(elseWrap&&steps[i].params&&Array.isArray(steps[i].params.else)){
- self._syncParams(elseWrap,steps[i].params.else);
- }
- });
- },
- // 把画布 DOM 上的参数同步回 _steps,并自动保存草稿(操作前调用防丢失)
- _syncFromDom:function(){
- this._syncParams(this._canvas,this._steps);
- this._autosave();
- },
- _autosave:function(){
- try{localStorage.setItem('se_draft_'+this._draftKey,JSON.stringify(this._steps));}catch(e){}
- },
- _loadDraft:function(){
- try{
- var d=localStorage.getItem('se_draft_'+this._draftKey);
- if(d)return JSON.parse(d);
- }catch(e){}
- return null;
- },
- clearDraft:function(){
- try{localStorage.removeItem('se_draft_'+this._draftKey);}catch(e){}
- },
- // ================== 元素抓取 ==================
- openPicker:function(btn){
- var input=btn.previousElementSibling;
- this._elTargetInput=input;
- // 直接弹设备选择页(独立模态框,不影响任务编辑窗口)
- this._showDevicePicker();
- },
- // 步骤1:设备选择页
- _showDevicePicker:function(){
- document.getElementById('el-picker-title').textContent='抓取元素 - 选择设备';
- document.getElementById('el-picker-body').innerHTML='';
- document.getElementById('el-picker-footer').innerHTML='';
- document.getElementById('el-picker-overlay').classList.add('show');
- fetch('/api/uiauto/devices').then(function(r){return r.json();}).then(function(res){
- var list=document.getElementById('el-device-list');
- if(!res.ok){list.innerHTML=''+esc(res.error||'获取设备失败')+'
';return;}
- var devices=res.devices||[];
- if(!devices.length){list.innerHTML='uiauto2 未发现已连接设备
';return;}
- list.innerHTML=devices.map(function(d){
- return ''+
- '
📱
'+
- '
'+esc(d.serial)+'
'+
- '
'+esc(d.model||d.product||'')+'
'+
- '
'+esc(d.status||'device')+' ';
- }).join('');
- }).catch(function(e){
- document.getElementById('el-device-list').innerHTML='请求失败: '+esc(e)+'
';
- });
- },
- // 步骤2:选中设备后,显示截图 + 元素树
- _showElPicker:function(serial){
- var self=this;
- self._elSerial=serial;
- document.getElementById('el-picker-title').textContent='抓取元素 - '+serial;
- document.getElementById('el-picker-body').innerHTML=
- ''+
- '
'+
- '
'+
- ''+
- '加载中...'+
- '
'+
- '
'+
- '
'+
- '
'+
- '
';
- document.getElementById('el-picker-footer').innerHTML=
- ''+
- '';
- // 加载截图和元素树(_refreshShot 内部同时刷新两者)
- self._refreshShot();
- },
- // 拉取元素树(进入页面和刷新时共用;带时间戳防缓存)
- _loadElData:function(){
- var serial=this._elSerial;
- if(!serial)return;
- var listEl=document.getElementById('el-list');
- if(listEl)listEl.innerHTML='加载元素树...
';
- var self=this;
- fetch('/api/uiauto/elements?serial='+encodeURIComponent(serial)+'&_='+Date.now())
- .then(function(r){return r.json();})
- .then(function(data){
- var le=document.getElementById('el-list');
- if(!le)return;
- if(!data.ok){
- le.innerHTML=''+esc(data.error||'抓取失败')+'
';
- return;
- }
- self._elData=data.elements||[];
- self._renderEl(self._elData);
- // 刷新后保持搜索过滤状态
- var search=document.getElementById('el-search');
- if(search&&search.value)self._filterEl(search.value);
- self._renderOverlays();
- })
- .catch(function(e){
- var le=document.getElementById('el-list');
- if(le)le.innerHTML='请求失败: '+esc(e)+'
';
- });
- },
- // 刷新截图
- _refreshShot:function(){
- var serial=this._elSerial;
- if(!serial)return;
- var container=document.getElementById('el-shot-container');
- if(!container)return;
- container.innerHTML='正在获取截图...
';
- var info=document.getElementById('el-shot-info');
- if(info)info.textContent='加载中...';
- var self=this;
- var img=new Image();
- img.onload=function(){
- container.innerHTML='';
- container.appendChild(img);
- var infoEl=document.getElementById('el-shot-info');
- if(infoEl)infoEl.textContent=img.naturalWidth+'x'+img.naturalHeight;
- // 截图加载完后重新渲染元素边界框
- self._renderOverlays();
- };
- img.onerror=function(){
- container.innerHTML='截图加载失败
';
- var infoEl=document.getElementById('el-shot-info');
- if(infoEl)infoEl.textContent='失败';
- };
- img.src='/api/uiauto/screenshot?serial='+encodeURIComponent(serial)+'&_='+Date.now();
- // 同时重新拉取元素树——只刷截图会导致"截图是新的、元素框是旧的"错位
- this._loadElData();
- },
- // 在截图上渲染元素边界框(可点击)
- _renderOverlays:function(){
- var container=document.getElementById('el-shot-container');
- var img=container.querySelector('img');
- if(!img||!this._elData)return;
- var self=this;
- function draw(){
- // 清除旧 overlay 和旧点击处理
- container.querySelectorAll('.el-shot-overlay').forEach(function(o){o.remove();});
- container.onclick=null;
- var scaleX=img.clientWidth/img.naturalWidth;
- var scaleY=img.clientHeight/img.naturalHeight;
- // 图片在 container 里的偏移
- var imgOffsetX=img.offsetLeft;
- var imgOffsetY=img.offsetTop;
- var imgW=img.naturalWidth, imgH=img.naturalHeight;
- self._elData.forEach(function(el,i){
- if(!el.bounds)return;
- // bounds 格式 "[x1,y1][x2,y2]"
- var m=el.bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
- if(!m)return;
- var x1=parseInt(m[1]),y1=parseInt(m[2]),x2=parseInt(m[3]),y2=parseInt(m[4]);
- var w=(x2-x1)*scaleX;
- var h=(y2-y1)*scaleY;
- if(w<4||h<4)return; // 太小不画
- // 跳过占满屏幕的容器:面积>90%屏幕,没有选择价值,还会盖住小元素
- if((x2-x1)*(y2-y1) > imgW*imgH*0.9)return;
- var ov=document.createElement('div');
- ov.className='el-shot-overlay';
- ov.setAttribute('data-idx',i);
- ov.style.left=(imgOffsetX+x1*scaleX)+'px';
- ov.style.top=(imgOffsetY+y1*scaleY)+'px';
- ov.style.width=w+'px';
- ov.style.height=h+'px';
- ov.title=el.name||el.class||'';
- container.appendChild(ov);
- });
- // 点击截图:按 bounds 命中测试,选中最小的(最深的)元素,
- // 而不是被 DOM 层叠中的大容器/大兄弟挡住
- container.onclick=function(e){
- var im=container.querySelector('img');
- if(!im)return;
- var r=im.getBoundingClientRect();
- var px=(e.clientX-r.left)/scaleX; // 换算回原始分辨率坐标
- var py=(e.clientY-r.top)/scaleY;
- var best=-1,bestArea=Infinity;
- self._elData.forEach(function(el,i){
- var mb=el.bounds&&el.bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
- if(!mb)return;
- var a=parseInt(mb[1]),b=parseInt(mb[2]),c=parseInt(mb[3]),dd=parseInt(mb[4]);
- if(px>=a&&px<=c&&py>=b&&py<=dd){
- var area=(c-a)*(dd-b);
- if(area=0)self._highlightEl(best);
- };
- }
- if(img.complete&&img.naturalWidth>0)draw();
- else img.onload=draw;
- },
- // 高亮元素(截图框 + 列表项联动)
- _highlightEl:function(i){
- // 清除旧高亮(只在 el-picker-overlay 范围内查找)
- var picker=document.getElementById('el-picker-overlay');
- picker.querySelectorAll('.el-shot-overlay.active').forEach(function(o){o.classList.remove('active');});
- picker.querySelectorAll('.el-picker-item.active').forEach(function(o){o.classList.remove('active');});
- // 高亮当前(overlay 用 data-idx 查找,渲染时跳过了全屏容器,索引不再一一对应)
- var overlay=picker.querySelector('.el-shot-overlay[data-idx="'+i+'"]');
- if(overlay)overlay.classList.add('active');
- // 列表项用 data-idx 查找(过滤后顺序可能不同)
- var item=picker.querySelector('.el-picker-item[data-idx="'+i+'"]');
- if(item){
- item.classList.add('active');
- item.scrollIntoView({block:'nearest',behavior:'smooth'});
- }
- // 信息栏显示将填入的选择器,确认是否精确命中目标
- var infoEl=document.getElementById('el-shot-info');
- if(infoEl&&this._elData&&this._elData[i]){
- var sg=this._elData[i].suggested||{};
- infoEl.textContent='已选中: '+(sg.type||'')+' '+(sg.value||'');
- }
- },
- _renderEl:function(list){
- var self=this;
- var html=list.map(function(el){
- var idx=self._elData.indexOf(el);
- var attrs=[];
- if(el.resource_id)attrs.push('id:'+esc(el.resource_id)+'');
- if(el.text)attrs.push('text:'+esc(el.text)+'');
- if(el.description)attrs.push('desc:'+esc(el.description)+'');
- var indent=' '.repeat(Math.min(el.depth||0,6));
- var sg=el.suggested||{};
- return ''+
- '
'+(el.depth||0)+''+
- '
'+indent+esc(el.name||el.class||'')+'
'+
- '
'+attrs.join('')+'
'+
- '
'+esc(sg.type)+''+
- (sg.indexed?'
#'+sg.occ+'/'+sg.total+'':'')+
- (sg.broad?'
⚠宽泛':'')+
- '
';
- }).join('');
- document.getElementById('el-list').innerHTML=html||'无元素
';
- },
- _filterEl:function(kw){
- if(!kw){this._renderEl(this._elData);this._renderOverlays();return;}
- kw=kw.toLowerCase();
- var f=this._elData.filter(function(el){
- return (el.resource_id&&el.resource_id.toLowerCase().includes(kw))||
- (el.text&&el.text.toLowerCase().includes(kw))||
- (el.description&&el.description.toLowerCase().includes(kw));
- });
- this._renderEl(f);
- },
- _pickEl:function(i){
- var el=this._elData[i];
- if(!el||!this._elTargetInput)return;
- // 填入推荐选择器值,并切换选择器类型
- this._elTargetInput.value=el.suggested.value;
- var sel=this._elTargetInput.closest('.sc-body').querySelector('[data-key="selector_type"]');
- if(sel)sel.value=el.suggested.type;
- // 程序赋值不会触发 input 事件,手动同步回 _steps 防丢失
- this._syncFromDom();
- // 只关闭抓取元素弹窗,不影响任务编辑窗口
- closeElPicker();
- showToast('已填入: '+el.suggested.type+'='+el.suggested.value,'success');
- }
-};
-
-async function saveTask(jobId){
- const name=document.getElementById('f-name').value.trim();
- if(!name){showToast('任务名不能为空','error');return;}
- let params={};
- const tt=document.getElementById('f-task_type').value;
- if(tt==='generic_steps'){
- // 通用步骤任务:从步骤编辑器收集
- const steps=_stepEditor.collect();
- if(!steps.length){showToast('请至少添加一个步骤','error');return;}
- // 校验:click 步骤的 selector_value 不能为空
- var warnings=_stepEditor.validate(steps);
- if(warnings.length){
- if(!confirm('步骤配置有问题:\n\n'+warnings.join('\n')+'\n\n确定要保存吗?'))return;
- }
- const mdEl=document.getElementById('f-max_duration');
- params={max_duration:mdEl?parseInt(mdEl.value)||0:0,steps:steps};
- }else{
- try{params=JSON.parse(document.getElementById('f-params').value||'{}');}
- catch(e){showToast('参数 JSON 格式错误','error');return;}
- }
-
- const targetMode=document.getElementById('f-target_mode').value;
- const target={mode:targetMode};
- if(targetMode==='group')target.group_name=document.getElementById('f-target_group').value;
- if(targetMode==='serial')target.serial=document.getElementById('f-target_serial').value;
-
- const schedMode=document.getElementById('f-schedule_mode').value;
- let schedule={mode:'once'};
- if(schedMode==='cron'){
- schedule={mode:'cron',cron:buildCronFromFreq()};
- }else if(schedMode==='cron_stop'){
- const st=(document.getElementById('f-schedule_stop_time').value||'18:00').split(':');
- schedule={mode:'cron_stop',
- cron:buildCronFromFreq(),
- stop_cron:(parseInt(st[1])||0)+' '+(parseInt(st[0])||0)+' * * *'};
- }
- // 运行窗口:窗口外(定时触发 + 手动执行)不启动
- if(document.getElementById('f-window_enable').checked){
- schedule.window={
- start:document.getElementById('f-window_start').value||'09:00',
- end:document.getElementById('f-window_end').value||'18:00',
- };
- }
-
- const data={
- name:name,
- task_type:document.getElementById('f-task_type').value,
- enabled:document.getElementById('f-enabled').checked,
- target:target,
- params:params,
- schedule:schedule,
- retry:{max_attempts:Math.max(1,parseInt(document.getElementById('f-retry_attempts').value)||1),
- delay:Math.max(10,parseInt(document.getElementById('f-retry_delay').value)||60)}
- };
-
- if(jobId){
- const r=await apiPut('/api/jobs/'+jobId,data);
- handleResult(r,()=>{_stepEditor.clearDraft();closeModal();loadTasks();});
- }else{
- const r=await apiPost('/api/jobs',data);
- handleResult(r,()=>{_stepEditor.clearDraft();closeModal();loadTasks();});
- }
-}
-
-async function deleteTask(jobId){
- if(!confirm('确认删除此任务吗?'))return;
- const r=await apiDelete('/api/jobs/'+jobId);
- handleResult(r,()=>loadTasks());
-}
-
-// ================== 自定义动作管理 ==================
-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:'| 暂无自定义动作,可点击"新建动作"或用任务编辑器☑选中步骤打包 |
'
- });
- applyListPager('actions');
- initListSearch('actions','search-actions');
- _stepEditor._renderLib(); // 刷新步骤编辑器左侧动作库
-}
-function renderActionRow(a){
- const n=(a.steps||[]).length;
- return ''+
- '| '+esc(a.icon||'📦')+' | '+
- ''+esc(a.name)+' | '+
- ''+n+' | '+
- ''+esc(a.created_at||'-')+' | '+
- ''+
- (_can('tasks')?(
- ' '+
- ''
- ):'只读')+
- ' | '+
- '
';
-}
-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=
- ''+
- '用下方步骤编辑器编排动作内容,保存后可拖入任务步骤编辑器复用
'+
- ''+
- '
动作步骤支持循环/分组/概率触发/拖拽排序'+
- (_can('tasks')?'':'')+'
'+
- '
'+
- '
';
- document.getElementById('action-modal-footer').innerHTML=
- ''+
- '';
- // 用独立画布初始化步骤编辑器(不与任务编辑器冲突)
- _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');
-}
-
-// ================== 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:'| 暂无分组,点击"新建分组"创建 |
'
- });
- applyListPager('groups');
- initListSearch('groups','search-groups');
-}
-function renderGroupRow(g){
- const serials=g.serials||[];
- return ''+
- '| '+esc(g.name)+' | '+
- ''+esc(g.description||'-')+' | '+
- ''+serials.length+' | '+
- ''+(serials.length?serials.map(esc).join(', '):'-')+' | '+
- ''+
- (_can('tasks')?(
- ''+
- ''
- ):'只读')+
- ' | '+
- '
';
-}
-
-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=>
- ''
- ).join('');
-
- document.getElementById('modal-body').innerHTML=
- ''+
- ''+
- '';
-
- document.getElementById('modal-footer').innerHTML=
- ''+
- '';
-
- 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,'[ERROR]')
- .replace(/\[WARNING\]/g,'[WARNING]')
- .replace(/\[INFO\]/g,'[INFO]');
- document.getElementById('log-content').innerHTML=colored||'(空)';
- 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:'| 暂无用户 |
'
- });
- applyListPager('users');
- initListSearch('users','search-users');
-}
-function renderUserRow(u){
- const admin=u.is_admin?'管理员':'普通';
- const perms=(u.perms||[]).map(p=>''+(PERM_LABELS[p]||p)+'').join(' ')
- ||'-';
- return ''+
- '| '+u.id+' | '+
- ''+esc(u.username)+' | '+
- ''+admin+' | '+
- ''+perms+' | '+
- ''+
- ''+
- (u.username==='admin'?'':'')+
- ' | '+
- '
';
-}
-
-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=>
- ''
- ).join('');
- document.getElementById('modal-body').innerHTML=
- ''+
- ''+
- ''+
- '';
-
- document.getElementById('modal-footer').innerHTML=
- ''+
- '';
-
- 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());
-}
-
-// ================== 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 ;服务器类命令保持原样
-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=[''].concat(
- list.map(d=>
- ''
- )
- );
- 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='| 加载中... |
';
- const r=await apiGet('/api/tailscale/devices');
- if(!r)return;
- if(!r.ok){
- tb.innerHTML='| '+esc(r.error||'获取失败')+' |
';
- 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:'| tailnet 暂无设备 |
'
- });
- applyListPager('tailscale');
- initListSearch('tailscale','search-tailscale');
- if(st)st.textContent='共 '+_tsDevices.length+' 台设备';
-}
-function renderTsRow(d){
- const ip=(d.addresses||[])[0]||'-';
- return ''+
- ''+esc(d.hostname||'-')+' | '+
- ''+esc(d.name||'-')+' | '+
- ''+esc(ip)+' | '+
- ''+esc(d.os||'-')+' | '+
- ''+((d.online===true)?'在线':(d.online===false?'离线':'最近在线'))+' | '+
- ''+(d.authorized?'已授权':'未授权')+' | '+
- ''+(d.key_expiry_disabled?'永不过期':'会过期')+' | '+
- ''+
- ' '+
- ' '+
- ' '+
- ' '+
- ''+
- ' |
';
-}
-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=
- '新设备执行 tailscale up --authkey=... 接入(默认已预授权)
'+
- ''+
- ''+
- ' ';
- ov.classList.add('show');
- showToast('Auth Key 已生成','success');
- }else{
- showToast(r.error,'error');
- }
- });
-}
-function copyTsKey(){
- const box=document.getElementById('ts-key-box');
- if(!box)return;
- box.select();
- try{document.execCommand('copy');}catch(e){}
- const s=document.getElementById('ts-key-status');
- if(s)s.textContent='已复制';
-}
-
-// ================== Tab: 应用 ==================
-let _apksCache=[];
-let _installTimer=null;
-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:'| 暂无APK,点击"上传APK"上传 |
'
- });
- applyListPager('apks');
- initListSearch('apks','search-apks');
- await fillAppsDeviceList();
-}
-function renderApkRow(a){
- return ''+
- '| '+esc(a.display_name||'(未命名)')+' | '+
- ''+esc(a.package_name||'-')+' | '+
- ''+(a.version_name?esc(a.version_name):'-')+' | '+
- ''+fmtSize(a.size)+' | '+
- ''+esc(a.upload_time)+' | '+
- ''+
- ' '+
- ''+
- ' | '+
- '
';
-}
-
-// 填充"设备已装应用"的设备下拉(保留当前选择)
-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=''+devices.map(d=>
- ''
- ).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='| '+(r&&r.error?esc(r.error):'获取失败')+' |
';
- showToast('获取失败','error');return;
- }
- const apps=r.apps||[];
- if(!apps.length){tb.innerHTML='| 无应用 |
';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:'| 无应用 |
'
- });
- applyListPager('devapps');
- initListSearch('devapps','search-devapps');
-}
-function renderDevAppRow(a){
- return ''+
- '| '+esc(a.label||a.package)+''+(a.label?' '+esc(a.package)+'':'')+' | '+
- ''+(a.version_name?esc(a.version_name):'-')+' | '+
- ''+(a.version_code!=null?esc(a.version_code):'-')+' | '+
- ''+esc(a.path||'-')+' | '+
- '
';
-}
-
-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='正在上传 '+esc(file.name)+' '+pct+'% ('+loaded+'/'+total+' MB)'+
- '';
- }
- };
- 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=
- ''+
- '安装应用:'+esc(apkName)+''+
- ' 运行中设备将自动跳过'+
- '
'+
- ''+
- ' '+
- ' '+
- ''+
- '
'+
- ''+
- online.map(d=>{
- const isUsb=d.source==='usb';
- const st=''+(isUsb?'本地USB':(d.source==='stf'?'STF':'adb'))+'';
- // serial 唯一,显示 IP + 型号便于区分同型号设备
- const label=(d.serial||'').replace(/:5555$/,'')+' · '+(d.model||(isUsb?'USB 有线设备':'设备'));
- return '';
- }).join('')+
- '
';
-
- document.getElementById('modal-title').textContent='安装应用到设备';
- document.getElementById('modal-body').innerHTML=body;
- document.getElementById('modal-footer').innerHTML=
- ''+
- '';
- 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=
- '';
- document.getElementById('modal-footer').innerHTML=
- '';
- 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='无安装任务
';
- 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 '| '+esc(info.name||serial)+' | '+
- ''+esc(serial)+' | '+
- ''+s.t+' | '+
- ''+esc(info.msg||'')+' |
';
- }).join('');
-
- const pct=st.total>0?Math.round((st.success+st.failed+st.skipped)*100/st.total):0;
- const summary=
- ''+
- '
'+
- '应用:'+esc(st.apk_name)+''+
- '成功 '+st.success+''+
- '失败 '+st.failed+''+
- '跳过 '+st.skipped+''+
- '安装中 '+st.installing+''+
- '等待 '+st.pending+''+
- '
'+
- '
'+
- '
';
-
- document.getElementById('install-progress').innerHTML=
- summary+'';
-
- 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();
-}
-
-
-// ================== 模态框 ==================
-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');}
-}
-
-// ================== 初始化 ==================
-initCsrf(); // 获取 CSRF token(非 GET 请求需携带)
-loadMe(); // 获取当前用户权限,隐藏无权限的功能入口
-showTab('monitor');
diff --git a/static/admin/tasks.js b/static/admin/tasks.js
new file mode 100644
index 0000000..b5d09ff
--- /dev/null
+++ b/static/admin/tasks.js
@@ -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:'| 暂无任务,点击"新建任务"创建 |
'
+ });
+ 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=''+schedTxt+' | ';
+ // 下次运行时间(后端已按运行窗口跳过窗口外触发点)
+ const nextRun=fmtNextRun(j.next_run);
+ const nextOut=''+(nextRun||'-')+' | ';
+ const retry=j.retry.max_attempts>1?(j.retry.max_attempts+'次/'+j.retry.delay+'s'):'不重试';
+ const en=j.enabled?'启用':'停用';
+ return ''+
+ ''+esc(j.id)+' | '+
+ ''+esc(j.name)+' | '+
+ ''+esc(j.task_type)+' | '+
+ ''+en+' | '+
+ ''+esc(tgt)+(tgtDetail?'('+esc(tgtDetail)+')':'')+' | '+
+ schedOut+
+ nextOut+
+ ''+retry+' | '+
+ ''+
+ (_can('tasks')?(
+ ''+
+ ''+
+ ''+
+ ''
+ ):'只读')+
+ ' | '+
+ '
';
+}
+
+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=>'').join('');
+ const gl=_groupsList.map(g=>'').join('');
+ const dl=_devicesList.map(s=>'').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=>
+ '').join('');
+
+ document.getElementById('modal-body').innerHTML=
+ ''+
+ ''+
+ ''+
+ ''+
+ ''+
+ ''+
+ '窗口外任务不会启动(定时触发和手动执行都受约束)。跨午夜示例:21:00-09:00 = 晚 9 点到次日早 9 点允许运行
'+
+ ''+
+ ''+
+ ''+
+ '
步骤编辑器用步骤卡片上的 ☑ 选中多个步骤,可打包成自定义动作'+
+ (_can('tasks')?'':'')+
+ '
'+
+ '
'+
+ '
';
+
+ // 初始化步骤编辑器(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=
+ ''+
+ '';
+
+ 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:'| 暂无自定义动作,可点击"新建动作"或用任务编辑器☑选中步骤打包 |
'
+ });
+ applyListPager('actions');
+ initListSearch('actions','search-actions');
+ _stepEditor._renderLib(); // 刷新步骤编辑器左侧动作库
+}
+function renderActionRow(a){
+ const n=(a.steps||[]).length;
+ return ''+
+ '| '+esc(a.icon||'📦')+' | '+
+ ''+esc(a.name)+' | '+
+ ''+n+' | '+
+ ''+esc(a.created_at||'-')+' | '+
+ ''+
+ (_can('tasks')?(
+ ' '+
+ ''
+ ):'只读')+
+ ' | '+
+ '
';
+}
+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=
+ ''+
+ '用下方步骤编辑器编排动作内容,保存后可拖入任务步骤编辑器复用
'+
+ ''+
+ '
动作步骤支持循环/分组/概率触发/拖拽排序'+
+ (_can('tasks')?'':'')+'
'+
+ '
'+
+ '
';
+ document.getElementById('action-modal-footer').innerHTML=
+ ''+
+ '';
+ // 用独立画布初始化步骤编辑器(不与任务编辑器冲突)
+ _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');
+}
+
diff --git a/static/admin/tools.js b/static/admin/tools.js
new file mode 100644
index 0000000..65a3f18
--- /dev/null
+++ b/static/admin/tools.js
@@ -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='无可用设备(本地 adb 或 STF 池均无)
';
+ 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 '';
+ }).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:'| 无可用设备 |
'
+ });
+ 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 '| '+esc(x.serial)+' | '+
+ '查询失败 | '+esc(x.error)+' |
';
+ if(x.installed)return '| '+esc(x.serial)+' | '+
+ '已安装 | '+
+ ''+esc(x.version_name||'')+' | '+esc(x.version_code||'')+' |
';
+ return '| '+esc(x.serial)+' | '+
+ '未安装 | - | - |
';
+}
+
+// ================== 工具: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:'| 无设备(脚本未配置、220 adb 也无连接) |
'
+ });
+ applyListPager('stfdev');
+ initListSearch('stfdev','search-stfdev');
+ status.textContent = '脚本配置 '+configured.length+' 台,已连接 '+Object.keys(connected).length+' 台';
+}
+function renderStfDevRow(x){
+ const stHtml = x.state==='device'
+ ? '已连接'
+ : (x.state ? ''+esc(x.state)+'' : '未连接');
+ return '| '+esc(x.ip)+' | '+
+ ''+(x.inCfg?'脚本中':'未配置')+' | '+
+ ''+stHtml+' | '+
+ ' |
';
+}
+
+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])=>
+ '| '+esc(s)+' | '+(v.ok?'成功':'失败 '+esc(v.msg))+' |
').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 ;服务器类命令保持原样
+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=[''].concat(
+ list.map(d=>
+ ''
+ )
+ );
+ 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='| 加载中... |
';
+ const r=await apiGet('/api/tailscale/devices');
+ if(!r)return;
+ if(!r.ok){
+ tb.innerHTML='| '+esc(r.error||'获取失败')+' |
';
+ 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:'| tailnet 暂无设备 |
'
+ });
+ applyListPager('tailscale');
+ initListSearch('tailscale','search-tailscale');
+ if(st)st.textContent='共 '+_tsDevices.length+' 台设备';
+}
+function renderTsRow(d){
+ const ip=(d.addresses||[])[0]||'-';
+ return ''+
+ ''+esc(d.hostname||'-')+' | '+
+ ''+esc(d.name||'-')+' | '+
+ ''+esc(ip)+' | '+
+ ''+esc(d.os||'-')+' | '+
+ ''+((d.online===true)?'在线':(d.online===false?'离线':'最近在线'))+' | '+
+ ''+(d.authorized?'已授权':'未授权')+' | '+
+ ''+(d.key_expiry_disabled?'永不过期':'会过期')+' | '+
+ ''+
+ ' '+
+ ' '+
+ ' '+
+ ' '+
+ ''+
+ ' |
';
+}
+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=
+ '新设备执行 tailscale up --authkey=... 接入(默认已预授权)
'+
+ ''+
+ ''+
+ ' ';
+ 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='已复制';
+}
+
diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html
index 660a046..2f1e24c 100644
--- a/templates/admin/monitor.html
+++ b/templates/admin/monitor.html
@@ -715,6 +715,14 @@ select.form-control{cursor:pointer}
+
+
+
+
+
+
+
+