Compare commits
4
Commits
468e86f0a9
...
473dc1909d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
473dc1909d | ||
|
|
fcbdb0a8a1 | ||
|
|
4f29762121 | ||
|
|
3a38d9a57f |
+151
-1
@@ -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
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()">×</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,<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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user