4 Commits
Author SHA1 Message Date
butubb 473dc1909d feat: 前端整体增强(应用查看UI/元素选择/步骤编辑器/拖拽/概率/分页/动作管理)
- 应用Tab:设备已装应用区块(应用名+版本+versionCode+路径)、安装弹窗设备显示 IP·型号
- 元素抓取:截图按 bounds 命中测试选最小元素,跳过全屏容器 overlay
- 步骤编辑器:输入实时同步+localStorage草稿自动保存(防丢内容)
- 拖拽修复:画布 drop 只绑定一次+stopPropagation+延迟渲染,可拖入循环块且不重复
- 步骤概率触发字段、循环按轮次/时间切换、监控页预计结束时间
- 全部列表统一搜索+分页(设备/任务/应用/分组/用户/动作)
- 自定义动作管理模块(列表/新建/编辑/删除)、步骤编辑器容器化、嵌套循环移动修复
2026-08-08 16:31:06 +08:00
butubb fcbdb0a8a1 feat: 通用步骤支持概率触发、循环按时间、预计结束时间上报
- 步骤新增 probability 参数:<100 时按百分比概率决定是否执行(实现"偶尔点赞")
- loop 支持 loop_mode=rounds/time,按时间循环跑满 loop_duration 秒
- BaseWorker._start_timer 上报 end_time(开始+max_duration),task_manager 透出
2026-08-08 16:30:54 +08:00
butubb 4f29762121 feat: 设备已装应用查看(含应用名/版本号)
- adb_helper.list_installed_apps:pm list + pm dump 取包名/versionCode/versionName/APK路径
- 应用名解析:常见应用映射 + 小APK流式pyaxmlparser解析(并行+缓存)+ 包名兜底
- web 新增 GET /api/devices/<serial>/apps
2026-08-08 16:30:49 +08:00
butubb 3a38d9a57f fix: adb 命令超时 + STF 释放失败检查,杜绝设备卡死/占用悬空
- adb 所有命令加 30s 超时:connect 到不可达地址不再无限挂起(worker 不再卡 connecting)
- STF release 检查响应状态并重试:504 等失败不再被静默吞掉,释放失败会真实上报
- release_all_mine 返回 (released, failed);web /api/release 透出失败列表
2026-08-08 16:30:42 +08:00
7 changed files with 793 additions and 173 deletions
+151 -1
View File
@@ -14,6 +14,9 @@ from core.logger import get_logger
_log = get_logger("core.adb") _log = get_logger("core.adb")
_ADB_LOCK = threading.Lock() _ADB_LOCK = threading.Lock()
# adb 命令超时(秒)。adb connect 到不可达主机(如 Tailscale IP 不通)会长时间挂起,
# 没有超时会让 worker 永久卡在 connecting。connect/disconnect 正常都应瞬时完成。
_ADB_TIMEOUT = 30
def _adb(*args): def _adb(*args):
@@ -21,8 +24,13 @@ def _adb(*args):
用 bytes 模式自己解码(Windows 下 subprocess text=True 会用 GBK 读 adb 输出, 用 bytes 模式自己解码(Windows 下 subprocess text=True 会用 GBK 读 adb 输出,
遇到非 GBK 字节会崩溃)。 遇到非 GBK 字节会崩溃)。
带 30s 超时:adb connect 到不可达地址可能挂很久,超时返回空串由调用方处理。
""" """
r = subprocess.run([ADB_PATH, *args], capture_output=True) try:
r = subprocess.run([ADB_PATH, *args], capture_output=True, timeout=_ADB_TIMEOUT)
except subprocess.TimeoutExpired:
_log.warning(f"adb {' '.join(args)} 超时 {_ADB_TIMEOUT}s")
return ""
out = (r.stdout or b"") + (r.stderr or b"") out = (r.stdout or b"") + (r.stderr or b"")
try: try:
return out.decode("utf-8", errors="replace").strip() return out.decode("utf-8", errors="replace").strip()
@@ -146,6 +154,148 @@ def screenshot(serial, timeout=5):
return False, str(e) return False, str(e)
# 常见应用名映射:快速识别大/重要应用,避免拉取 APK 解析(慢)
COMMON_APPS = {
"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.settings": "设置",
"com.android.systemui": "系统",
"com.github.uiautomator": "ATX",
"com.github.uiautomator.test": "ATX测试",
"jp.co.cyberagent.stf": "STF服务",
"moe.shizuku.privileged.api": "Shizuku",
"moe.shizuku.api": "Shizuku",
"io.tailscale.android": "Tailscale",
"com.immomo.momo": "陌陌",
"com.xingin.xhs": "小红书",
}
# 解析应用名时,超过该大小的 APK 不拉取(太慢),用映射或包名兜底
_LABEL_PARSE_MAX_BYTES = 5 * 1024 * 1024 # 5MB
# 应用名缓存:(serial, package) -> label
_APP_LABEL_CACHE = {}
def list_installed_apps(serial, timeout=40):
"""获取设备上已安装应用列表:应用名 + 包名 + versionCode + versionName + APK路径。
- `pm list packages --show-versioncode -f` 取包名/版本码/路径(快)
- `pm dump packages` 取人性化 versionName(实测 ~0.8s)
- 应用名 label:常见应用走映射;用户安装且较小(<=10MB)的未知应用流式拉 APK 用
pyaxmlparser 解析(带缓存),其余用包名兜底。
返回 (ok, apps) 或 (False, 错误消息)。
"""
if not serial:
return False, "缺少 serial"
try:
# 1. 包名 + versionCode + APK 路径
r1 = subprocess.run(
[ADB_PATH, "-s", serial, "shell", "pm", "list", "packages",
"--show-versioncode", "-f"],
capture_output=True, timeout=timeout)
apps = {}
for line in (r1.stdout or b"").decode("utf-8", errors="replace").splitlines():
line = line.strip()
m = re.match(r'package:(.+)=(\S+) versionCode:(\d+)', line)
if m:
apps[m.group(2)] = {"package": m.group(2), "path": m.group(1),
"version_code": int(m.group(3)), "version_name": "",
"label": COMMON_APPS.get(m.group(2), "")}
else:
m2 = re.match(r'package:(.+)=(\S+)', line)
if m2:
apps[m2.group(2)] = {"package": m2.group(2), "path": m2.group(1),
"version_code": 0, "version_name": "",
"label": COMMON_APPS.get(m2.group(2), "")}
# 2. 人性化版本号 versionName
try:
r2 = subprocess.run(
[ADB_PATH, "-s", serial, "shell", "pm", "dump", "packages"],
capture_output=True, timeout=timeout)
out2 = (r2.stdout or b"").decode("utf-8", errors="replace")
for m in re.finditer(r'Package \[([^\]]+)\]', out2):
pkg = m.group(1)
if pkg not in apps:
continue
nxt = out2.find("Package [", m.end())
seg = out2[m.end():nxt if nxt != -1 else len(out2)]
vm = re.search(r'versionName=(\S+)', seg)
if vm:
apps[pkg]["version_name"] = vm.group(1)
except Exception:
pass # versionName 拿不到不阻塞
# 3. 应用名 label:用户安装且未映射的小 APK,流式拉取解析真实名
_resolve_app_labels(serial, apps, timeout)
return True, sorted(apps.values(), key=lambda a: a["package"])
except subprocess.TimeoutExpired:
return False, "查询超时"
except Exception as e:
return False, str(e)
def _resolve_app_labels(serial, apps, timeout):
"""并行解析用户安装小应用的显示名 label(带缓存)。失败/超时静默跳过。"""
import io as _io
from concurrent.futures import ThreadPoolExecutor
targets = [(pkg, a["path"]) for pkg, a in apps.items()
if a["path"].startswith("/data/") and not a["label"]
and (serial, pkg) not in _APP_LABEL_CACHE]
if not targets:
return
# 用 ls -l 一次拿所有候选 APK 大小,跳过过大的(流式拉大 APK 太慢)
sizes = {}
try:
rsz = subprocess.run(
[ADB_PATH, "-s", serial, "shell", "ls", "-l"] + [p for _, p in targets],
capture_output=True, timeout=min(timeout, 15))
for line in (rsz.stdout or b"").decode("utf-8", errors="replace").splitlines():
parts = line.split()
# ls -l(toybox): 权限 链接 属主 属组 大小 日期 时间 路径 = 8 字段
if len(parts) >= 6:
try:
sizes[parts[-1]] = int(parts[4])
except ValueError:
pass
except Exception:
pass
small = [(pkg, path) for pkg, path in targets if sizes.get(path, 0) <= _LABEL_PARSE_MAX_BYTES]
def _fetch(pkg_path):
pkg, path = pkg_path
label = ""
try:
rp = subprocess.run(
[ADB_PATH, "-s", serial, "exec-out", "cat", path],
capture_output=True, timeout=min(timeout, 8))
from pyaxmlparser import APK as _APKParser
label = (_APKParser(_io.BytesIO(rp.stdout)).get_app_name() or "").strip()
except Exception:
label = ""
_APP_LABEL_CACHE[(serial, pkg)] = label
return pkg, label
if small:
try:
with ThreadPoolExecutor(max_workers=8) as pool:
for pkg, label in pool.map(_fetch, small):
if pkg in apps:
apps[pkg]["label"] = label
except Exception:
pass # label 解析失败不影响主流程
def identify_device(serial, duration=3): def identify_device(serial, duration=3):
"""让设备响铃以供识别(直连 serial,不经过 STF)。 """让设备响铃以供识别(直连 serial,不经过 STF)。
+15 -8
View File
@@ -51,13 +51,17 @@ class STFDevice:
raise STFError(f"占用设备失败: {msg}", "conflict") raise STFError(f"占用设备失败: {msg}", "conflict")
_log.info(f"[{self.serial}] STF 占用成功") _log.info(f"[{self.serial}] STF 占用成功")
# 优先直连设备 serial(当 serial 本身就是 IP:5555 时,如 Tailscale 网络) # 直连优先:serial 本身是 IP:5555(如 Tailscale 网络设备)时直接 adb connect。
# 这比 STF remoteConnect 桥接更稳定,避免桥接地址认证失败 # 实测(本机 100.100.10.2,设备 100.100.10.x):
# - 本机在 tailnet 内时直连可靠且快(<1s),且只 connect、绝不 disconnect/kill-server,
# 不会影响 STF provider 共享的 adb 连接(STF 端设备保持 present/ready)。
# - STF remoteConnect 桥接在此环境反而有 adb key 认证问题(隧道显示 unauthorized,
# 即使设备已授权本机 key),直连才是可靠路径。
# 仅当 serial 不是 IP:port(如 USB 序列号)时才走 STF 桥接。
if ":" in self.serial: if ":" in self.serial:
self.remote_adb_url = self.serial self.remote_adb_url = self.serial
_log.info(f"[{self.serial}] 直连设备: {self.remote_adb_url}") _log.info(f"[{self.serial}] 直连设备: {self.remote_adb_url}")
else: else:
# serial 不是 IP:port 格式,走 STF remoteConnect 桥接
self.remote_adb_url = self.stf.remote_connect(self.serial) self.remote_adb_url = self.stf.remote_connect(self.serial)
_log.info(f"[{self.serial}] STF 桥接: {self.remote_adb_url}") _log.info(f"[{self.serial}] STF 桥接: {self.remote_adb_url}")
@@ -75,18 +79,18 @@ class STFDevice:
if not self.serial: if not self.serial:
return return
# 直连模式(remote_adb_url == serial):不 disconnect # 直连模式(remote_adb_url == serial):不 disconnect
# STF agent 可能共享同一个 adb 连接,disconnect 会导致 STF 设备离线后重新连接 # STF provider 共享该 IP:5555 的 adb transport,disconnect 会让 STF 误判离线并重连
if self.remote_adb_url and self.remote_adb_url != self.serial: if self.remote_adb_url and self.remote_adb_url != self.serial:
adb_disconnect(self.remote_adb_url) adb_disconnect(self.remote_adb_url)
try: try:
self.stf.remote_disconnect(self.serial) self.stf.remote_disconnect(self.serial)
except Exception: except Exception:
pass pass
try: ok, msg = self.stf.release(self.serial)
self.stf.release(self.serial) if ok:
_log.info(f"[{self.serial}] 已释放") _log.info(f"[{self.serial}] 已释放")
except Exception as e: else:
_log.warning(f"[{self.serial}] 释放失败: {e}") _log.warning(f"[{self.serial}] STF 释放失败: {msg}")
# ================== 全局 worker 状态注册表(供 web_server 读取) ================== # ================== 全局 worker 状态注册表(供 web_server 读取) ==================
@@ -242,6 +246,9 @@ class BaseWorker(threading.Thread):
def _start_timer(self): def _start_timer(self):
"""子类在 run_task 开头调用,启动运行时长计时。""" """子类在 run_task 开头调用,启动运行时长计时。"""
self._start_time = time.time() self._start_time = time.time()
if self.max_duration > 0:
# 预计结束时间(前端监控页展示),到点自动停止
_update_status(self.serial, end_time=self._start_time + self.max_duration)
def is_time_up(self): def is_time_up(self):
"""是否已达最大运行时长。max_duration=0 时永远返回 False。""" """是否已达最大运行时长。max_duration=0 时永远返回 False。"""
+29 -11
View File
@@ -120,27 +120,45 @@ class STFClient:
return False, str(e) return False, str(e)
return False, "占用冲突重试耗尽" return False, "占用冲突重试耗尽"
def release(self, serial): def release(self, serial, retries=3):
"""释放设备占用(含远程断开)。""" """释放设备占用。返回 (ok, msg)。
STF 释放可能因设备响应超时返回 504(如设备过载/卡死),此时设备实际未释放。
必须检查响应状态并重试,不能静默吞掉——否则占用会一直悬着。
"""
self.remote_disconnect(serial) self.remote_disconnect(serial)
last_status = None
last_body = ""
for attempt in range(1, retries + 1):
try: try:
requests.delete(f"{self.base_url}/api/v1/user/devices/{serial}", r = requests.delete(f"{self.base_url}/api/v1/user/devices/{serial}",
headers=self.headers, timeout=_TIMEOUT) headers=self.headers, timeout=_TIMEOUT)
if r.status_code in (200, 201, 202, 204):
return True, "released"
last_status = r.status_code
last_body = r.text[:150]
_log.warning(f"释放 {serial} 失败 HTTP {r.status_code}: {last_body} ({attempt}/{retries})")
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
_log.warning(f"释放 {serial} 请求失败: {e}") _log.warning(f"释放 {serial} 请求异常 ({attempt}/{retries}): {e}")
last_status = -1
last_body = str(e)
if attempt < retries:
time.sleep(2)
return False, f"STF 释放失败(HTTP {last_status}): {last_body}"
def release_all_mine(self): def release_all_mine(self):
"""释放当前账户占用的所有设备(清理用)。""" """释放当前账户占用的所有设备(清理用)。返回 (released, failed)。"""
released = [] released, failed = [], []
for d in self.list_my_devices(): for d in self.list_my_devices():
serial = d["serial"] serial = d["serial"]
try: ok, msg = self.release(serial)
self.release(serial) if ok:
released.append(serial) released.append(serial)
_log.info(f"已释放 {serial}") _log.info(f"已释放 {serial}")
except Exception as e: else:
_log.error(f"释放 {serial} 失败: {e}") failed.append(serial)
return released _log.error(f"释放 {serial} 失败: {msg}")
return released, failed
# ================== 远程 ADB 隧道 ================== # ================== 远程 ADB 隧道 ==================
def remote_connect(self, serial): def remote_connect(self, serial):
+1
View File
@@ -735,6 +735,7 @@ class TaskManager:
"running_job": r.get("job_id", ""), "running_job": r.get("job_id", ""),
"task_job": w.get("task_job", ""), "task_job": w.get("task_job", ""),
"attempt": r.get("attempt", 0), "attempt": r.get("attempt", 0),
"end_time": w.get("end_time", 0),
}) })
return result return result
+24 -2
View File
@@ -21,6 +21,7 @@ worker 按 steps 顺序执行,支持 loop 步骤循环、停止信号、进度
group - 动作组(含 children 步骤列表,按序执行一次,可折叠复用) group - 动作组(含 children 步骤列表,按序执行一次,可折叠复用)
""" """
import random import random
import time
from tasks.base import BaseTask, register_task from tasks.base import BaseTask, register_task
from core.device_worker import BaseWorker, _update_status from core.device_worker import BaseWorker, _update_status
@@ -43,7 +44,7 @@ STEP_TYPES = [
{"type": "wait", "label": "等待", "icon": "⏱", {"type": "wait", "label": "等待", "icon": "⏱",
"params": {"min": 1.0, "max": 3.0}}, "params": {"min": 1.0, "max": 3.0}},
{"type": "loop", "label": "循环块", "icon": "↻", {"type": "loop", "label": "循环块", "icon": "↻",
"params": {"max_iterations": 10, "children": []}}, "params": {"loop_mode": "rounds", "max_iterations": 10, "loop_duration": 600, "children": []}},
{"type": "group", "label": "动作组", "icon": "📦", {"type": "group", "label": "动作组", "icon": "📦",
"params": {"children": []}}, "params": {"children": []}},
] ]
@@ -112,6 +113,11 @@ class GenericStepsWorker(BaseWorker):
stype = step.get("type", "") stype = step.get("type", "")
label = step.get("label", stype) label = step.get("label", stype)
params = step.get("params", {}) params = step.get("params", {})
# 概率触发:probability=100 必执行,<100 按百分比概率决定本次是否执行
prob = float(params.get("probability", 100))
if prob < 100 and random.random() * 100 > prob:
_log.info(f"[{self.serial}] 步骤 '{label}' 概率 {prob}% 未触发,跳过")
return
self.set_action(f"执行: {label}") self.set_action(f"执行: {label}")
_log.info(f"[{self.serial}] 步骤: {label}({stype}) params={params}") _log.info(f"[{self.serial}] 步骤: {label}({stype}) params={params}")
@@ -196,10 +202,26 @@ class GenericStepsWorker(BaseWorker):
random_sleep(min_s, max_s) random_sleep(min_s, max_s)
def _exec_loop(self, d, params, depth=0): def _exec_loop(self, d, params, depth=0):
max_iter = int(params.get("max_iterations", 10))
children = params.get("children", []) children = params.get("children", [])
if not children: if not children:
return return
mode = params.get("loop_mode", "rounds")
if mode == "time":
# 按时间循环:跑满 loop_duration 秒,同时受全局 stopped/max_duration 约束
duration = float(params.get("loop_duration", 0) or 0)
if duration <= 0:
_log.warning(f"[{self.serial}] 按时间循环未设置时长,跳过")
return
start = time.time()
i = 0
while not self.stopped() and not self.is_time_up() and (time.time() - start) < duration:
i += 1
self.set_action(f"循环第 {i} 轮(按时间)")
_log.info(f"[{self.serial}] loop 第 {i} 轮(按时间, 已{(time.time()-start):.0f}s/{duration:.0f}s)")
self._exec_steps(d, children, depth + 1)
else:
# 按轮次循环
max_iter = int(params.get("max_iterations", 10))
for i in range(max_iter): for i in range(max_iter):
if self.stopped() or self.is_time_up(): if self.stopped() or self.is_time_up():
return return
+478 -66
View File
@@ -54,6 +54,11 @@ body{background:var(--bg);font-family:-apple-system,"Segoe UI",Roboto,"PingFang
.btn-sm{padding:4px 12px;font-size:12px} .btn-sm{padding:4px 12px;font-size:12px}
.btn-xs{padding:3px 10px;font-size:12px} .btn-xs{padding:3px 10px;font-size:12px}
/* 通用列表分页栏 */
.list-pager{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin:8px 0 20px}
.list-pager .lp-total{font-size:12px;color:var(--text-light);margin-right:auto}
.list-search{width:240px}
/* 区块标题 */ /* 区块标题 */
.section-title{font-size:15px;font-weight:600;color:var(--text);margin:0 0 12px;padding-bottom:8px;border-bottom:2px solid var(--primary);display:inline-block} .section-title{font-size:15px;font-weight:600;color:var(--text);margin:0 0 12px;padding-bottom:8px;border-bottom:2px solid var(--primary);display:inline-block}
@@ -202,6 +207,8 @@ select.form-control{cursor:pointer}
.step-card.group-block .sc-ico{background:#8b5cf6} .step-card.group-block .sc-ico{background:#8b5cf6}
.step-card.selected{border-color:var(--primary);box-shadow:0 0 0 2px rgba(59,130,246,.2)} .step-card.selected{border-color:var(--primary);box-shadow:0 0 0 2px rgba(59,130,246,.2)}
.step-children{margin-left:20px;margin-top:6px;border-left:2px dashed var(--border);padding-left:8px} .step-children{margin-left:20px;margin-top:6px;border-left:2px dashed var(--border);padding-left:8px}
.step-children.drag-over{border-left-color:var(--primary);background:rgba(59,130,246,.06);border-radius:6px}
#se-canvas.drag-over{outline:2px dashed var(--primary);outline-offset:2px;border-radius:8px}
.sel-picker{display:flex;gap:4px;align-items:center} .sel-picker{display:flex;gap:4px;align-items:center}
.sel-picker input{flex:1} .sel-picker input{flex:1}
.sel-picker .btn-pick{padding:5px 8px;font-size:11px;white-space:nowrap} .sel-picker .btn-pick{padding:5px 8px;font-size:11px;white-space:nowrap}
@@ -287,6 +294,7 @@ select.form-control{cursor:pointer}
<button class="btn" onclick="apiAction('/api/release')">强制释放占用</button> <button class="btn" onclick="apiAction('/api/release')">强制释放占用</button>
<button class="btn btn-primary" id="btn-scan-fg" onclick="scanForeground()">扫描前台App</button> <button class="btn btn-primary" id="btn-scan-fg" onclick="scanForeground()">扫描前台App</button>
<button class="btn" onclick="loadMonitor()">刷新</button> <button class="btn" onclick="loadMonitor()">刷新</button>
<input class="form-control list-search" id="search-devices" placeholder="搜索设备/型号/App/任务...">
<span class="hint"><span id="fg-status"></span> · 每 5 秒自动刷新 · <span id="srvTime"></span> · <span id="sel-count">未选中</span></span> <span class="hint"><span id="fg-status"></span> · 每 5 秒自动刷新 · <span id="srvTime"></span> · <span id="sel-count">未选中</span></span>
</div> </div>
<table class="table"> <table class="table">
@@ -305,6 +313,7 @@ select.form-control{cursor:pointer}
</tr></thead> </tr></thead>
<tbody id="tb-devices"></tbody> <tbody id="tb-devices"></tbody>
</table> </table>
<div class="list-pager" id="pager-devices"></div>
<div class="section-title" style="margin-top:8px">任务运行概况</div> <div class="section-title" style="margin-top:8px">任务运行概况</div>
<div id="jobs-grid" class="jobs-grid"></div> <div id="jobs-grid" class="jobs-grid"></div>
@@ -317,6 +326,7 @@ select.form-control{cursor:pointer}
<div class="toolbar"> <div class="toolbar">
<button class="btn btn-primary" onclick="openTaskModal()">新建任务</button> <button class="btn btn-primary" onclick="openTaskModal()">新建任务</button>
<button class="btn" onclick="loadTasks()">刷新</button> <button class="btn" onclick="loadTasks()">刷新</button>
<input class="form-control list-search" id="search-tasks" placeholder="搜索任务名/类型/ID...">
</div> </div>
<table class="table"> <table class="table">
<thead><tr> <thead><tr>
@@ -324,6 +334,23 @@ select.form-control{cursor:pointer}
</tr></thead> </tr></thead>
<tbody id="tb-tasks"></tbody> <tbody id="tb-tasks"></tbody>
</table> </table>
<div class="list-pager" id="pager-tasks"></div>
<!-- 自定义动作管理 -->
<div style="margin-top:28px">
<div class="page-title" style="font-size:16px">自定义动作管理</div>
<div class="page-sub">把常用步骤打包成可复用的动作,拖入任务步骤编辑器使用;也可在任务编辑器里"☑选中步骤→打包"生成</div>
<div class="toolbar">
<button class="btn btn-primary" onclick="openActionModal()">新建动作</button>
<button class="btn" onclick="loadCustomActions()">刷新</button>
<input class="form-control list-search" id="search-actions" placeholder="搜索动作名...">
</div>
<table class="table">
<thead><tr><th>图标</th><th>动作名</th><th>步骤数</th><th>创建时间</th><th>操作</th></tr></thead>
<tbody id="tb-actions"></tbody>
</table>
<div class="list-pager" id="pager-actions"></div>
</div>
</div> </div>
<!-- ========== Tab: 应用 ========== --> <!-- ========== Tab: 应用 ========== -->
@@ -336,6 +363,7 @@ select.form-control{cursor:pointer}
<input type="file" accept=".apk" style="display:none" onchange="uploadApk(this)"> <input type="file" accept=".apk" style="display:none" onchange="uploadApk(this)">
</label> </label>
<button class="btn" onclick="loadApks()">刷新</button> <button class="btn" onclick="loadApks()">刷新</button>
<input class="form-control list-search" id="search-apks" placeholder="搜索应用名/包名...">
<span class="hint" id="apk-hint"></span> <span class="hint" id="apk-hint"></span>
</div> </div>
<table class="table"> <table class="table">
@@ -344,6 +372,32 @@ select.form-control{cursor:pointer}
</tr></thead> </tr></thead>
<tbody id="tb-apks"></tbody> <tbody id="tb-apks"></tbody>
</table> </table>
<div class="list-pager" id="pager-apks"></div>
<!-- 设备已装应用 -->
<div style="margin-top:28px">
<div class="page-title" style="font-size:16px">设备已装应用</div>
<div class="page-sub">查看某台设备上安装的所有应用(含版本号)</div>
<div class="toolbar">
<select id="apps-device" class="form-control" style="width:280px;display:inline-block">
<option value="">选择设备...</option>
</select>
<button class="btn btn-primary" onclick="loadDeviceApps()">查看应用</button>
<input class="form-control list-search" id="search-devapps" placeholder="搜索包名/路径...">
<span class="hint" id="apps-hint"></span>
</div>
<div style="max-height:420px;overflow:auto;border:1px solid var(--border);border-radius:8px">
<table class="table" style="margin:0">
<thead><tr>
<th>应用</th><th>版本号</th><th>versionCode</th><th>APK 路径</th>
</tr></thead>
<tbody id="tb-device-apps">
<tr><td colspan="4" class="empty">选择设备后点击"查看应用"</td></tr>
</tbody>
</table>
</div>
<div class="list-pager" id="pager-devapps"></div>
</div>
</div> </div>
<!-- ========== Tab 3: 分组 ========== --> <!-- ========== Tab 3: 分组 ========== -->
@@ -353,6 +407,7 @@ select.form-control{cursor:pointer}
<div class="toolbar"> <div class="toolbar">
<button class="btn btn-primary" onclick="openGroupModal()">新建分组</button> <button class="btn btn-primary" onclick="openGroupModal()">新建分组</button>
<button class="btn" onclick="loadGroups()">刷新</button> <button class="btn" onclick="loadGroups()">刷新</button>
<input class="form-control list-search" id="search-groups" placeholder="搜索分组名/描述...">
</div> </div>
<table class="table"> <table class="table">
<thead><tr> <thead><tr>
@@ -360,6 +415,7 @@ select.form-control{cursor:pointer}
</tr></thead> </tr></thead>
<tbody id="tb-groups"></tbody> <tbody id="tb-groups"></tbody>
</table> </table>
<div class="list-pager" id="pager-groups"></div>
</div> </div>
<!-- ========== Tab 4: 日志 ========== --> <!-- ========== Tab 4: 日志 ========== -->
@@ -393,6 +449,7 @@ select.form-control{cursor:pointer}
<div class="toolbar"> <div class="toolbar">
<button class="btn btn-primary" onclick="openUserModal()">新建用户</button> <button class="btn btn-primary" onclick="openUserModal()">新建用户</button>
<button class="btn" onclick="loadUsers()">刷新</button> <button class="btn" onclick="loadUsers()">刷新</button>
<input class="form-control list-search" id="search-users" placeholder="搜索用户名...">
</div> </div>
<table class="table"> <table class="table">
<thead><tr> <thead><tr>
@@ -400,6 +457,7 @@ select.form-control{cursor:pointer}
</tr></thead> </tr></thead>
<tbody id="tb-users"></tbody> <tbody id="tb-users"></tbody>
</table> </table>
<div class="list-pager" id="pager-users"></div>
</div> </div>
</div><!-- /.content --> </div><!-- /.content -->
@@ -416,6 +474,18 @@ select.form-control{cursor:pointer}
</div> </div>
</div> </div>
<!-- ========== 自定义动作编辑模态框 ========== -->
<div id="action-modal-overlay" class="modal-overlay" onclick="if(event.target===this)closeActionModal()">
<div class="modal" id="action-modal-box" style="max-width:1000px">
<div class="modal-header">
<h3 id="action-modal-title">新建动作</h3>
<button class="close" onclick="closeActionModal()">&times;</button>
</div>
<div class="modal-body" id="action-modal-body" style="max-height:78vh;overflow:auto"></div>
<div class="modal-footer" id="action-modal-footer"></div>
</div>
</div>
<!-- ========== 元素抓取模态框(独立第二层,不覆盖任务编辑窗口)========== --> <!-- ========== 元素抓取模态框(独立第二层,不覆盖任务编辑窗口)========== -->
<div id="el-picker-overlay" class="modal-overlay" style="z-index:1100" onclick="if(event.target===this)closeElPicker()"> <div id="el-picker-overlay" class="modal-overlay" style="z-index:1100" onclick="if(event.target===this)closeElPicker()">
<div class="modal" id="el-picker-box" style="max-width:900px"> <div class="modal" id="el-picker-box" style="max-width:900px">
@@ -671,13 +741,68 @@ function showTab(name){
if(_logTimer){clearInterval(_logTimer);_logTimer=null;} if(_logTimer){clearInterval(_logTimer);_logTimer=null;}
// 按需加载数据 // 按需加载数据
if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);} if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);}
if(name==='tasks')loadTasks(); if(name==='tasks'){loadTasks();loadCustomActions();}
if(name==='apps')loadApks(); if(name==='apps')loadApks();
if(name==='groups')loadGroups(); if(name==='groups')loadGroups();
if(name==='logs'){loadLogs();if(document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);} if(name==='logs'){loadLogs();if(document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);}
if(name==='users')loadUsers(); if(name==='users')loadUsers();
} }
// ================== 通用列表:搜索 + 分页 ==================
// 用法:
// setListPager(key,{data:()=>array, filter:(item,kw)=>bool, render:(item)=>html,
// tbody:'tb-id', pager:'pager-id', pageSize:N});
// applyListPager(key); // 数据变化后重渲染(含搜索/翻页状态)
// initListSearch(key,'search-id');// 搜索框绑定
var _LIST_PAGERS={};
function _lp(key){ return _LIST_PAGERS[key] || (_LIST_PAGERS[key]={page:1,kw:'',cfg:null}); }
function setListPager(key,cfg){
// 只更新配置,不重置 page/kw —— 监控列表 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 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;
var pageSize=cfg.pageSize||10;
var pages=Math.max(1,Math.ceil(filtered.length/pageSize));
if(st.page>pages)st.page=pages;
if(st.page<1)st.page=1;
var start=(st.page-1)*pageSize;
var slice=filtered.slice(start,start+pageSize);
var tb=document.getElementById(cfg.tbody);
if(tb)tb.innerHTML=slice.map(cfg.render).join('')||(cfg.empty||'<tr><td colspan="20" class="empty">无数据</td></tr>');
var pg=document.getElementById(cfg.pager);
if(pg)pg.innerHTML=_lpPagerHtml(key,pages,st.page,filtered.length,pageSize);
}
function _lpPagerHtml(key,pages,cur,total,pageSize){
if(pages<=1)return total>0?'<div class="list-pager"><span class="lp-total">共 '+total+' 条</span></div>':'';
var h='<div class="list-pager"><span class="lp-total">共 '+total+' 条</span>';
h+='<button class="btn btn-xs" onclick="_lpGo(\''+key+'\','+(cur-1)+')"'+(cur<=1?' disabled':'')+'>‹ 上一页</button>';
for(var p=Math.max(1,cur-2);p<=Math.min(pages,cur+2);p++){
h+='<button class="btn btn-xs'+(p===cur?' btn-primary':'')+'" onclick="_lpGo(\''+key+'\','+p+')">'+p+'</button>';
}
h+='<button class="btn btn-xs" onclick="_lpGo(\''+key+'\','+(cur+1)+')"'+(cur>=pages?' disabled':'')+'>下一页 ›</button>';
h+='</div>';
return h;
}
function _lpGo(key,p){
var st=_lp(key); st.page=p; applyListPager(key);
}
// ================== Tab 1: 监控 ================== // ================== Tab 1: 监控 ==================
let _statusData=null; let _statusData=null;
let _jobsData=null; let _jobsData=null;
@@ -732,7 +857,8 @@ async function loadMonitor(){
}catch(e){console.error(e);} }catch(e){console.error(e);}
} }
// 渲染设备表格(带排序,复选状态保持) // 渲染设备表格(带排序+搜索+分页,复选状态保持)
let _sortedDevices=[];
function renderDevices(){ function renderDevices(){
if(!_statusData||!_statusData.devices){return;} if(!_statusData||!_statusData.devices){return;}
let devices=_statusData.devices.slice(); let devices=_statusData.devices.slice();
@@ -748,7 +874,18 @@ function renderDevices(){
return (va-vb)*dir; return (va-vb)*dir;
}); });
} }
const rows=devices.map(dev=>{ _sortedDevices=devices;
setListPager('devices',{
data:()=>_sortedDevices,
filter:(dev,kw)=>((dev.serial||'').toLowerCase().includes(kw)||(dev.model||'').toLowerCase().includes(kw)||(dev.foreground_app||'').toLowerCase().includes(kw)||(dev.task_job||'').toLowerCase().includes(kw)||(dev.current_action||'').toLowerCase().includes(kw)),
render:renderDeviceRow,
tbody:'tb-devices',pager:'pager-devices',pageSize:10,
empty:'<tr><td colspan="11" class="empty">暂无设备</td></tr>'
});
applyListPager('devices');
initListSearch('devices','search-devices');
}
function renderDeviceRow(dev){
const stf=dev.stf_occupied?'<span class="label label-success">占用</span>':'<span class="label label-default">空闲</span>'; const stf=dev.stf_occupied?'<span class="label label-success">占用</span>':'<span class="label label-default">空闲</span>';
const offline=dev.present?'':' <span class="label label-danger">离线</span>'; const offline=dev.present?'':' <span class="label label-danger">离线</span>';
const st=STATUS_MAP[dev.worker_status]||{t:dev.worker_status,c:'default'}; const st=STATUS_MAP[dev.worker_status]||{t:dev.worker_status,c:'default'};
@@ -762,6 +899,8 @@ function renderDevices(){
const prog=fmtProgress(dev.progress, dev.worker_status); const prog=fmtProgress(dev.progress, dev.worker_status);
const tname=dev.task_job?'<span class="label label-info">'+esc(dev.task_job)+'</span>':'<span class="text-muted">-</span>'; const tname=dev.task_job?'<span class="label label-info">'+esc(dev.task_job)+'</span>':'<span class="text-muted">-</span>';
const retry=dev.attempt>0?' <span class="text-muted">'+dev.attempt+'次</span>':''; const retry=dev.attempt>0?' <span class="text-muted">'+dev.attempt+'次</span>':'';
// 预计结束时间(设置了运行时长上限时显示)
const endInfo=(dev.worker_status==='running'&&dev.end_time)?'<div style="font-size:11px;color:#94a3b8">预计结束 '+new Date(dev.end_time*1000).toLocaleTimeString()+'</div>':'';
const stopBtn=(dev.worker_status==='running'||dev.worker_status==='connecting')?'<button class="btn btn-danger btn-xs" onclick="apiAction(\'/api/stop_device\',{serial:\''+esc(dev.serial)+'\'})">停止</button>':''; const stopBtn=(dev.worker_status==='running'||dev.worker_status==='connecting')?'<button class="btn btn-danger btn-xs" onclick="apiAction(\'/api/stop_device\',{serial:\''+esc(dev.serial)+'\'})">停止</button>':'';
// 截图按钮(任何状态都可看,离线时会报错) // 截图按钮(任何状态都可看,离线时会报错)
const shotBtn='<button class="btn btn-xs" onclick="openScreenshot(\''+esc(dev.serial)+'\')" title="查看设备画面(不影响任务)">截图</button>'; const shotBtn='<button class="btn btn-xs" onclick="openScreenshot(\''+esc(dev.serial)+'\')" title="查看设备画面(不影响任务)">截图</button>';
@@ -772,9 +911,7 @@ function renderDevices(){
const checked=_selectedSerials.has(dev.serial)?'checked':''; const checked=_selectedSerials.has(dev.serial)?'checked':'';
const rowCls=_selectedSerials.has(dev.serial)?'row-checked':''; const rowCls=_selectedSerials.has(dev.serial)?'row-checked':'';
const cb='<input type="checkbox" '+checked+' onclick="toggleDevice(\''+esc(dev.serial)+'\',this.checked)">'; const cb='<input type="checkbox" '+checked+' onclick="toggleDevice(\''+esc(dev.serial)+'\',this.checked)">';
return '<tr class="'+rowCls+'"><td class="col-cb">'+cb+'</td><td>'+devLabel+'</td><td>'+esc(dev.model)+'</td><td>'+stf+offline+'</td><td>'+wb+'</td><td>'+fg+'</td><td>'+prog+'</td><td>'+tname+retry+'</td><td class="text-muted">'+esc(dev.current_action||'-')+'</td><td class="err-text" title="'+esc(dev.last_error||'')+'">'+esc(dev.last_error||'-')+'</td><td>'+btns+'</td></tr>'; return '<tr class="'+rowCls+'"><td class="col-cb">'+cb+'</td><td>'+devLabel+'</td><td>'+esc(dev.model)+'</td><td>'+stf+offline+'</td><td>'+wb+'</td><td>'+fg+'</td><td>'+prog+'</td><td>'+tname+retry+endInfo+'</td><td class="text-muted">'+esc(dev.current_action||'-')+'</td><td class="err-text" title="'+esc(dev.last_error||'')+'">'+esc(dev.last_error||'-')+'</td><td>'+btns+'</td></tr>';
});
document.getElementById('tb-devices').innerHTML=rows.length?rows.join(''):'<tr><td colspan="11" class="empty">暂无设备</td></tr>';
} }
async function loadJobsForMonitor(){ async function loadJobsForMonitor(){
@@ -899,7 +1036,17 @@ async function loadTasks(){
function renderTaskTable(){ function renderTaskTable(){
const jobs=_jobsData||[]; const jobs=_jobsData||[];
document.getElementById('tb-tasks').innerHTML=jobs.length?jobs.map(j=>{ setListPager('tasks',{
data:()=>jobs,
filter:(j,kw)=>((j.name||'').toLowerCase().includes(kw)||(j.task_type||'').toLowerCase().includes(kw)||(j.id||'').toLowerCase().includes(kw)),
render:renderTaskRow,
tbody:'tb-tasks',pager:'pager-tasks',pageSize:10,
empty:'<tr><td colspan="8" class="empty">暂无任务,点击"新建任务"创建</td></tr>'
});
applyListPager('tasks');
initListSearch('tasks','search-tasks');
}
function renderTaskRow(j){
const tgt=TARGET_MAP[j.target.mode]||j.target.mode; 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 tgtDetail=j.target.mode==='group'?(j.target.group_name||''):(j.target.mode==='serial'?(j.target.serial||''):'');
const sched=j.schedule.mode==='cron_stop'?('启停 '+esc(j.schedule.cron||'')+'→'+esc(j.schedule.stop_cron||'')): const sched=j.schedule.mode==='cron_stop'?('启停 '+esc(j.schedule.cron||'')+'→'+esc(j.schedule.stop_cron||'')):
@@ -921,7 +1068,6 @@ function renderTaskTable(){
'<button class="btn btn-danger btn-xs" onclick="deleteTask(\''+j.id+'\')">删除</button>'+ '<button class="btn btn-danger btn-xs" onclick="deleteTask(\''+j.id+'\')">删除</button>'+
'</td>'+ '</td>'+
'</tr>'; '</tr>';
}).join(''):'<tr><td colspan="8" class="empty">暂无任务,点击"新建任务"创建</td></tr>';
} }
function openTaskModal(jobId){ function openTaskModal(jobId){
@@ -982,8 +1128,8 @@ function openTaskModal(jobId){
'</div>'+ '</div>'+
'</div>'; '</div>';
// 初始化步骤编辑器(generic_steps 时显示,其他隐藏) // 初始化步骤编辑器(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); _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= document.getElementById('modal-footer').innerHTML=
'<button class="btn" onclick="closeModal()">取消</button>'+ '<button class="btn" onclick="closeModal()">取消</button>'+
@@ -1021,7 +1167,7 @@ var STEP_LIB=[
{type:'click',label:'点击元素',icon:'✦',params:{selector_type:'xpath',selector_value:'',wait_timeout:2}}, {type:'click',label:'点击元素',icon:'✦',params:{selector_type:'xpath',selector_value:'',wait_timeout:2}},
{type:'input_text',label:'输入文字',icon:'⌨',params:{mode:'random',texts:'你好\n有趣\n支持',fixed_text:''}}, {type:'input_text',label:'输入文字',icon:'⌨',params:{mode:'random',texts:'你好\n有趣\n支持',fixed_text:''}},
{type:'wait',label:'等待',icon:'⏱',params:{min:1.0,max:3.0}}, {type:'wait',label:'等待',icon:'⏱',params:{min:1.0,max:3.0}},
{type:'loop',label:'循环块',icon:'↻',params:{max_iterations:10,children:[]}}, {type:'loop',label:'循环块',icon:'↻',params:{loop_mode:'rounds',max_iterations:10,loop_duration:600,children:[]}},
{type:'group',label:'动作组',icon:'📦',params:{children:[]}} {type:'group',label:'动作组',icon:'📦',params:{children:[]}}
]; ];
var _customActions=[]; // 后端加载的自定义动作 var _customActions=[]; // 后端加载的自定义动作
@@ -1041,15 +1187,39 @@ var _stepEditor={
_steps:[], _steps:[],
_selected:[], _selected:[],
_dragSuppressClick:false, _dragSuppressClick:false,
init:function(steps,maxDuration){ init:function(steps,maxDuration,draftKey,canvasId,libId){
this._steps=steps.length?JSON.parse(JSON.stringify(steps)):[]; this._draftKey=draftKey||'new';
this._selected=[]; this._selected=[];
this._dragSuppressClick=false; 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._ensureIds(this._steps);
this._renderLib(); this._renderLib();
this._renderCanvas(); this._renderCanvas();
_loadCustomActions(); _loadCustomActions();
// max_duration 输入框(放在 se-head 旁边) // 实时同步:画布内任何输入(含下拉/勾选)都同步回 _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'); var head=document.querySelector('#step-editor .se-head');
if(head&&!document.getElementById('f-max_duration')){ if(head&&!document.getElementById('f-max_duration')){
var md=document.createElement('span'); var md=document.createElement('span');
@@ -1068,7 +1238,8 @@ var _stepEditor={
}); });
}, },
_renderLib:function(){ _renderLib:function(){
var lib=document.getElementById('se-lib'); var lib=this._lib;
if(!lib)return; // 未初始化编辑器(如仅加载动作列表时)跳过
var items=STEP_LIB.map(function(s){ var items=STEP_LIB.map(function(s){
return '<div class="lib-item" draggable="true" data-type="'+s.type+'">'+ return '<div class="lib-item" draggable="true" data-type="'+s.type+'">'+
'<span class="ico">'+s.icon+'</span><span>'+s.label+'</span></div>'; '<span class="ico">'+s.icon+'</span><span>'+s.label+'</span></div>';
@@ -1115,12 +1286,14 @@ var _stepEditor={
}); });
}, },
_renderCanvas:function(){ _renderCanvas:function(){
this._renderStepList(document.getElementById('se-canvas'),this._steps); if(!this._canvas)return;
this._renderStepList(this._canvas,this._steps);
}, },
_renderStepList:function(container,steps){ _renderStepList:function(container,steps){
var self=this; var self=this;
if(!steps.length){ if(!steps.length){
container.innerHTML='<div class="se-empty">拖拽左侧操作到这里<div class="hint">或点击操作库直接添加</div></div>'; container.innerHTML='<div class="se-empty">拖拽左侧操作到这里<div class="hint">或点击操作库直接添加</div></div>';
this._bindDropZone(container,steps); // 空时也要能接收拖入
return; return;
} }
container.innerHTML=''; container.innerHTML='';
@@ -1148,10 +1321,10 @@ var _stepEditor={
'<span class="sc-actions">'+ '<span class="sc-actions">'+
'<button title="选中/取消选中(可多选后打包)" onclick="event.stopPropagation();_stepEditor.toggleSelect(\''+(step.id||'')+'\')">☑</button>'+ '<button title="选中/取消选中(可多选后打包)" onclick="event.stopPropagation();_stepEditor.toggleSelect(\''+(step.id||'')+'\')">☑</button>'+
'<button title="展开/收起参数" onclick="_stepEditor.toggleExpand(this)">⚙</button>'+ '<button title="展开/收起参数" onclick="_stepEditor.toggleExpand(this)">⚙</button>'+
'<button title="上移" onclick="_stepEditor.move('+idx+',-1,\''+this._path(parentArr)+'\')">↑</button>'+ '<button title="上移" onclick="_stepEditor.move('+idx+',-1,\''+this._arrId(parentArr)+'\')">↑</button>'+
'<button title="下移" onclick="_stepEditor.move('+idx+',1,\''+this._path(parentArr)+'\')">↓</button>'+ '<button title="下移" onclick="_stepEditor.move('+idx+',1,\''+this._arrId(parentArr)+'\')">↓</button>'+
'<button title="复制" onclick="_stepEditor.duplicate('+idx+',\''+this._path(parentArr)+'\')">⧉</button>'+ '<button title="复制" onclick="_stepEditor.duplicate('+idx+',\''+this._arrId(parentArr)+'\')">⧉</button>'+
'<button title="删除" onclick="_stepEditor.remove('+idx+',\''+this._path(parentArr)+'\')">✕</button>'+ '<button title="删除" onclick="_stepEditor.remove('+idx+',\''+this._arrId(parentArr)+'\')">✕</button>'+
'</span>'; '</span>';
head.addEventListener('click',function(e){ head.addEventListener('click',function(e){
if(e.target.tagName==='BUTTON')return; if(e.target.tagName==='BUTTON')return;
@@ -1165,7 +1338,7 @@ var _stepEditor={
card.appendChild(body); card.appendChild(body);
// 拖拽排序 // 拖拽排序
card.addEventListener('dragstart',function(e){ card.addEventListener('dragstart',function(e){
e.dataTransfer.setData('text/plain','move:'+idx+':'+self._path(parentArr)); e.dataTransfer.setData('text/plain','move:'+idx+':'+self._arrId(parentArr));
e.dataTransfer.effectAllowed='move'; e.dataTransfer.effectAllowed='move';
card.classList.add('dragging'); card.classList.add('dragging');
}); });
@@ -1177,6 +1350,7 @@ var _stepEditor={
card.addEventListener('dragleave',function(){card.classList.remove('drag-over');}); card.addEventListener('dragleave',function(){card.classList.remove('drag-over');});
card.addEventListener('drop',function(e){ card.addEventListener('drop',function(e){
e.preventDefault(); e.preventDefault();
e.stopPropagation(); // 关键:阻止冒泡到外层卡片/容器,避免重复添加
card.classList.remove('drag-over'); card.classList.remove('drag-over');
self._handleDrop(e,parentArr,idx); self._handleDrop(e,parentArr,idx);
}); });
@@ -1222,41 +1396,66 @@ var _stepEditor={
h+='<div class="form-group" data-mode="fixed" style="display:none"><label>指定文字</label><input type="text" class="form-control" data-key="fixed_text" value="'+esc(p.fixed_text||'')+'"></div>'; h+='<div class="form-group" data-mode="fixed" style="display:none"><label>指定文字</label><input type="text" class="form-control" data-key="fixed_text" value="'+esc(p.fixed_text||'')+'"></div>';
h+='<div class="help">需先用"点击元素"步骤定位到输入框,本步骤只负责输入文字</div>'; h+='<div class="help">需先用"点击元素"步骤定位到输入框,本步骤只负责输入文字</div>';
}else if(step.type==='loop'){ }else if(step.type==='loop'){
h+='<div class="form-group"><label>最大循环次数</label><input type="number" class="form-control" data-key="max_iterations" value="'+(p.max_iterations||10)+'" min="1" max="1000"></div>'; h+='<div class="form-row"><div class="form-group"><label>循环方式</label><select class="form-control" data-key="loop_mode" onchange="_stepEditor._toggleLoopMode(this)">'+
h+='<div class="help">将下方子步骤(拖拽到本卡片内)循环执行指定次数</div>'; '<option value="rounds"'+(p.loop_mode!=='time'?' selected':'')+'>按轮次</option>'+
'<option value="time"'+(p.loop_mode==='time'?' selected':'')+'>按时间</option></select></div>'+
'<div class="form-group" data-lmode="rounds"><label>最大循环次数</label><input type="number" class="form-control" data-key="max_iterations" value="'+(p.max_iterations||10)+'" min="1" max="100000"></div>'+
'<div class="form-group" data-lmode="time" style="display:none"><label>循环时长(秒)</label><input type="number" class="form-control" data-key="loop_duration" value="'+(p.loop_duration||600)+'" min="1"></div></div>';
h+='<div class="help">将下方子步骤(拖拽到本卡片内)循环执行指定轮次或时长;按时间时配合"运行时长上限"看预计结束时间</div>';
}else if(step.type==='group'){ }else if(step.type==='group'){
h+='<div class="help">动作组:按顺序执行下方子步骤一次。把多个步骤打包成一个可复用的整体。</div>'; h+='<div class="help">动作组:按顺序执行下方子步骤一次。把多个步骤打包成一个可复用的整体。</div>';
h+='<div class="form-group"><button class="btn btn-xs btn-primary" onclick="_stepEditor.saveAsCustomAction()">存为自定义动作</button> <span class="help" style="display:inline">把本组步骤保存到左侧自定义动作库,方便复用</span></div>'; h+='<div class="form-group"><button class="btn btn-xs btn-primary" onclick="_stepEditor.saveAsCustomAction()">存为自定义动作</button> <span class="help" style="display:inline">把本组步骤保存到左侧自定义动作库,方便复用</span></div>';
} }
// 共享:触发概率(所有步骤通用,0~100)
h+='<div class="form-row" style="margin-top:8px"><div class="form-group"><label>触发概率(%)</label><input type="number" class="form-control" data-key="probability" value="'+(p.probability!=null?p.probability:100)+'" min="0" max="100"><div class="help">0~100,&lt;100 表示按概率执行(如 30=隔几次才触发一次,适合"偶尔点赞")</div></div></div>';
// 可编辑 label // 可编辑 label
h+='<div class="form-group" style="margin-top:8px"><label>步骤名称(可选)</label><input type="text" class="form-control" data-key="_label" value="'+esc(step.label||'')+'" placeholder="留空用默认"></div>'; h+='<div class="form-group"><label>步骤名称(可选)</label><input type="text" class="form-control" data-key="_label" value="'+esc(step.label||'')+'" placeholder="留空用默认"></div>';
return h; return h;
}, },
_bindDropZone:function(container,arr){ _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; var self=this;
// 画布空白处也能接收 drop // 画布空白处也能接收 drop,拖入时高亮提示
container.addEventListener('dragover',function(e){e.preventDefault();}); 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){ container.addEventListener('drop',function(e){
e.preventDefault(); e.preventDefault();
e.stopPropagation(); // 阻止冒泡到外层卡片/容器,避免重复添加
container.classList.remove('drag-over');
if(e.target===container||e.target.classList.contains('se-empty')){ if(e.target===container||e.target.classList.contains('se-empty')){
self._handleDrop(e,arr,-1); self._handleDrop(e,self._dropArrs.get(container),-1);
} }
}); });
}, },
_handleDrop:function(e,arr,idx){ _handleDrop:function(e,arr,idx){
var self=this;
var data=e.dataTransfer.getData('text/plain'); var data=e.dataTransfer.getData('text/plain');
if(!data)return; if(!data)return;
// drop 处理中同步重建 DOM 会打断拖拽事件,可能被浏览器重发 drop 导致重复添加。
// 延迟到下一帧渲染,保证一次拖入只加一次。
function rerender(){ setTimeout(function(){self._renderCanvas();},0); }
if(data.startsWith('move:')){ if(data.startsWith('move:')){
// 内部排序 // 内部排序
var parts=data.split(':'); var parts=data.split(':');
var fromIdx=parseInt(parts[1]); var fromIdx=parseInt(parts[1]);
var fromPath=parts[2]; var fromPath=parts[2];
if(fromPath===this._path(arr)&&fromIdx!==idx){ if(fromPath===this._arrId(arr)&&fromIdx!==idx){
this._syncFromDom();
var moved=arr.splice(fromIdx,1)[0]; var moved=arr.splice(fromIdx,1)[0];
if(idx<0)arr.push(moved); if(idx<0)arr.push(moved);
else if(fromIdx<idx)arr.splice(idx-1,0,moved); else if(fromIdx<idx)arr.splice(idx-1,0,moved);
else arr.splice(idx,0,moved); else arr.splice(idx,0,moved);
this._renderCanvas(); rerender();
} }
}else if(data.startsWith('action:')){ }else if(data.startsWith('action:')){
// 从自定义动作库拖入 → 展开为 group 步骤 // 从自定义动作库拖入 → 展开为 group 步骤
@@ -1269,7 +1468,7 @@ var _stepEditor={
this._ensureIds(ns.params.children); this._ensureIds(ns.params.children);
if(idx<0||idx>=arr.length)arr.push(ns); if(idx<0||idx>=arr.length)arr.push(ns);
else arr.splice(idx,0,ns); else arr.splice(idx,0,ns);
this._renderCanvas(); rerender();
} }
}else if(data.startsWith('type:')){ }else if(data.startsWith('type:')){
// 从系统操作库拖入 // 从系统操作库拖入
@@ -1279,7 +1478,7 @@ var _stepEditor={
var ns2=this._makeStep(lib); var ns2=this._makeStep(lib);
if(idx<0||idx>=arr.length)arr.push(ns2); if(idx<0||idx>=arr.length)arr.push(ns2);
else arr.splice(idx,0,ns2); else arr.splice(idx,0,ns2);
this._renderCanvas(); rerender();
} }
} }
}, },
@@ -1289,12 +1488,14 @@ var _stepEditor={
addStep:function(type){ addStep:function(type){
var lib=_findLib(type); var lib=_findLib(type);
if(!lib)return; if(!lib)return;
this._syncFromDom();
this._steps.push(this._makeStep(lib)); this._steps.push(this._makeStep(lib));
this._renderCanvas(); this._renderCanvas();
}, },
addCustomAction:function(actionId){ addCustomAction:function(actionId){
var action=_customActions.find(function(a){return a.id===actionId;}); var action=_customActions.find(function(a){return a.id===actionId;});
if(!action||!action.steps||!action.steps.length)return; if(!action||!action.steps||!action.steps.length)return;
this._syncFromDom();
var ns=this._makeStep({type:'group',label:action.name,icon:action.icon||'📦',params:{children:[]}}); var ns=this._makeStep({type:'group',label:action.name,icon:action.icon||'📦',params:{children:[]}});
ns.label=action.name; ns.label=action.name;
ns.params.children=JSON.parse(JSON.stringify(action.steps)); ns.params.children=JSON.parse(JSON.stringify(action.steps));
@@ -1310,6 +1511,7 @@ var _stepEditor={
}); });
}, },
toggleSelect:function(stepId){ toggleSelect:function(stepId){
this._syncFromDom();
if(!this._selected)this._selected=[]; if(!this._selected)this._selected=[];
var i=this._selected.indexOf(stepId); var i=this._selected.indexOf(stepId);
if(i>=0)this._selected.splice(i,1); if(i>=0)this._selected.splice(i,1);
@@ -1368,8 +1570,32 @@ var _stepEditor={
g.style.display=g.dataset.mode===mode?'block':'none'; 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';
});
},
// 给数组分配唯一 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];
},
move:function(idx,dir,path){ move:function(idx,dir,path){
var arr=path==='root'?this._steps:this._findLoopChildren(); this._syncFromDom(); // 先同步 DOM 编辑,避免重渲染丢内容
var arr=this._resolveArr(path);
if(!arr)return; if(!arr)return;
var ni=idx+dir; var ni=idx+dir;
if(ni<0||ni>=arr.length)return; if(ni<0||ni>=arr.length)return;
@@ -1377,7 +1603,8 @@ var _stepEditor={
this._renderCanvas(); this._renderCanvas();
}, },
duplicate:function(idx,path){ duplicate:function(idx,path){
var arr=path==='root'?this._steps:this._findLoopChildren(); this._syncFromDom();
var arr=this._resolveArr(path);
if(!arr)return; if(!arr)return;
var copy=JSON.parse(JSON.stringify(arr[idx])); var copy=JSON.parse(JSON.stringify(arr[idx]));
copy.id=_newStepId(); copy.id=_newStepId();
@@ -1386,24 +1613,16 @@ var _stepEditor={
this._renderCanvas(); this._renderCanvas();
}, },
remove:function(idx,path){ remove:function(idx,path){
var arr=path==='root'?this._steps:this._findLoopChildren(); this._syncFromDom();
var arr=this._resolveArr(path);
if(!arr)return; if(!arr)return;
arr.splice(idx,1); arr.splice(idx,1);
this._renderCanvas(); this._renderCanvas();
}, },
_findLoopChildren:function(){
// 简化:找到第一个 loop 的 children(当前只支持单层 loop 编辑)
for(var i=0;i<this._steps.length;i++){
if(this._steps[i].type==='loop'&&this._steps[i].params&&this._steps[i].params.children){
return this._steps[i].params.children;
}
}
return null;
},
collect:function(){ collect:function(){
// 先把当前 DOM 上的参数值同步回 _steps // 先把当前 DOM 上的参数值同步回 _steps
var self=this; var self=this;
this._syncParams(document.getElementById('se-canvas'),this._steps); this._syncParams(this._canvas,this._steps);
return JSON.parse(JSON.stringify(this._steps)); return JSON.parse(JSON.stringify(this._steps));
}, },
validate:function(steps){ validate:function(steps){
@@ -1463,6 +1682,24 @@ var _stepEditor={
} }
}); });
}, },
// 把画布 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){ openPicker:function(btn){
var input=btn.previousElementSibling; var input=btn.previousElementSibling;
@@ -1564,16 +1801,17 @@ var _stepEditor={
var container=document.getElementById('el-shot-container'); var container=document.getElementById('el-shot-container');
var img=container.querySelector('img'); var img=container.querySelector('img');
if(!img||!this._elData)return; if(!img||!this._elData)return;
// 等图片加载完
var self=this; var self=this;
function draw(){ function draw(){
// 清除旧 overlay // 清除旧 overlay 和旧点击处理
container.querySelectorAll('.el-shot-overlay').forEach(function(o){o.remove();}); container.querySelectorAll('.el-shot-overlay').forEach(function(o){o.remove();});
container.onclick=null;
var scaleX=img.clientWidth/img.naturalWidth; var scaleX=img.clientWidth/img.naturalWidth;
var scaleY=img.clientHeight/img.naturalHeight; var scaleY=img.clientHeight/img.naturalHeight;
// 图片在 container 里的偏移 // 图片在 container 里的偏移
var imgOffsetX=img.offsetLeft; var imgOffsetX=img.offsetLeft;
var imgOffsetY=img.offsetTop; var imgOffsetY=img.offsetTop;
var imgW=img.naturalWidth, imgH=img.naturalHeight;
self._elData.forEach(function(el,i){ self._elData.forEach(function(el,i){
if(!el.bounds)return; if(!el.bounds)return;
// bounds 格式 "[x1,y1][x2,y2]" // bounds 格式 "[x1,y1][x2,y2]"
@@ -1583,19 +1821,38 @@ var _stepEditor={
var w=(x2-x1)*scaleX; var w=(x2-x1)*scaleX;
var h=(y2-y1)*scaleY; var h=(y2-y1)*scaleY;
if(w<4||h<4)return; // 太小不画 if(w<4||h<4)return; // 太小不画
// 跳过占满屏幕的容器:面积>90%屏幕,没有选择价值,还会盖住小元素
if((x2-x1)*(y2-y1) > imgW*imgH*0.9)return;
var ov=document.createElement('div'); var ov=document.createElement('div');
ov.className='el-shot-overlay'; ov.className='el-shot-overlay';
ov.setAttribute('data-idx',i);
ov.style.left=(imgOffsetX+x1*scaleX)+'px'; ov.style.left=(imgOffsetX+x1*scaleX)+'px';
ov.style.top=(imgOffsetY+y1*scaleY)+'px'; ov.style.top=(imgOffsetY+y1*scaleY)+'px';
ov.style.width=w+'px'; ov.style.width=w+'px';
ov.style.height=h+'px'; ov.style.height=h+'px';
ov.title=el.name||el.class||''; ov.title=el.name||el.class||'';
ov.onclick=function(e){
e.stopPropagation();
self._highlightEl(i);
};
container.appendChild(ov); 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<bestArea){bestArea=area;best=i;}
}
});
if(best>=0)self._highlightEl(best);
};
} }
if(img.complete&&img.naturalWidth>0)draw(); if(img.complete&&img.naturalWidth>0)draw();
else img.onload=draw; else img.onload=draw;
@@ -1606,9 +1863,9 @@ var _stepEditor={
var picker=document.getElementById('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-shot-overlay.active').forEach(function(o){o.classList.remove('active');});
picker.querySelectorAll('.el-picker-item.active').forEach(function(o){o.classList.remove('active');}); picker.querySelectorAll('.el-picker-item.active').forEach(function(o){o.classList.remove('active');});
// 高亮当前(overlays 基于 _elData 原始索引) // 高亮当前(overlay 用 data-idx 查找,渲染时跳过了全屏容器,索引不再一一对应)
var overlays=picker.querySelectorAll('.el-shot-overlay'); var overlay=picker.querySelector('.el-shot-overlay[data-idx="'+i+'"]');
if(overlays[i])overlays[i].classList.add('active'); if(overlay)overlay.classList.add('active');
// 列表项用 data-idx 查找(过滤后顺序可能不同) // 列表项用 data-idx 查找(过滤后顺序可能不同)
var item=picker.querySelector('.el-picker-item[data-idx="'+i+'"]'); var item=picker.querySelector('.el-picker-item[data-idx="'+i+'"]');
if(item){ if(item){
@@ -1651,6 +1908,8 @@ var _stepEditor={
this._elTargetInput.value=el.suggested.value; this._elTargetInput.value=el.suggested.value;
var sel=this._elTargetInput.closest('.sc-body').querySelector('[data-key="selector_type"]'); var sel=this._elTargetInput.closest('.sc-body').querySelector('[data-key="selector_type"]');
if(sel)sel.value=el.suggested.type; if(sel)sel.value=el.suggested.type;
// 程序赋值不会触发 input 事件,手动同步回 _steps 防丢失
this._syncFromDom();
// 只关闭抓取元素弹窗,不影响任务编辑窗口 // 只关闭抓取元素弹窗,不影响任务编辑窗口
closeElPicker(); closeElPicker();
showToast('已填入: '+el.suggested.type+'='+el.suggested.value,'success'); showToast('已填入: '+el.suggested.type+'='+el.suggested.value,'success');
@@ -1706,10 +1965,10 @@ async function saveTask(jobId){
if(jobId){ if(jobId){
const r=await apiPut('/api/jobs/'+jobId,data); const r=await apiPut('/api/jobs/'+jobId,data);
handleResult(r,()=>{closeModal();loadTasks();}); handleResult(r,()=>{_stepEditor.clearDraft();closeModal();loadTasks();});
}else{ }else{
const r=await apiPost('/api/jobs',data); const r=await apiPost('/api/jobs',data);
handleResult(r,()=>{closeModal();loadTasks();}); handleResult(r,()=>{_stepEditor.clearDraft();closeModal();loadTasks();});
} }
} }
@@ -1719,6 +1978,82 @@ async function deleteTask(jobId){
handleResult(r,()=>loadTasks()); 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)),
render:renderActionRow,
tbody:'tb-actions',pager:'pager-actions',pageSize:10,
empty:'<tr><td colspan="5" class="empty">暂无自定义动作,可点击"新建动作"或用任务编辑器☑选中步骤打包</td></tr>'
});
applyListPager('actions');
initListSearch('actions','search-actions');
_stepEditor._renderLib(); // 刷新步骤编辑器左侧动作库
}
function renderActionRow(a){
const n=(a.steps||[]).length;
return '<tr>'+
'<td style="font-size:20px">'+esc(a.icon||'📦')+'</td>'+
'<td><strong>'+esc(a.name)+'</strong></td>'+
'<td>'+n+'</td>'+
'<td style="font-size:12px;color:#94a3b8">'+esc(a.created_at||'-')+'</td>'+
'<td>'+
'<button class="btn btn-xs" onclick="openActionModal(\''+a.id+'\')">编辑</button> '+
'<button class="btn btn-danger btn-xs" onclick="deleteAction(\''+a.id+'\')">删除</button>'+
'</td>'+
'</tr>';
}
function openActionModal(actionId){
const action=actionId?_customActions.find(a=>a.id===actionId):null;
const isEdit=!!action;
document.getElementById('action-modal-title').textContent=isEdit?'编辑动作':'新建动作';
document.getElementById('action-modal-body').innerHTML=
'<div class="form-row">'+
'<div class="form-group"><label>动作名称</label><input type="text" class="form-control" id="a-name" value="'+esc(action?action.name:'')+'" placeholder="如:点赞动作"></div>'+
'<div class="form-group"><label>图标</label><input type="text" class="form-control" id="a-icon" value="'+esc(action?(action.icon&&action.icon!=='📦'?action.icon:''):'')+'" placeholder="📦" style="width:80px"></div>'+
'</div>'+
'<div class="help" style="margin-bottom:8px">用下方步骤编辑器编排动作内容,保存后可拖入任务步骤编辑器复用</div>'+
'<div class="step-editor" style="display:block">'+
'<div class="se-head"><span>动作步骤</span><span style="font-size:11px;color:var(--text-light);margin-left:12px">支持循环/分组/概率触发/拖拽排序</span></div>'+
'<div class="se-body">'+
'<div class="se-lib" id="action-lib"><div class="lib-title">操作库</div></div>'+
'<div class="se-canvas" id="action-canvas"></div>'+
'</div>'+
'</div>';
document.getElementById('action-modal-footer').innerHTML=
'<button class="btn" onclick="closeActionModal()">取消</button>'+
'<button class="btn btn-primary" onclick="saveAction('+(isEdit?'\''+actionId+'\'':'null')+')">保存动作</button>';
// 用独立画布初始化步骤编辑器(不与任务编辑器冲突)
_stepEditor.init(action&&action.steps?action.steps:[], 0, 'action:'+(actionId||'new'), 'action-canvas', 'action-lib');
document.getElementById('action-modal-overlay').classList.add('show');
}
async function saveAction(actionId){
const name=document.getElementById('a-name').value.trim();
if(!name){showToast('动作名不能为空','error');return;}
const icon=document.getElementById('a-icon').value.trim()||'📦';
const steps=_stepEditor.collect();
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: 分组 ================== // ================== Tab 3: 分组 ==================
async function loadGroups(){ async function loadGroups(){
const r=await apiGet('/api/groups');if(!r)return; const r=await apiGet('/api/groups');if(!r)return;
@@ -1730,7 +2065,17 @@ async function loadGroups(){
function renderGroupTable(){ function renderGroupTable(){
const groups=_groupsList||[]; const groups=_groupsList||[];
document.getElementById('tb-groups').innerHTML=groups.length?groups.map(g=>{ setListPager('groups',{
data:()=>groups,
filter:(g,kw)=>((g.name||'').toLowerCase().includes(kw)||(g.description||'').toLowerCase().includes(kw)||(g.serials||[]).join(' ').toLowerCase().includes(kw)),
render:renderGroupRow,
tbody:'tb-groups',pager:'pager-groups',pageSize:10,
empty:'<tr><td colspan="5" class="empty">暂无分组,点击"新建分组"创建</td></tr>'
});
applyListPager('groups');
initListSearch('groups','search-groups');
}
function renderGroupRow(g){
const serials=g.serials||[]; const serials=g.serials||[];
return '<tr>'+ return '<tr>'+
'<td><strong>'+esc(g.name)+'</strong></td>'+ '<td><strong>'+esc(g.name)+'</strong></td>'+
@@ -1742,7 +2087,6 @@ function renderGroupTable(){
'<button class="btn btn-danger btn-xs" onclick="deleteGroup(\''+esc(g.name)+'\')">删除</button>'+ '<button class="btn btn-danger btn-xs" onclick="deleteGroup(\''+esc(g.name)+'\')">删除</button>'+
'</td>'+ '</td>'+
'</tr>'; '</tr>';
}).join(''):'<tr><td colspan="5" class="empty">暂无分组,点击"新建分组"创建</td></tr>';
} }
function openGroupModal(name){ function openGroupModal(name){
@@ -1823,7 +2167,17 @@ function toggleLogAuto(){
async function loadUsers(){ async function loadUsers(){
const r=await apiGet('/api/users');if(!r)return; const r=await apiGet('/api/users');if(!r)return;
const users=r.users||[]; const users=r.users||[];
document.getElementById('tb-users').innerHTML=users.length?users.map(u=>{ setListPager('users',{
data:()=>users,
filter:(u,kw)=>(u.username||'').toLowerCase().includes(kw),
render:renderUserRow,
tbody:'tb-users',pager:'pager-users',pageSize:10,
empty:'<tr><td colspan="4" class="empty">暂无用户</td></tr>'
});
applyListPager('users');
initListSearch('users','search-users');
}
function renderUserRow(u){
const admin=u.is_admin?'<span class="label label-primary">管理员</span>':'<span class="label label-default">普通</span>'; const admin=u.is_admin?'<span class="label label-primary">管理员</span>':'<span class="label label-default">普通</span>';
return '<tr>'+ return '<tr>'+
'<td>'+u.id+'</td>'+ '<td>'+u.id+'</td>'+
@@ -1834,7 +2188,6 @@ async function loadUsers(){
(u.username==='admin'?'':'<button class="btn btn-danger btn-xs" onclick="deleteUser('+u.id+')">删除</button>')+ (u.username==='admin'?'':'<button class="btn btn-danger btn-xs" onclick="deleteUser('+u.id+')">删除</button>')+
'</td>'+ '</td>'+
'</tr>'; '</tr>';
}).join(''):'<tr><td colspan="4" class="empty">暂无用户</td></tr>';
} }
function openUserModal(uid){ function openUserModal(uid){
@@ -1888,6 +2241,7 @@ async function deleteUser(uid){
// ================== Tab: 应用 ================== // ================== Tab: 应用 ==================
let _apksCache=[]; let _apksCache=[];
let _installTimer=null; let _installTimer=null;
let _devAppsData=[];
function fmtSize(b){ function fmtSize(b){
if(!b)return '0 B'; if(!b)return '0 B';
@@ -1901,12 +2255,19 @@ async function loadApks(){
const apks=r.apks||[]; const apks=r.apks||[];
_apksCache=apks; _apksCache=apks;
document.getElementById('apk-hint').textContent='共 '+apks.length+' 个应用'; document.getElementById('apk-hint').textContent='共 '+apks.length+' 个应用';
if(!apks.length){ setListPager('apks',{
document.getElementById('tb-apks').innerHTML='<tr><td colspan="6" class="empty">暂无APK,点击"上传APK"上传</td></tr>'; data:()=>apks,
return; filter:(a,kw)=>((a.display_name||'').toLowerCase().includes(kw)||(a.package_name||'').toLowerCase().includes(kw)),
render:renderApkRow,
tbody:'tb-apks',pager:'pager-apks',pageSize:10,
empty:'<tr><td colspan="6" class="empty">暂无APK,点击"上传APK"上传</td></tr>'
});
applyListPager('apks');
initListSearch('apks','search-apks');
await fillAppsDeviceList();
} }
document.getElementById('tb-apks').innerHTML=apks.map(a=> function renderApkRow(a){
'<tr>'+ return '<tr>'+
'<td><strong>'+esc(a.display_name||'(未命名)')+'</strong></td>'+ '<td><strong>'+esc(a.display_name||'(未命名)')+'</strong></td>'+
'<td style="font-family:monospace;font-size:12px;color:#64748b">'+esc(a.package_name||'-')+'</td>'+ '<td style="font-family:monospace;font-size:12px;color:#64748b">'+esc(a.package_name||'-')+'</td>'+
'<td>'+(a.version_name?esc(a.version_name):'-')+'</td>'+ '<td>'+(a.version_name?esc(a.version_name):'-')+'</td>'+
@@ -1916,8 +2277,58 @@ async function loadApks(){
'<button class="btn btn-primary btn-xs" onclick="openInstallModal(\''+a.id+'\')">安装</button> '+ '<button class="btn btn-primary btn-xs" onclick="openInstallModal(\''+a.id+'\')">安装</button> '+
'<button class="btn btn-danger btn-xs" onclick="deleteApk(\''+a.id+'\')">删除</button>'+ '<button class="btn btn-danger btn-xs" onclick="deleteApk(\''+a.id+'\')">删除</button>'+
'</td>'+ '</td>'+
'</tr>' '</tr>';
}
// 填充"设备已装应用"的设备下拉(保留当前选择)
async function fillAppsDeviceList(){
const sr=await apiGet('/api/status');if(!sr||!sr.ok)return;
const sel=document.getElementById('apps-device');
if(!sel)return;
const cur=sel.value;
const devices=(sr.devices||[]).filter(d=>d.present);
sel.innerHTML='<option value="">选择设备...</option>'+devices.map(d=>
'<option value="'+esc(d.serial)+'">'+esc((d.serial||'').replace(/:5555$/,'')+' ('+(d.device_name||d.model||'未知设备')+')')+'</option>'
).join(''); ).join('');
if(cur)sel.value=cur;
}
// 查看指定设备已安装应用
async function loadDeviceApps(){
const sel=document.getElementById('apps-device');
const serial=sel.value;
if(!serial){showToast('请先选择设备','error');return;}
const hint=document.getElementById('apps-hint');
const tb=document.getElementById('tb-device-apps');
hint.textContent='加载中...';
const r=await apiGet('/api/devices/'+encodeURIComponent(serial)+'/apps');
hint.textContent='';
if(!r||!r.ok){
tb.innerHTML='<tr><td colspan="4" class="empty">'+(r&&r.error?esc(r.error):'获取失败')+'</td></tr>';
showToast('获取失败','error');return;
}
const apps=r.apps||[];
if(!apps.length){tb.innerHTML='<tr><td colspan="4" class="empty">无应用</td></tr>';return;}
const userApps=apps.filter(a=>/^\/data\//.test(a.path||'')).length;
hint.textContent='共 '+apps.length+' 个应用(用户安装 '+userApps+')';
_devAppsData=apps;
setListPager('devapps',{
data:()=>_devAppsData||[],
filter:(a,kw)=>((a.label||a.package||'').toLowerCase().includes(kw)||(a.package||'').toLowerCase().includes(kw)||(a.path||'').toLowerCase().includes(kw)),
render:renderDevAppRow,
tbody:'tb-device-apps',pager:'pager-devapps',pageSize:20,
empty:'<tr><td colspan="4" class="empty">无应用</td></tr>'
});
applyListPager('devapps');
initListSearch('devapps','search-devapps');
}
function renderDevAppRow(a){
return '<tr>'+
'<td><strong>'+esc(a.label||a.package)+'</strong>'+(a.label?' <span style="font-size:11px;color:#94a3b8;font-family:monospace">'+esc(a.package)+'</span>':'')+'</td>'+
'<td>'+(a.version_name?esc(a.version_name):'-')+'</td>'+
'<td style="font-size:12px;color:#94a3b8">'+(a.version_code!=null?esc(a.version_code):'-')+'</td>'+
'<td style="font-size:11px;color:#94a3b8;word-break:break-all">'+esc(a.path||'-')+'</td>'+
'</tr>';
} }
async function uploadApk(input){ async function uploadApk(input){
@@ -1994,7 +2405,8 @@ async function openInstallModal(apkId){
online.map(d=>{ online.map(d=>{
const running=d.worker_status==='running'||d.worker_status==='connecting'; const running=d.worker_status==='running'||d.worker_status==='connecting';
const st='<span class="label label-'+(running?'success':'default')+'" style="margin-left:6px">'+(running?'运行中':'空闲')+'</span>'; const st='<span class="label label-'+(running?'success':'default')+'" style="margin-left:6px">'+(running?'运行中':'空闲')+'</span>';
const label=d.device_name||d.model||d.serial; // serial 唯一,显示 IP + 型号便于区分同型号设备
const label=(d.serial||'').replace(/:5555$/,'')+' · '+(d.device_name||d.model||'设备');
return '<label><input type="checkbox" value="'+esc(d.serial)+'"'+(running?'':' checked')+'>'+esc(label)+st+'</label>'; return '<label><input type="checkbox" value="'+esc(d.serial)+'"'+(running?'':' checked')+'>'+esc(label)+st+'</label>';
}).join('')+ }).join('')+
'</div>'; '</div>';
+13 -3
View File
@@ -29,7 +29,7 @@ from core.task_manager import TaskManager
from core.logger import get_logger, _LOG_DIR, _MODULE_FILES from core.logger import get_logger, _LOG_DIR, _MODULE_FILES
from core.models import db, init_db, User, DeviceGroup, TaskJob, CustomAction from core.models import db, init_db, User, DeviceGroup, TaskJob, CustomAction
from core.apk_manager import ApkManager from core.apk_manager import ApkManager
from core.adb_helper import screenshot from core.adb_helper import screenshot, list_installed_apps
from core import uiauto_helper from core import uiauto_helper
from tasks import list_task_types, get_task_class from tasks import list_task_types, get_task_class
@@ -346,8 +346,8 @@ def api_stop_all():
@app.route("/api/release", methods=["POST"]) @app.route("/api/release", methods=["POST"])
@login_required @login_required
def api_release(): def api_release():
released = stf.release_all_mine() released, failed = stf.release_all_mine()
return jsonify({"ok": True, "released": released}) return jsonify({"ok": True, "released": released, "failed": failed})
@app.route("/api/device/screenshot") @app.route("/api/device/screenshot")
@@ -370,6 +370,16 @@ def api_device_screenshot():
return jsonify({"ok": False, "error": data}), 500 return jsonify({"ok": False, "error": data}), 500
@app.route("/api/devices/<serial>/apps")
@login_required
def api_device_apps(serial):
"""获取指定设备上已安装的应用列表(包名 + versionCode + versionName + 路径)。"""
ok, data = list_installed_apps(serial)
if ok:
return jsonify({"ok": True, "apps": data})
return jsonify({"ok": False, "error": data}), 500
# ================== 自定义动作(步骤打包) ================== # ================== 自定义动作(步骤打包) ==================
@app.route("/api/custom_actions") @app.route("/api/custom_actions")
@login_required @login_required