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= + '
'+ + '
'+ + '
'+ + '
'+(deviceCheckboxes||'
暂无在线设备
')+'
'+ + '
仅显示当前在线设备,已选设备即使离线也会保留
'+ + '
'; + + 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= + '
'+ + '
'+ + '
'+ + '
'+ + '
'+permBoxes+'
'+ + '
任务管理=任务/自定义动作/分组;设备控制=停止/释放/清异常/扫描/抓元素;应用管理=APK上传安装;日志查看=日志页
'+ + '
'; + + 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+''+itemRows+'
设备Serial状态详情
'; + + 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 '
'+ + '
'+ + ''+pick+test+ + '
'+(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 '
'+c[1]+'
'+list+'
'; + }).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+='
'; + 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+='
'; + h+='
'; + 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+='
'; + 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+='
0~100,<100 表示按概率执行(如 30=隔几次才触发一次,适合"偶尔点赞")
'; + // 可编辑 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?'
共 '+total+' 条
':''; + var h='
共 '+total+' 条'; + h+=''; + for(var p=Math.max(1,cur-2);p<=Math.min(pages,cur+2);p++){ + h+=''; + } + h+=''; + 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?'
共 '+total+' 条
':''; - var h='
共 '+total+' 条'; - h+=''; - for(var p=Math.max(1,cur-2);p<=Math.min(pages,cur+2);p++){ - h+=''; - } - h+=''; - 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 点允许运行
'+ - '
'+ - '
'+ - '
'+ - '
'+ - '
可留空使用默认参数。抖音任务可设 max_duration(秒,如 1800=30分钟)限制单设备运行时长
'+ - '
'+ - '
步骤编辑器用步骤卡片上的 ☑ 选中多个步骤,可打包成自定义动作'+ - (_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 '
'+ - '
'+ - ''+pick+test+ - '
'+(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 '
'+c[1]+'
'+list+'
'; - }).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+='
'; - 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+='
'; - h+='
'; - 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+='
'; - 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+='
0~100,<100 表示按概率执行(如 30=隔几次才触发一次,适合"偶尔点赞")
'; - // 可编辑 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= - '
'+ - '
'+ - '
'+ - '
'+(deviceCheckboxes||'
暂无在线设备
')+'
'+ - '
仅显示当前在线设备,已选设备即使离线也会保留
'+ - '
'; - - 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= - '
'+ - '
'+ - '
'+ - '
'+ - '
'+permBoxes+'
'+ - '
任务管理=任务/自定义动作/分组;设备控制=停止/释放/清异常/扫描/抓元素;应用管理=APK上传安装;日志查看=日志页
'+ - '
'; - - 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+''+itemRows+'
设备Serial状态详情
'; - - 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 点允许运行
'+ + '
'+ + '
'+ + '
'+ + '
'+ + '
可留空使用默认参数。抖音任务可设 max_duration(秒,如 1800=30分钟)限制单设备运行时长
'+ + '
'+ + '
步骤编辑器用步骤卡片上的 ☑ 选中多个步骤,可打包成自定义动作'+ + (_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}
+ + + + + + + + diff --git a/web_server.py b/web_server.py index 9b90839..9b5dace 100644 --- a/web_server.py +++ b/web_server.py @@ -32,7 +32,7 @@ from flask_login import (LoginManager, login_user, logout_user, from core.stf_client import STFClient from core.task_manager import TaskManager -from core.device_worker import clear_worker_error, clear_all_worker_errors +from core.device_worker import clear_worker_error, clear_all_worker_errors, get_all_worker_status from core.logger import get_logger, _LOG_DIR, _MODULE_FILES from core.models import db, init_db, User, DeviceGroup, TaskJob, CustomAction from core.apk_manager import ApkManager