Compare commits
11
Commits
5f914c4fe4
...
608df50acb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
608df50acb | ||
|
|
06a9df111a | ||
|
|
a0d1a533c6 | ||
|
|
5ccf622942 | ||
|
|
794f40a880 | ||
|
|
c37290e8e3 | ||
|
|
003cb42cb1 | ||
|
|
3320035c9e | ||
|
|
26c908f04c | ||
|
|
e3acb5739c | ||
|
|
8a3cd67a97 |
@@ -200,7 +200,11 @@ adb disconnect + STF release(释放设备)
|
|||||||
| `cron` | 定时启动:到 cron 时间点自动启动所有目标设备 |
|
| `cron` | 定时启动:到 cron 时间点自动启动所有目标设备 |
|
||||||
| `cron_stop` | 定时启停:启动 cron 到点启动,停止 cron 到点停止本任务 worker |
|
| `cron_stop` | 定时启停:启动 cron 到点启动,停止 cron 到点停止本任务 worker |
|
||||||
|
|
||||||
cron 表达式为标准 5 段格式:`分 时 日 月 周`(如 `0 9 * * *` = 每天 9:00)
|
**简单设置**:编辑任务时选"定时启动/定时启动+停止"后,频率用下拉选择——每天(选时间)、每小时(整点)、每隔 N 小时、每周(选星期+时间)——cron 表达式自动生成,不需要懂 cron 语法。老手可在"自定义 cron(高级)"里直接填。
|
||||||
|
|
||||||
|
cron 表达式为标准 5 段格式:`分 时 日 月 周`(如 `0 9 * * *` = 每天 9:00,`0 */2 * * *` = 每 2 小时整点,周 `0`/`7` 均为周日)
|
||||||
|
|
||||||
|
**运行窗口**:任务可勾选"启用运行窗口",设置每天允许运行的时间段(如 `21:00-09:00` = 晚 9 点到次日早 9 点,支持跨午夜)。窗口外**定时触发和手动执行都不会启动**(手动执行会提示"当前不在运行窗口内")。典型用法:`每小时`定时 + 窗口 `09:00-21:00`,即只在白天每小时跑一次。
|
||||||
|
|
||||||
### 设备分组
|
### 设备分组
|
||||||
|
|
||||||
@@ -241,15 +245,34 @@ self.set_progress(done=5, total=80, unit="视频",
|
|||||||
|
|
||||||
### 页面结构
|
### 页面结构
|
||||||
|
|
||||||
单页应用(`templates/admin/monitor.html`),5 个 Tab:
|
单页应用(`templates/admin/monitor.html`),6 个 Tab:
|
||||||
|
|
||||||
| Tab | 功能 |
|
| Tab | 功能 | 可见性 |
|
||||||
|-----|------|
|
|-----|------|--------|
|
||||||
| 监控 | 设备状态大屏:在线/离线、运行任务、当前动作、进度条、前台 App、截图、停止 |
|
| 监控 | 设备状态大屏:在线/离线、运行任务、当前动作、进度条、前台 App、截图、停止 | 所有登录用户(设备操作按钮需"设备控制"权限) |
|
||||||
| 任务 | 任务计划 CRUD:新建/编辑/删除/启用停用/立即执行 |
|
| 任务 | 任务计划 CRUD:新建/编辑/删除/启用停用/立即执行 | 所有登录用户可看,写操作需"任务管理"权限 |
|
||||||
| 分组 | 设备分组管理:创建/编辑/删除分组 |
|
| 应用 | APK 上传/安装/删除 | 需"应用管理"权限 |
|
||||||
| 日志 | 实时日志查看:按模块切换(core/task/web/action) |
|
| 分组 | 设备分组管理:创建/编辑/删除分组 | 所有登录用户可看,写操作需"任务管理"权限 |
|
||||||
| 用户 | 用户管理:创建/删除/修改密码 |
|
| 日志 | 实时日志查看:按模块切换(core/task/web/action) | 需"日志查看"权限 |
|
||||||
|
| 用户 | 用户管理:创建/删除/修改密码/分配权限 | 仅管理员 |
|
||||||
|
| 维护 | STF 容器一键重启、adb 远程终端(设备快捷选择/自动 `-s`、卸载 STF agent、重连设备) | 仅管理员 |
|
||||||
|
|
||||||
|
### 用户与权限
|
||||||
|
|
||||||
|
默认账号 `admin` / `admin123`(管理员,拥有全部权限)。管理员可在"用户"Tab 创建普通用户并分配权限:
|
||||||
|
|
||||||
|
| 权限位 | 说明 | 覆盖功能 |
|
||||||
|
|--------|------|---------|
|
||||||
|
| 任务管理 `tasks` | 任务/自定义动作/分组的增删改、启停、立即执行 | 任务 Tab、分组 Tab 的写操作 |
|
||||||
|
| 设备控制 `devices` | 停止设备、释放占用、清除异常、前台扫描、元素抓取 | 监控 Tab 的设备操作按钮、步骤编辑器的"抓取元素" |
|
||||||
|
| 应用管理 `apks` | APK 上传、安装、删除 | 应用 Tab |
|
||||||
|
| 日志查看 `logs` | 日志页 | 日志 Tab |
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- **管理员拥有全部权限**,不受权限位限制
|
||||||
|
- 查看类接口(设备/任务/分组列表、状态、截图)所有登录用户可用
|
||||||
|
- **用户管理仅管理员可用**;不能删除/取消最后一个管理员
|
||||||
|
- 无权限的 tab 和按钮在界面上自动隐藏(后端同样拦截,返回 403)
|
||||||
|
|
||||||
### 前台 App 扫描
|
### 前台 App 扫描
|
||||||
|
|
||||||
@@ -298,7 +321,17 @@ self.set_progress(done=5, total=80, unit="视频",
|
|||||||
| 步骤类型 | 说明 |
|
| 步骤类型 | 说明 |
|
||||||
|---------|------|
|
|---------|------|
|
||||||
| `open_app` | 启动 App(指定包名,可选等待首页) |
|
| `open_app` | 启动 App(指定包名,可选等待首页) |
|
||||||
|
| `stop_app` | 强制结束 App(am force-stop,清后台,下次打开冷启动) |
|
||||||
|
| `screen_on` | 亮屏(息屏时唤醒并滑动解锁) |
|
||||||
|
| `screen_off` | 息屏 |
|
||||||
|
| `keep_screen` | 保持亮屏/恢复自动息屏(充电时屏幕常亮,适合长任务) |
|
||||||
|
| `key_event` | 按键:返回/Home/回车/菜单等(退出评论、返回上一页) |
|
||||||
| `swipe` | 滑动(上/下/左/右,可配置时长) |
|
| `swipe` | 滑动(上/下/左/右,可配置时长) |
|
||||||
|
| `swipe_until` | 滑动直到元素出现(最多 N 次,可选找到后点击) |
|
||||||
|
| `click_xy` | 点击坐标(屏幕百分比,无选择器时兜底) |
|
||||||
|
| `long_click` | 长按元素(选择器 + 时长) |
|
||||||
|
| `wait_el` | 等待元素出现(条件等待,替代固定时长) |
|
||||||
|
| `input_text` | 输入文字(随机候选/指定文字,可选输入前先清空) |
|
||||||
| `click` | 点击元素(支持 xpath/description/resourceId/text 选择器) |
|
| `click` | 点击元素(支持 xpath/description/resourceId/text 选择器) |
|
||||||
| `input_text` | 输入文字(随机候选/固定文字) |
|
| `input_text` | 输入文字(随机候选/固定文字) |
|
||||||
| `wait` | 等待(可配置时长范围) |
|
| `wait` | 等待(可配置时长范围) |
|
||||||
|
|||||||
@@ -64,3 +64,9 @@ APK_DIR = os.path.join(DATA_DIR, "apks")
|
|||||||
# 启动时是否自动释放"仍被本账户占用"的设备(上次崩溃/强杀残留)。
|
# 启动时是否自动释放"仍被本账户占用"的设备(上次崩溃/强杀残留)。
|
||||||
# 仅单实例部署时设为 True:多实例共用同一 STF 账户时不要开,会误释放另一实例的运行中任务。
|
# 仅单实例部署时设为 True:多实例共用同一 STF 账户时不要开,会误释放另一实例的运行中任务。
|
||||||
AUTO_RELEASE_STALE_OCCUPY = _env("AUTO_RELEASE_STALE_OCCUPY", "false").lower() in ("1", "true", "yes")
|
AUTO_RELEASE_STALE_OCCUPY = _env("AUTO_RELEASE_STALE_OCCUPY", "false").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
# ================== 维护(STF 容器一键重启) ==================
|
||||||
|
# 维护页"一键重启 STF 容器"通过 SSH 到部署机执行 docker restart。
|
||||||
|
# 前置条件:本机可免密 SSH 到目标机(stf@220 需已授权本机公钥)。
|
||||||
|
STF_SSH_TARGET = _env("STF_SSH_TARGET", "[email protected]")
|
||||||
|
STF_DOCKER_CONTAINER = _env("STF_DOCKER_CONTAINER", "stf")
|
||||||
|
|||||||
+11
-2
@@ -274,13 +274,15 @@ class ApkManager:
|
|||||||
def _install_one(self, serial, apk_path, package_name=""):
|
def _install_one(self, serial, apk_path, package_name=""):
|
||||||
"""直连设备安装 APK。
|
"""直连设备安装 APK。
|
||||||
|
|
||||||
serial 即设备的 adb 网络地址(IP:5555),直接 adb connect 即可。
|
serial 为 adb 网络地址(IP:5555)时直接 adb connect;
|
||||||
|
本机 USB 有线设备(serial 无冒号,如 ZY322XXXX)已在 adb 中,无需 connect。
|
||||||
不经过 STF occupy/release,安装完断开连接,app 永久保留。
|
不经过 STF occupy/release,安装完断开连接,app 永久保留。
|
||||||
"""
|
"""
|
||||||
from .adb_helper import _adb
|
from .adb_helper import _adb
|
||||||
self._set_item(serial, "installing", f"正在连接 {serial}...")
|
self._set_item(serial, "installing", f"正在连接 {serial}...")
|
||||||
try:
|
try:
|
||||||
# adb connect(带重试,设备网络可能需要几次握手)
|
if ":" in serial:
|
||||||
|
# 网络设备:adb connect(带重试,设备网络可能需要几次握手)
|
||||||
connected = False
|
connected = False
|
||||||
last_out = ""
|
last_out = ""
|
||||||
for attempt in range(1, 4): # 最多 3 次
|
for attempt in range(1, 4): # 最多 3 次
|
||||||
@@ -295,6 +297,13 @@ class ApkManager:
|
|||||||
_log.warning(f"[{serial}] adb connect 失败: 输出={last_out}")
|
_log.warning(f"[{serial}] adb connect 失败: 输出={last_out}")
|
||||||
self._set_item(serial, "failed", f"adb connect 失败: {last_out[:150]}")
|
self._set_item(serial, "failed", f"adb connect 失败: {last_out[:150]}")
|
||||||
return
|
return
|
||||||
|
else:
|
||||||
|
# 本机 USB 设备:确认已在 adb devices 列表中
|
||||||
|
dev_out = _adb("devices")
|
||||||
|
if serial not in (dev_out or ""):
|
||||||
|
self._set_item(serial, "failed",
|
||||||
|
"设备未在本地 adb 列表中,请检查 USB 连接/驱动")
|
||||||
|
return
|
||||||
|
|
||||||
# 安装
|
# 安装
|
||||||
self._set_item(serial, "installing", "正在安装...")
|
self._set_item(serial, "installing", "正在安装...")
|
||||||
|
|||||||
@@ -115,6 +115,36 @@ def _remove_worker(serial):
|
|||||||
_WORKERS.pop(serial, None)
|
_WORKERS.pop(serial, None)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_worker_error(serial):
|
||||||
|
"""清除设备错误/失败状态,恢复为 idle。运行中/连接中返回 False 不清理。
|
||||||
|
|
||||||
|
检查与更新在同一把锁内完成,避免与任务启动写 connecting/running 的竞态。
|
||||||
|
"""
|
||||||
|
with _WORKERS_LOCK:
|
||||||
|
w = _WORKERS.get(serial)
|
||||||
|
if w is None:
|
||||||
|
return True # 本就没有状态记录,视作已清理
|
||||||
|
if w.get("status") in ("running", "connecting"):
|
||||||
|
return False
|
||||||
|
w.update(status="idle", last_error="", last_warning="", task_job="",
|
||||||
|
current_action="", attempt=0, progress={}, end_time=0,
|
||||||
|
last_heartbeat=time.time())
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def clear_all_worker_errors(exclude=()):
|
||||||
|
"""批量清除所有 error/failed 状态(exclude 里的跳过)。返回清除数。"""
|
||||||
|
exclude = set(exclude)
|
||||||
|
with _WORKERS_LOCK:
|
||||||
|
targets = [s for s, w in _WORKERS.items()
|
||||||
|
if s not in exclude and w.get("status") in ("error", "failed")]
|
||||||
|
cleared = 0
|
||||||
|
for s in targets:
|
||||||
|
if clear_worker_error(s):
|
||||||
|
cleared += 1
|
||||||
|
return cleared
|
||||||
|
|
||||||
|
|
||||||
def get_worker_heartbeat(serial):
|
def get_worker_heartbeat(serial):
|
||||||
"""返回 worker 最近一次心跳时间,没有返回 0。"""
|
"""返回 worker 最近一次心跳时间,没有返回 0。"""
|
||||||
with _WORKERS_LOCK:
|
with _WORKERS_LOCK:
|
||||||
|
|||||||
+27
-2
@@ -45,11 +45,35 @@ def _sqlite_pragma(dbapi_connection, connection_record):
|
|||||||
|
|
||||||
|
|
||||||
class User(UserMixin, db.Model):
|
class User(UserMixin, db.Model):
|
||||||
"""后台用户。"""
|
"""后台用户。
|
||||||
|
|
||||||
|
权限模型:
|
||||||
|
- is_admin=True:管理员,拥有全部权限,不受 perms 限制
|
||||||
|
- is_admin=False:按 perms 授权(JSON 数组,如 ["tasks","devices"])
|
||||||
|
- 权限位:tasks=任务管理, devices=设备控制, apks=应用管理, logs=日志查看
|
||||||
|
- 用户管理本身只有管理员能做(web_server 层强制),不设普通权限位
|
||||||
|
"""
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
username = db.Column(db.String(80), unique=True, nullable=False)
|
username = db.Column(db.String(80), unique=True, nullable=False)
|
||||||
password_hash = db.Column(db.String(255), nullable=False)
|
password_hash = db.Column(db.String(255), nullable=False)
|
||||||
is_admin = db.Column(db.Boolean, default=True)
|
is_admin = db.Column(db.Boolean, default=True)
|
||||||
|
perms = db.Column(db.Text, default="[]") # JSON 数组:业务权限位
|
||||||
|
|
||||||
|
def get_perms(self):
|
||||||
|
try:
|
||||||
|
return json.loads(self.perms or "[]")
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def set_perms(self, lst):
|
||||||
|
"""设置权限位。管理员强制为全部权限(内部不区分)。"""
|
||||||
|
self.perms = json.dumps(list(lst or []), ensure_ascii=False)
|
||||||
|
|
||||||
|
def has_perm(self, perm):
|
||||||
|
"""是否拥有指定权限。管理员恒为 True。"""
|
||||||
|
if self.is_admin:
|
||||||
|
return True
|
||||||
|
return perm in self.get_perms()
|
||||||
|
|
||||||
def set_password(self, password):
|
def set_password(self, password):
|
||||||
self.password_hash = generate_password_hash(password)
|
self.password_hash = generate_password_hash(password)
|
||||||
@@ -195,7 +219,8 @@ class ApkFile(db.Model):
|
|||||||
# 版本化 schema 迁移:新增结构变更时在此追加 (版本号, 说明, SQL)
|
# 版本化 schema 迁移:新增结构变更时在此追加 (版本号, 说明, SQL)
|
||||||
# 版本号单调递增,只执行比当前 schema_version 新的迁移。
|
# 版本号单调递增,只执行比当前 schema_version 新的迁移。
|
||||||
SCHEMA_MIGRATIONS = [
|
SCHEMA_MIGRATIONS = [
|
||||||
# (1, "初始 schema(由 create_all 建立,版本标记从 1 开始)", None),
|
(1, "用户权限位:user 表新增 perms 列(JSON 数组,默认空=无业务权限,管理员不受限)",
|
||||||
|
"ALTER TABLE user ADD COLUMN perms TEXT DEFAULT '[]'"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+85
-1
@@ -21,6 +21,7 @@ import json
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
import threading
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
@@ -45,6 +46,61 @@ _log = get_logger("core.tm")
|
|||||||
_START_STAGGER_SEC = 0.2
|
_START_STAGGER_SEC = 0.2
|
||||||
|
|
||||||
|
|
||||||
|
def _in_run_window(schedule, now=None):
|
||||||
|
"""是否在当前运行窗口内。
|
||||||
|
|
||||||
|
schedule.window = {"start": "HH:MM", "end": "HH:MM"},每天重复;
|
||||||
|
窗口外(定时触发 + 手动执行)任务不会启动。未配置/非法配置视为不限制。
|
||||||
|
支持跨午夜(如 21:00-09:00 = 晚上 9 点运行到次日早 9 点)。
|
||||||
|
"""
|
||||||
|
win = (schedule or {}).get("window") or {}
|
||||||
|
start, end = win.get("start", ""), win.get("end", "")
|
||||||
|
if not start or not end:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
cur = (now or datetime.now()).hour * 60 + (now or datetime.now()).minute
|
||||||
|
s = int(start.split(":")[0]) * 60 + int(start.split(":")[1])
|
||||||
|
e = int(end.split(":")[0]) * 60 + int(end.split(":")[1])
|
||||||
|
except (ValueError, AttributeError, IndexError):
|
||||||
|
return True # 配置非法按不限制处理
|
||||||
|
if s == e:
|
||||||
|
return True # 起止相同视为不限制
|
||||||
|
if s < e:
|
||||||
|
return s <= cur < e
|
||||||
|
return cur >= s or cur < e # 跨午夜
|
||||||
|
|
||||||
|
|
||||||
|
def _next_run_time(schedule, now=None):
|
||||||
|
"""任务下次真正执行的时间(考虑运行窗口)。
|
||||||
|
|
||||||
|
用 CronTrigger.get_next_fire_time 从当前时间向后找触发点,
|
||||||
|
跳过运行窗口外的触发点(最多找 200 次防死循环)。
|
||||||
|
返回 datetime 或 None(无 cron / 配置非法)。
|
||||||
|
"""
|
||||||
|
sched = schedule or {}
|
||||||
|
if sched.get("mode") not in ("cron", "cron_stop"):
|
||||||
|
return None
|
||||||
|
cron = sched.get("cron", "")
|
||||||
|
if not cron:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
trigger = CronTrigger.from_crontab(cron)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
now = now or datetime.now()
|
||||||
|
win = sched.get("window") or {}
|
||||||
|
if not win.get("start") or not win.get("end"):
|
||||||
|
return trigger.get_next_fire_time(None, now)
|
||||||
|
fire = trigger.get_next_fire_time(None, now)
|
||||||
|
for _ in range(200):
|
||||||
|
if fire is None:
|
||||||
|
return None
|
||||||
|
if _in_run_window({"window": win}, fire):
|
||||||
|
return fire
|
||||||
|
fire = trigger.get_next_fire_time(fire, fire)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ================== 设备分组 ==================
|
# ================== 设备分组 ==================
|
||||||
class DeviceGroup:
|
class DeviceGroup:
|
||||||
def __init__(self, name, serials=None, description=""):
|
def __init__(self, name, serials=None, description=""):
|
||||||
@@ -402,6 +458,12 @@ class TaskManager:
|
|||||||
if name in self.groups:
|
if name in self.groups:
|
||||||
del self.groups[name]
|
del self.groups[name]
|
||||||
self._save_groups()
|
self._save_groups()
|
||||||
|
# 同上:显式删数据库行,避免删除的分组重启后复活
|
||||||
|
with self._db():
|
||||||
|
row = GroupRow.query.filter_by(name=name).first()
|
||||||
|
if row:
|
||||||
|
db.session.delete(row)
|
||||||
|
db.session.commit()
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -436,6 +498,13 @@ class TaskManager:
|
|||||||
if job_id in self.jobs:
|
if job_id in self.jobs:
|
||||||
del self.jobs[job_id]
|
del self.jobs[job_id]
|
||||||
self._save_jobs()
|
self._save_jobs()
|
||||||
|
# _save_jobs 只做 upsert 不会删行:必须显式删数据库行,
|
||||||
|
# 否则删除的任务重启后会从数据库重新加载回来
|
||||||
|
with self._db():
|
||||||
|
row = JobRow.query.get(job_id)
|
||||||
|
if row:
|
||||||
|
db.session.delete(row)
|
||||||
|
db.session.commit()
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -504,6 +573,11 @@ class TaskManager:
|
|||||||
job = self.jobs.get(job_id)
|
job = self.jobs.get(job_id)
|
||||||
if not job:
|
if not job:
|
||||||
return
|
return
|
||||||
|
if not _in_run_window(job.schedule):
|
||||||
|
win = job.schedule.get("window") or {}
|
||||||
|
_log.info(f"定时触发 {job.name}({job.id}) 跳过:当前不在运行窗口内 "
|
||||||
|
f"({win.get('start','')}-{win.get('end','')})")
|
||||||
|
return
|
||||||
_log.info(f"定时触发: {job.name}({job.id})")
|
_log.info(f"定时触发: {job.name}({job.id})")
|
||||||
self._run_job(job)
|
self._run_job(job)
|
||||||
|
|
||||||
@@ -526,11 +600,21 @@ class TaskManager:
|
|||||||
stopped.append(serial)
|
stopped.append(serial)
|
||||||
_log.info(f"定时停止 {job.name}({job.id}): 停止 {len(stopped)} 台设备 {stopped}")
|
_log.info(f"定时停止 {job.name}({job.id}): 停止 {len(stopped)} 台设备 {stopped}")
|
||||||
|
|
||||||
|
def next_run_of(self, job):
|
||||||
|
"""任务下次真正执行的时间(考虑运行窗口)。停用/无 cron 返回 None。"""
|
||||||
|
if not job or not job.enabled:
|
||||||
|
return None
|
||||||
|
return _next_run_time(job.schedule)
|
||||||
|
|
||||||
def run_job_now(self, job_id):
|
def run_job_now(self, job_id):
|
||||||
"""立即执行任务(手动触发)。"""
|
"""立即执行任务(手动触发)。运行窗口外拒绝启动。"""
|
||||||
job = self.jobs.get(job_id)
|
job = self.jobs.get(job_id)
|
||||||
if not job:
|
if not job:
|
||||||
return {"ok": False, "error": "任务不存在"}
|
return {"ok": False, "error": "任务不存在"}
|
||||||
|
if not _in_run_window(job.schedule):
|
||||||
|
win = job.schedule.get("window") or {}
|
||||||
|
return {"ok": False,
|
||||||
|
"error": f"当前不在运行窗口内({win.get('start', '')}-{win.get('end', '')}),任务未启动"}
|
||||||
# 在独立线程跑,不阻塞调用方
|
# 在独立线程跑,不阻塞调用方
|
||||||
t = threading.Thread(target=self._run_job, args=(job,), daemon=True)
|
t = threading.Thread(target=self._run_job, args=(job,), daemon=True)
|
||||||
t.start()
|
t.start()
|
||||||
|
|||||||
+90
-39
@@ -12,6 +12,7 @@ API 参考(uiautodev 0.14):
|
|||||||
- GET /api/android/{serial}/dump_hierarchy — 元素树 JSON
|
- GET /api/android/{serial}/dump_hierarchy — 元素树 JSON
|
||||||
"""
|
"""
|
||||||
import requests
|
import requests
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
from core.logger import get_logger
|
from core.logger import get_logger
|
||||||
|
|
||||||
@@ -78,12 +79,23 @@ def get_elements(serial):
|
|||||||
"""获取指定设备的 UI 元素树。
|
"""获取指定设备的 UI 元素树。
|
||||||
|
|
||||||
返回 (ok, data_or_error):
|
返回 (ok, data_or_error):
|
||||||
ok=True — data 是元素列表 [{name, attrs:{...}, children:[...]}, ...]
|
ok=True — data 是元素列表 [{name, attrs..., suggested:{type,value,indexed?,occ?,total?,broad?}}, ...]
|
||||||
ok=False — data 是错误消息字符串
|
ok=False — data 是错误消息字符串
|
||||||
|
|
||||||
元素树由 uiautodev 的 U2AndroidDriver.dump_hierarchy 返回(JSON)。
|
元素树由 uiautodev 的 dump_hierarchy 返回(JSON)。我们递归提取每个节点的
|
||||||
我们递归提取每个节点的关键属性(resource-id/text/description/class/...),
|
关键属性(resource-id/text/description/class/bounds...),供前端列表展示和选择。
|
||||||
供前端列表展示和选择。
|
|
||||||
|
选择器精度策略(精确到具体按钮的关键):
|
||||||
|
1. 有 resource-id/text/content-desc 的元素:
|
||||||
|
先预统计该属性在整棵树中的出现次数——
|
||||||
|
- 唯一出现:直接用属性选择器 //*[@resource-id="x"]
|
||||||
|
- 重复出现(如抖音信息流点赞按钮同 id 几十个):附加 [k] 位置谓词
|
||||||
|
精确到具体实例 //*[@resource-id="x"][k]。uiautomator2 的 d.xpath()
|
||||||
|
底层是 lxml 标准 XPath,[k] 与抓取时同一语义,不会误中屏幕外第一个。
|
||||||
|
2. 无任何属性的元素:
|
||||||
|
用最近一个有属性祖先的选择器限定范围 + 同 class 兄弟序号定位
|
||||||
|
(如 //*[@resource-id="x"]/FrameLayout/ImageView[2]);
|
||||||
|
整棵树都没有属性时退化为从根开始的结构路径(标记 broad,前端提示脆弱)。
|
||||||
"""
|
"""
|
||||||
if not serial:
|
if not serial:
|
||||||
return False, "缺少 serial"
|
return False, "缺少 serial"
|
||||||
@@ -99,7 +111,7 @@ def get_elements(serial):
|
|||||||
# uiautodev hierarchy 返回 Node 树:{key, name, bounds, rect, properties, children}
|
# uiautodev hierarchy 返回 Node 树:{key, name, bounds, rect, properties, children}
|
||||||
# 提取成扁平的可选列表(保留层级缩进信息)
|
# 提取成扁平的可选列表(保留层级缩进信息)
|
||||||
elements = []
|
elements = []
|
||||||
_flatten(data, elements, depth=0)
|
_extract(data, elements)
|
||||||
if not elements:
|
if not elements:
|
||||||
return False, "当前界面未抓取到元素"
|
return False, "当前界面未抓取到元素"
|
||||||
return True, elements
|
return True, elements
|
||||||
@@ -112,16 +124,50 @@ def get_elements(serial):
|
|||||||
return False, f"抓取失败: {e}"
|
return False, f"抓取失败: {e}"
|
||||||
|
|
||||||
|
|
||||||
def _flatten(node, out, depth=0, path="", ctx=None, tag_index=0):
|
def _xpath_q(v):
|
||||||
"""递归扁平化元素树,提取关键属性供前端选择。
|
"""XPath 字符串字面量:优先双引号,值含双引号时改用单引号包裹(XPath 1.0 无转义)。"""
|
||||||
|
if '"' in v:
|
||||||
|
return "'" + v + "'"
|
||||||
|
return '"' + v + '"'
|
||||||
|
|
||||||
uiautodev dump_hierarchy 返回 Node 格式:
|
|
||||||
{key, name, bounds(归一化浮点), rect({x,y,width,height}), properties({所有属性}), children}
|
|
||||||
其中 properties 包含原始 XML 属性(resource-id/text/content-desc/class/bounds 字符串等)。
|
|
||||||
|
|
||||||
ctx: 最近一个有唯一属性(resource-id/text/content-desc)祖先的 xpath。
|
def _extract(root, out):
|
||||||
无唯一属性的元素用它限定范围生成精确选择器,避免裸 class(如 //ImageView)过宽误点。
|
"""把 uiautodev 元素树扁平化为可选列表,并为每个节点生成精确选择器建议。"""
|
||||||
tag_index: 本节点在父节点同 class 兄弟中的序号(1-based),用于消歧。
|
# 全树属性出现次数(预统计,供重复元素加 [k] 序号消歧)
|
||||||
|
id_cnt, text_cnt, desc_cnt = Counter(), Counter(), Counter()
|
||||||
|
# 文档顺序已出现次数(决定当前元素是第几个)
|
||||||
|
seen_id, seen_text, seen_desc = Counter(), Counter(), Counter()
|
||||||
|
|
||||||
|
def count_attrs(node):
|
||||||
|
if not isinstance(node, dict):
|
||||||
|
return
|
||||||
|
props = node.get("properties") or {}
|
||||||
|
rid, text, desc = (props.get(k, "") for k in ("resource-id", "text", "content-desc"))
|
||||||
|
if rid:
|
||||||
|
id_cnt[rid] += 1
|
||||||
|
if text:
|
||||||
|
text_cnt[text] += 1
|
||||||
|
if desc:
|
||||||
|
desc_cnt[desc] += 1
|
||||||
|
for c in node.get("children") or []:
|
||||||
|
count_attrs(c)
|
||||||
|
|
||||||
|
def attr_selector(attr, value, cnt, seen):
|
||||||
|
"""属性选择器:唯一直接出,重复加 [k] 位置谓词。返回 (选择器, suggested)。"""
|
||||||
|
seen[value] += 1
|
||||||
|
val = f'//*[@{attr}={_xpath_q(value)}]'
|
||||||
|
if cnt[value] > 1:
|
||||||
|
val += f"[{seen[value]}]"
|
||||||
|
return val, {"type": "xpath", "value": val,
|
||||||
|
"indexed": True, "occ": seen[value], "total": cnt[value]}
|
||||||
|
return val, {"type": "xpath", "value": val}
|
||||||
|
|
||||||
|
def flatten(node, depth=0, path="", ctx=None, tag_path="", tag_index=1):
|
||||||
|
"""递归扁平化。
|
||||||
|
|
||||||
|
ctx: 最近一个有属性祖先的选择器(含序号),无属性元素用它限定范围。
|
||||||
|
tag_path: 从根到本节点的完整结构路径(class + 同class兄弟序号),兜底用。
|
||||||
|
tag_index: 本节点在父下同 class 兄弟中的序号(1-based)。
|
||||||
"""
|
"""
|
||||||
if not isinstance(node, dict):
|
if not isinstance(node, dict):
|
||||||
return
|
return
|
||||||
@@ -130,36 +176,41 @@ def _flatten(node, out, depth=0, path="", ctx=None, tag_index=0):
|
|||||||
# bounds:优先取 properties 中的原始字符串 "[x1,y1][x2,y2]"(前端渲染 overlay 用)
|
# bounds:优先取 properties 中的原始字符串 "[x1,y1][x2,y2]"(前端渲染 overlay 用)
|
||||||
bounds_str = props.get("bounds", "")
|
bounds_str = props.get("bounds", "")
|
||||||
if not bounds_str and node.get("rect"):
|
if not bounds_str and node.get("rect"):
|
||||||
|
# rect 兜底:只有整数像素坐标才用;uiautodev 归一化浮点坐标无法换算像素
|
||||||
r = node["rect"]
|
r = node["rect"]
|
||||||
bounds_str = f"[{r['x']},{r['y']}][{r['x']+r['width']},{r['y']+r['height']}]"
|
try:
|
||||||
|
vals = [r["x"], r["y"], r["x"] + r["width"], r["y"] + r["height"]]
|
||||||
|
if all(isinstance(v, (int, float)) and float(v).is_integer() for v in vals):
|
||||||
|
bounds_str = f"[{int(vals[0])},{int(vals[1])}][{int(vals[2])},{int(vals[3])}]"
|
||||||
|
except (TypeError, KeyError):
|
||||||
|
pass
|
||||||
|
|
||||||
rid = props.get("resource-id", "")
|
rid = props.get("resource-id", "")
|
||||||
text = props.get("text", "")
|
text = props.get("text", "")
|
||||||
desc = props.get("content-desc", "")
|
desc = props.get("content-desc", "")
|
||||||
cls = props.get("class", "")
|
cls = props.get("class", "")
|
||||||
tag = cls.split(".")[-1] if cls else "*"
|
tag = cls.split(".")[-1] if cls else "*"
|
||||||
|
# 始终带同 class 兄弟序号(含 [1]),结构路径才精确无歧义
|
||||||
|
seg = f"{tag}[{tag_index}]"
|
||||||
|
own_tag_path = f"{tag_path}/{seg}" if tag_path else seg
|
||||||
|
|
||||||
# 推荐选择器:唯一属性优先;否则用最近锚点祖先限定范围(带同class序号消歧)
|
# ---- 推荐选择器:唯一属性 > 锚点祖先限定 > 全结构路径 ----
|
||||||
if rid:
|
if rid:
|
||||||
suggested = {"type": "xpath", "value": f'//*[@resource-id="{rid}"]'}
|
child_ctx, suggested = attr_selector("resource-id", rid, id_cnt, seen_id)
|
||||||
child_ctx = suggested["value"]
|
|
||||||
elif text:
|
elif text:
|
||||||
suggested = {"type": "xpath", "value": f'//*[@text="{text}"]'}
|
child_ctx, suggested = attr_selector("text", text, text_cnt, seen_text)
|
||||||
child_ctx = suggested["value"]
|
|
||||||
elif desc:
|
elif desc:
|
||||||
suggested = {"type": "xpath", "value": f'//*[@content-desc="{desc}"]'}
|
child_ctx, suggested = attr_selector("content-desc", desc, desc_cnt, seen_desc)
|
||||||
child_ctx = suggested["value"]
|
elif ctx:
|
||||||
|
val = f"{ctx}/{seg}"
|
||||||
|
suggested = {"type": "xpath", "value": val}
|
||||||
|
child_ctx = val
|
||||||
else:
|
else:
|
||||||
if ctx:
|
# 无唯一属性且无锚点祖先:用从根开始的结构路径(脆弱,标记 broad 让前端提示)
|
||||||
seg = f"/{tag}" + (f"[{tag_index}]" if tag_index > 1 else "")
|
suggested = {"type": "xpath", "value": f"//{own_tag_path}", "broad": True}
|
||||||
suggested = {"type": "xpath", "value": f"{ctx}{seg}"}
|
|
||||||
child_ctx = f"{ctx}{seg}"
|
|
||||||
else:
|
|
||||||
# 无唯一属性且无锚点祖先:裸 class 过宽,标记 broad 让前端提示
|
|
||||||
suggested = {"type": "xpath", "value": f"//{tag}", "broad": True}
|
|
||||||
child_ctx = None
|
child_ctx = None
|
||||||
|
|
||||||
item = {
|
out.append({
|
||||||
"depth": depth,
|
"depth": depth,
|
||||||
"path": path,
|
"path": path,
|
||||||
"name": name,
|
"name": name,
|
||||||
@@ -170,20 +221,20 @@ def _flatten(node, out, depth=0, path="", ctx=None, tag_index=0):
|
|||||||
"package": props.get("package", ""),
|
"package": props.get("package", ""),
|
||||||
"clickable": props.get("clickable", ""),
|
"clickable": props.get("clickable", ""),
|
||||||
"bounds": bounds_str,
|
"bounds": bounds_str,
|
||||||
# 推荐选择器(resource-id > text > description > 锚点祖先限定class)
|
|
||||||
"suggested": suggested,
|
"suggested": suggested,
|
||||||
}
|
})
|
||||||
out.append(item)
|
|
||||||
# 递归子节点,同时计算每个子节点在父下同 class 兄弟中的序号
|
# 递归子节点,同时计算每个子节点在父下同 class 兄弟中的序号
|
||||||
children = node.get("children") or []
|
children = node.get("children") or []
|
||||||
for i, child in enumerate(children):
|
for i, child in enumerate(children):
|
||||||
cprops = child.get("properties") or {}
|
cprops = child.get("properties") or {}
|
||||||
ccls = cprops.get("class", "")
|
ccls = cprops.get("class", "")
|
||||||
ctag = ccls.split(".")[-1] if ccls else "*"
|
ctag = ccls.split(".")[-1] if ccls else "*"
|
||||||
same = 1
|
same = 1 + sum(
|
||||||
for prev in children[:i]:
|
1 for prev in children[:i]
|
||||||
pcls = (prev.get("properties") or {}).get("class", "")
|
if ((prev.get("properties") or {}).get("class", "")).split(".")[-1] == ctag
|
||||||
ptag = pcls.split(".")[-1] if pcls else "*"
|
)
|
||||||
if ptag == ctag:
|
flatten(child, depth + 1, f"{path}/{i}", child_ctx, own_tag_path, same)
|
||||||
same += 1
|
|
||||||
_flatten(child, out, depth + 1, f"{path}/{i}", child_ctx, same)
|
count_attrs(root)
|
||||||
|
flatten(root)
|
||||||
|
|||||||
Binary file not shown.
+141
-28
@@ -14,6 +14,21 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
**权限模型**(v2 起):
|
||||||
|
- 所有接口需登录;**查看类 GET 接口**(状态/列表/截图)所有登录用户可用
|
||||||
|
- **写操作按权限位授权**(管理员拥有全部权限):
|
||||||
|
| 权限位 | 中文 | 覆盖接口 |
|
||||||
|
|--------|------|---------|
|
||||||
|
| `tasks` | 任务管理 | 任务/自定义动作/分组的增删改、启停、立即执行 |
|
||||||
|
| `devices` | 设备控制 | 停止设备、释放占用、清除异常、前台扫描、元素抓取 |
|
||||||
|
| `apks` | 应用管理 | APK 上传、安装、删除 |
|
||||||
|
| `logs` | 日志查看 | `GET /api/logs` |
|
||||||
|
- **用户管理接口仅管理员可用**(普通用户即使被授予业务权限也无法访问)
|
||||||
|
- 无权限访问返回 `403 {"ok": false, "error": "无权限执行此操作..."}`
|
||||||
|
- 当前用户权限查询:`GET /api/me`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 1. 认证
|
## 1. 认证
|
||||||
|
|
||||||
### POST /login
|
### POST /login
|
||||||
@@ -83,7 +98,7 @@
|
|||||||
|
|
||||||
**worker_status 取值**:`idle` / `connecting` / `running` / `done` / `error` / `failed` / `released`
|
**worker_status 取值**:`idle` / `connecting` / `running` / `done` / `error` / `failed` / `released`
|
||||||
|
|
||||||
### POST /api/scan_foreground
|
(权限:设备控制)
|
||||||
|
|
||||||
手动触发前台 App 扫描(后台异步执行,不打扰设备)。
|
手动触发前台 App 扫描(后台异步执行,不打扰设备)。
|
||||||
|
|
||||||
@@ -176,12 +191,14 @@
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"jobs": [{"id": "abc123", "name": "抖音养号", "task_type": "douyin_nurture", "...": "..."}],
|
"jobs": [{"id": "abc123", "name": "抖音养号", "task_type": "douyin_nurture",
|
||||||
|
"next_run": "2026-08-12 09:00", "...": "..."}],
|
||||||
"task_types": [...]
|
"task_types": [...]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
`next_run`:下次真正执行时间(已按运行窗口跳过窗口外触发点,格式 `YYYY-MM-DD HH:MM`);手动任务/已停用为 `null`。
|
||||||
|
|
||||||
### POST /api/jobs
|
(权限:任务管理)
|
||||||
|
|
||||||
创建任务计划。
|
创建任务计划。
|
||||||
|
|
||||||
@@ -192,7 +209,7 @@
|
|||||||
"task_type": "douyin_nurture",
|
"task_type": "douyin_nurture",
|
||||||
"target": {"mode": "all"},
|
"target": {"mode": "all"},
|
||||||
"params": {"watch_count": 50, "actions": {"like": {"params": {"rate": 0.5}}}},
|
"params": {"watch_count": 50, "actions": {"like": {"params": {"rate": 0.5}}}},
|
||||||
"schedule": {"mode": "once"},
|
"schedule": {"mode": "cron", "cron": "0 */2 * * *"},
|
||||||
"retry": {"max_attempts": 3, "delay": 60},
|
"retry": {"max_attempts": 3, "delay": 60},
|
||||||
"enabled": true
|
"enabled": true
|
||||||
}
|
}
|
||||||
@@ -203,7 +220,15 @@
|
|||||||
{"ok": true, "msg": "任务已创建", "job": {"id": "abc123", "...": "..."}}
|
{"ok": true, "msg": "任务已创建", "job": {"id": "abc123", "...": "..."}}
|
||||||
```
|
```
|
||||||
|
|
||||||
### PUT /api/jobs/:job_id
|
`schedule` 字段格式:
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `mode` | `once` 手动 / `cron` 定时启动 / `cron_stop` 定时启动+停止 |
|
||||||
|
| `cron` | 标准 5 段 cron:`分 时 日 月 周`(周 `0`/`7`=周日);如 `0 */2 * * *` 每 2 小时整点 |
|
||||||
|
| `stop_cron` | (cron_stop 必填)到点停止本任务 worker |
|
||||||
|
| `window` | 可选,运行窗口 `{"start": "21:00", "end": "09:00"}`(每天重复,支持跨午夜)。窗口外定时触发和手动执行(`POST /api/jobs/:id/run`)均不启动,手动执行返回错误提示 |
|
||||||
|
|
||||||
|
(权限:任务管理)
|
||||||
|
|
||||||
更新任务计划。只需传要更新的字段。
|
更新任务计划。只需传要更新的字段。
|
||||||
|
|
||||||
@@ -217,7 +242,7 @@
|
|||||||
{"ok": true, "msg": "任务已更新", "job": {"...": "..."}}
|
{"ok": true, "msg": "任务已更新", "job": {"...": "..."}}
|
||||||
```
|
```
|
||||||
|
|
||||||
### DELETE /api/jobs/:job_id
|
(权限:任务管理)
|
||||||
|
|
||||||
删除任务计划。
|
删除任务计划。
|
||||||
|
|
||||||
@@ -226,7 +251,7 @@
|
|||||||
{"ok": true, "msg": "任务已删除"}
|
{"ok": true, "msg": "任务已删除"}
|
||||||
```
|
```
|
||||||
|
|
||||||
### POST /api/jobs/:job_id/run
|
(权限:任务管理)
|
||||||
|
|
||||||
立即执行任务(异步,不阻塞)。
|
立即执行任务(异步,不阻塞)。
|
||||||
|
|
||||||
@@ -235,7 +260,7 @@
|
|||||||
{"ok": true, "msg": "任务 抖音养号 已触发"}
|
{"ok": true, "msg": "任务 抖音养号 已触发"}
|
||||||
```
|
```
|
||||||
|
|
||||||
### POST /api/jobs/:job_id/toggle
|
(权限:任务管理)
|
||||||
|
|
||||||
启用/停用任务。
|
启用/停用任务。
|
||||||
|
|
||||||
@@ -267,7 +292,7 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### POST /api/groups
|
(权限:任务管理)
|
||||||
|
|
||||||
创建分组。
|
创建分组。
|
||||||
|
|
||||||
@@ -276,7 +301,7 @@
|
|||||||
{"name": "A组", "serials": ["192.168.1.100:5555"], "description": "测试组"}
|
{"name": "A组", "serials": ["192.168.1.100:5555"], "description": "测试组"}
|
||||||
```
|
```
|
||||||
|
|
||||||
### PUT /api/groups/:name
|
(权限:任务管理)
|
||||||
|
|
||||||
更新分组。
|
更新分组。
|
||||||
|
|
||||||
@@ -285,7 +310,7 @@
|
|||||||
{"serials": ["192.168.1.100:5555", "192.168.1.101:5555"], "description": "更新描述"}
|
{"serials": ["192.168.1.100:5555", "192.168.1.101:5555"], "description": "更新描述"}
|
||||||
```
|
```
|
||||||
|
|
||||||
### DELETE /api/groups/:name
|
(权限:任务管理)
|
||||||
|
|
||||||
删除分组。
|
删除分组。
|
||||||
|
|
||||||
@@ -293,7 +318,7 @@
|
|||||||
|
|
||||||
## 7. 运行控制
|
## 7. 运行控制
|
||||||
|
|
||||||
### POST /api/stop_device
|
(权限:设备控制)
|
||||||
|
|
||||||
停止单台设备的 worker(并阻止后续重试)。
|
停止单台设备的 worker(并阻止后续重试)。
|
||||||
|
|
||||||
@@ -307,7 +332,7 @@
|
|||||||
{"ok": true, "msg": "已发送停止信号给 192.168.1.100:5555"}
|
{"ok": true, "msg": "已发送停止信号给 192.168.1.100:5555"}
|
||||||
```
|
```
|
||||||
|
|
||||||
### POST /api/stop_all
|
(权限:设备控制)
|
||||||
|
|
||||||
停止所有运行中的 worker。
|
停止所有运行中的 worker。
|
||||||
|
|
||||||
@@ -316,7 +341,7 @@
|
|||||||
{"ok": true, "stopped": ["192.168.1.100:5555", "192.168.1.101:5555"]}
|
{"ok": true, "stopped": ["192.168.1.100:5555", "192.168.1.101:5555"]}
|
||||||
```
|
```
|
||||||
|
|
||||||
### POST /api/release
|
(权限:设备控制)
|
||||||
|
|
||||||
释放当前账户占用的所有 STF 设备(清理用)。
|
释放当前账户占用的所有 STF 设备(清理用)。
|
||||||
|
|
||||||
@@ -329,7 +354,7 @@
|
|||||||
|
|
||||||
## 8. 用户管理
|
## 8. 用户管理
|
||||||
|
|
||||||
### GET /api/users
|
(仅管理员)
|
||||||
|
|
||||||
列出所有用户。
|
列出所有用户。
|
||||||
|
|
||||||
@@ -338,7 +363,7 @@
|
|||||||
{"ok": true, "users": [{"id": 1, "username": "admin", "is_admin": true}]}
|
{"ok": true, "users": [{"id": 1, "username": "admin", "is_admin": true}]}
|
||||||
```
|
```
|
||||||
|
|
||||||
### POST /api/users
|
(仅管理员)
|
||||||
|
|
||||||
创建用户。
|
创建用户。
|
||||||
|
|
||||||
@@ -347,7 +372,7 @@
|
|||||||
{"username": "user1", "password": "pass123", "is_admin": false}
|
{"username": "user1", "password": "pass123", "is_admin": false}
|
||||||
```
|
```
|
||||||
|
|
||||||
### PUT /api/users/:uid
|
(仅管理员)
|
||||||
|
|
||||||
更新用户(修改密码/管理员权限)。
|
更新用户(修改密码/管理员权限)。
|
||||||
|
|
||||||
@@ -356,7 +381,7 @@
|
|||||||
{"password": "newpass", "is_admin": true}
|
{"password": "newpass", "is_admin": true}
|
||||||
```
|
```
|
||||||
|
|
||||||
### DELETE /api/users/:uid
|
(仅管理员)
|
||||||
|
|
||||||
删除用户(不能删除 admin 和当前登录用户)。
|
删除用户(不能删除 admin 和当前登录用户)。
|
||||||
|
|
||||||
@@ -364,7 +389,7 @@
|
|||||||
|
|
||||||
## 9. 日志
|
## 9. 日志
|
||||||
|
|
||||||
### GET /api/logs
|
(权限:日志查看)
|
||||||
|
|
||||||
查看日志文件内容。
|
查看日志文件内容。
|
||||||
|
|
||||||
@@ -394,7 +419,7 @@
|
|||||||
|
|
||||||
列出所有自定义动作(步骤打包)。
|
列出所有自定义动作(步骤打包)。
|
||||||
|
|
||||||
### POST /api/custom_actions
|
(权限:任务管理)
|
||||||
|
|
||||||
创建自定义动作。
|
创建自定义动作。
|
||||||
|
|
||||||
@@ -403,11 +428,11 @@
|
|||||||
{"name": "登录流程", "icon": "📦", "steps": [{"type": "click", "...": "..."}]}
|
{"name": "登录流程", "icon": "📦", "steps": [{"type": "click", "...": "..."}]}
|
||||||
```
|
```
|
||||||
|
|
||||||
### PUT /api/custom_actions/:action_id
|
(权限:任务管理)
|
||||||
|
|
||||||
更新自定义动作。
|
更新自定义动作。
|
||||||
|
|
||||||
### DELETE /api/custom_actions/:action_id
|
(权限:任务管理)
|
||||||
|
|
||||||
删除自定义动作。
|
删除自定义动作。
|
||||||
|
|
||||||
@@ -424,7 +449,7 @@
|
|||||||
{"ok": true, "running": true}
|
{"ok": true, "running": true}
|
||||||
```
|
```
|
||||||
|
|
||||||
### GET /api/uiauto/devices
|
(权限:设备控制)
|
||||||
|
|
||||||
获取 uiauto2 已连接的设备列表。
|
获取 uiauto2 已连接的设备列表。
|
||||||
|
|
||||||
@@ -433,7 +458,7 @@
|
|||||||
{"ok": true, "devices": [{"serial": "192.168.1.100:5555", "model": "Pixel 6"}]}
|
{"ok": true, "devices": [{"serial": "192.168.1.100:5555", "model": "Pixel 6"}]}
|
||||||
```
|
```
|
||||||
|
|
||||||
### GET /api/uiauto/screenshot
|
(权限:设备控制)
|
||||||
|
|
||||||
通过 uiauto2 获取设备截图(JPEG)。
|
通过 uiauto2 获取设备截图(JPEG)。
|
||||||
|
|
||||||
@@ -444,7 +469,7 @@
|
|||||||
|
|
||||||
**响应**:成功返回 `image/jpeg`,失败返回 JSON 错误。
|
**响应**:成功返回 `image/jpeg`,失败返回 JSON 错误。
|
||||||
|
|
||||||
### GET /api/uiauto/elements
|
(权限:设备控制)
|
||||||
|
|
||||||
获取设备 UI 元素树。
|
获取设备 UI 元素树。
|
||||||
|
|
||||||
@@ -498,7 +523,7 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### POST /api/apks/upload
|
(权限:应用管理)
|
||||||
|
|
||||||
上传 APK 文件(自动解析包名/版本/应用名)。
|
上传 APK 文件(自动解析包名/版本/应用名)。
|
||||||
|
|
||||||
@@ -512,11 +537,11 @@
|
|||||||
{"ok": true, "apk": {"...": "..."}, "msg": "上传成功: 抖音"}
|
{"ok": true, "apk": {"...": "..."}, "msg": "上传成功: 抖音"}
|
||||||
```
|
```
|
||||||
|
|
||||||
### DELETE /api/apks/:apk_id
|
(权限:应用管理)
|
||||||
|
|
||||||
删除 APK 文件和记录。
|
删除 APK 文件和记录。
|
||||||
|
|
||||||
### POST /api/apks/install
|
(权限:应用管理)
|
||||||
|
|
||||||
批量安装 APK 到指定设备。
|
批量安装 APK 到指定设备。
|
||||||
|
|
||||||
@@ -530,6 +555,19 @@
|
|||||||
{"ok": true, "msg": "开始安装 抖音 到 2 台设备"}
|
{"ok": true, "msg": "开始安装 抖音 到 2 台设备"}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### GET /api/apks/install/devices
|
||||||
|
|
||||||
|
可安装设备列表:STF 在线设备池 + 本机 adb 设备(**含 USB 有线连接**,serial 无冒号标记 `usb`)。
|
||||||
|
安装弹窗用此列表,USB 设备安装时跳过 adb connect 直接安装。
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
```json
|
||||||
|
{"ok": true, "devices": [
|
||||||
|
{"serial": "100.100.10.11:5555", "model": "22120RN86C", "source": "stf"},
|
||||||
|
{"serial": "ZY322ABCDEF", "model": "", "source": "usb"}
|
||||||
|
]}
|
||||||
|
```
|
||||||
|
|
||||||
### GET /api/apks/install/status
|
### GET /api/apks/install/status
|
||||||
|
|
||||||
获取安装任务实时状态。
|
获取安装任务实时状态。
|
||||||
@@ -554,3 +592,78 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 维护(仅管理员)
|
||||||
|
|
||||||
|
维护页两个接口都**仅管理员可用**(普通用户即使有业务权限也访问不了,返回 403)。
|
||||||
|
|
||||||
|
### POST /api/stf/restart
|
||||||
|
|
||||||
|
一键重启 STF Docker 容器。通过 SSH 到部署机执行 `docker restart`,随后轮询 STF API 确认恢复。
|
||||||
|
|
||||||
|
- 目标与容器名:`STF_SSH_TARGET`(默认 `[email protected]`)、`STF_DOCKER_CONTAINER`(默认 `stf`)环境变量配置
|
||||||
|
- 前置条件:本机可免密 SSH 到部署机
|
||||||
|
- **有任务在运行时返回 409 拒绝执行**(STF 重启会重置设备占用状态)
|
||||||
|
- 失败返回 502(SSH 不可达/容器名错误);执行超时返回 408
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
```json
|
||||||
|
{"ok": true, "msg": "STF 容器已重启", "detail": "stf: Up 3 seconds", "api_alive": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/adb/devices
|
||||||
|
|
||||||
|
维护终端设备列表:本地 adb 已连接(含 offline)+ STF 在线设备池(标记 `stf`)。
|
||||||
|
供终端设备选择器使用——选中后前端自动附加 `-s <serial>`。
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
```json
|
||||||
|
{"ok": true, "devices": [
|
||||||
|
{"serial": "100.100.10.11:5555", "state": "device"},
|
||||||
|
{"serial": "100.100.10.13:5555", "state": "offline"},
|
||||||
|
{"serial": "100.100.10.12:5555", "state": "stf"}
|
||||||
|
]}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/adb/cmd
|
||||||
|
|
||||||
|
adb 远程终端:用平台 adb 二进制执行任意 adb 命令(20s 超时)。
|
||||||
|
|
||||||
|
**请求**(JSON):
|
||||||
|
```json
|
||||||
|
{"cmd": "adb -s 100.100.10.11:5555 shell ls /sdcard"}
|
||||||
|
```
|
||||||
|
(开头的 `adb` 前缀可省略)
|
||||||
|
|
||||||
|
**安全红线**:包含 `kill-server` / `disconnect` 的命令直接拒绝(400)——会断开 STF provider 共享的 adb transport,导致全部设备被 STF 误判离线。
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
```json
|
||||||
|
{"ok": true, "stdout": "...", "stderr": "", "code": 0}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 步骤测试(仅管理员可触发,需"设备控制"权限)
|
||||||
|
|
||||||
|
### POST /api/steps/test
|
||||||
|
|
||||||
|
在指定设备上单步试执行(步骤编辑器"测试此步骤"按钮),验证选择器是否命中。
|
||||||
|
只读连接(adb connect + u2),不占用/释放 STF,与运行中任务互不干扰。
|
||||||
|
|
||||||
|
**请求**(JSON):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"serial": "100.100.10.11:5555",
|
||||||
|
"step": {"type": "click", "label": "测试", "params": {"selector_type": "xpath", "selector_value": "//*[@resource-id=\"x\"]"}}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
```json
|
||||||
|
{"ok": true, "msg": "步骤已执行(测试)", "result": "命中"}
|
||||||
|
```
|
||||||
|
`result`:`命中` / `未找到` / `已执行`(无选择器命中语义的步骤)。
|
||||||
|
无"设备控制"权限返回 403。
|
||||||
|
|||||||
+11
-1
@@ -185,7 +185,7 @@ SQLAlchemy 模型,存于 `data/users.db`:
|
|||||||
|
|
||||||
| 模型 | 表名 | 说明 |
|
| 模型 | 表名 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `User` | user | 后台用户(Flask-Login 认证,SHA256 密码) |
|
| `User` | user | 后台用户(Flask-Login 认证,SHA256 密码;`is_admin` 管理员 + `perms` 业务权限位) |
|
||||||
| `DeviceGroup` | device_group | 设备分组(serials 存 JSON) |
|
| `DeviceGroup` | device_group | 设备分组(serials 存 JSON) |
|
||||||
| `TaskJob` | task_job | 任务计划(target/params/schedule/retry 存 JSON) |
|
| `TaskJob` | task_job | 任务计划(target/params/schedule/retry 存 JSON) |
|
||||||
| `CustomAction` | custom_action | 自定义动作(步骤打包,steps 存 JSON) |
|
| `CustomAction` | custom_action | 自定义动作(步骤打包,steps 存 JSON) |
|
||||||
@@ -195,6 +195,16 @@ SQLAlchemy 模型,存于 `data/users.db`:
|
|||||||
- 创建所有表
|
- 创建所有表
|
||||||
- 首次启动创建默认管理员 `admin/admin123`
|
- 首次启动创建默认管理员 `admin/admin123`
|
||||||
- 自动迁移旧 `groups.json` / `jobs.json` 到 SQLite(迁移后归档为 `.migrated`)
|
- 自动迁移旧 `groups.json` / `jobs.json` 到 SQLite(迁移后归档为 `.migrated`)
|
||||||
|
- 版本化 schema 迁移(`SCHEMA_MIGRATIONS`):结构变更必须追加迁移条目,`create_all` 只建新表不加列
|
||||||
|
|
||||||
|
### 权限模型
|
||||||
|
|
||||||
|
- `User.perms` 存业务权限位 JSON 数组(`tasks`/`devices`/`apks`/`logs`),`is_admin=true` 拥有全部权限(`has_perm` 短路)
|
||||||
|
- 后端统一用 `@perm_required(PERM_X)` / `@admin_required` 装饰器拦截(web_server.py),无权限返回 403;
|
||||||
|
查看类 GET 接口只要求登录;用户管理、维护接口(STF 重启 `/api/stf/restart`、adb 终端 `/api/adb/cmd`)强制 `admin_required`
|
||||||
|
- adb 终端安全红线:拒绝 `kill-server` / `disconnect`(STF provider 共享 adb transport,断开会误判全设备离线)
|
||||||
|
- 前端 `loadMe()` 拉取 `/api/me`,用 `data-perm` 属性隐藏无权限的 tab/按钮,行内按钮用 `_can(perm)` 判断
|
||||||
|
- 安全兜底:**前端隐藏只是 UX,权限强制在后端**;新增路由时按"写操作必须带权限装饰器"的约定
|
||||||
|
|
||||||
### 3.7 APK 管理(`core/apk_manager.py`)
|
### 3.7 APK 管理(`core/apk_manager.py`)
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ STF_TOKEN = "你的STF_API_Token"
|
|||||||
| `WEB_HOST` | `0.0.0.0` | 仅本机访问改为 `127.0.0.1` |
|
| `WEB_HOST` | `0.0.0.0` | 仅本机访问改为 `127.0.0.1` |
|
||||||
| `WEB_PORT` | `18050` | 端口冲突时修改 |
|
| `WEB_PORT` | `18050` | 端口冲突时修改 |
|
||||||
| `ADB_PATH` | 自动识别 | 用自定义 adb 时修改 |
|
| `ADB_PATH` | 自动识别 | 用自定义 adb 时修改 |
|
||||||
|
| `STF_SSH_TARGET` | `[email protected]` | 维护页"一键重启 STF 容器"的 SSH 目标(需免密登录) |
|
||||||
|
| `STF_DOCKER_CONTAINER` | `stf` | 220 上 STF Docker 容器名(`docker ps` 查看) |
|
||||||
|
|
||||||
|
> **维护页 STF 重启前置**:本机需能免密 SSH 到部署机(把本机公钥加入目标机 `authorized_keys`),
|
||||||
|
> 且 `STF_DOCKER_CONTAINER` 与真实容器名一致;未配置好时按钮会返回明确错误。
|
||||||
|
|
||||||
### 2.4 启动服务
|
### 2.4 启动服务
|
||||||
|
|
||||||
@@ -196,6 +201,8 @@ server {
|
|||||||
### 3.3 安全加固
|
### 3.3 安全加固
|
||||||
|
|
||||||
- **修改默认密码**:登录后立即在"用户"Tab 修改 admin 密码
|
- **修改默认密码**:登录后立即在"用户"Tab 修改 admin 密码
|
||||||
|
- **最小权限分配**:需要多人使用后台时,在"用户"Tab 创建普通用户并只勾选必要权限
|
||||||
|
(任务管理/设备控制/应用管理/日志查看),不要把 admin 密码共享出去
|
||||||
- **修改 SECRET_KEY**:编辑 `web_server.py`,把 `app.config["SECRET_KEY"]` 改成随机字符串
|
- **修改 SECRET_KEY**:编辑 `web_server.py`,把 `app.config["SECRET_KEY"]` 改成随机字符串
|
||||||
- **限制访问**:生产环境把 `WEB_HOST` 改为 `127.0.0.1`,配合反向代理
|
- **限制访问**:生产环境把 `WEB_HOST` 改为 `127.0.0.1`,配合反向代理
|
||||||
- **防火墙**:只开放必要端口
|
- **防火墙**:只开放必要端口
|
||||||
|
|||||||
+535
-72
@@ -20,6 +20,29 @@ function _csrfHeaders(){
|
|||||||
return _csrfToken?{'X-CSRF-Token':_csrfToken}:{};
|
return _csrfToken?{'X-CSRF-Token':_csrfToken}:{};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 当前用户与权限 =====
|
||||||
|
// _me 来自 /api/me:{username, is_admin, perms:[tasks/devices/apks/logs]}
|
||||||
|
// 管理员 perms 恒为全部。无权限的功能入口(tab/按钮)用 data-perm 标记或 _can() 判断隐藏。
|
||||||
|
let _me={is_admin:false,perms:[],username:''};
|
||||||
|
async function loadMe(){
|
||||||
|
try{
|
||||||
|
const r=await apiGet('/api/me');
|
||||||
|
if(r&&r.ok)_me=r.user||{is_admin:false,perms:[],username:''};
|
||||||
|
const un=document.getElementById('me-username');
|
||||||
|
if(un)un.textContent=_me.username||'';
|
||||||
|
// 隐藏无权限的 tab 和按钮(data-perm="admin" 表示仅管理员)
|
||||||
|
document.querySelectorAll('[data-perm]').forEach(function(el){
|
||||||
|
const p=el.dataset.perm;
|
||||||
|
const ok=(p==='admin')?_me.is_admin:(_me.perms||[]).includes(p);
|
||||||
|
if(!ok)el.style.display='none';
|
||||||
|
});
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
function _can(perm){
|
||||||
|
return _me.is_admin||(_me.perms||[]).includes(perm);
|
||||||
|
}
|
||||||
|
const PERM_LABELS={tasks:'任务管理',devices:'设备控制',apks:'应用管理',logs:'日志查看'};
|
||||||
|
|
||||||
async function apiGet(url){
|
async function apiGet(url){
|
||||||
const r=await fetch(url);
|
const r=await fetch(url);
|
||||||
if(r.status===401){window.location='/login';return null;}
|
if(r.status===401){window.location='/login';return null;}
|
||||||
@@ -130,6 +153,19 @@ async function stopSelectedDevices(){
|
|||||||
setTimeout(loadMonitor,500);
|
setTimeout(loadMonitor,500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清除单台设备的异常状态(error/failed → 未启动)
|
||||||
|
async function clearDeviceError(serial){
|
||||||
|
if(!confirm('确认清除设备 '+serial+' 的异常状态?'))return;
|
||||||
|
const r=await apiPost('/api/device/clear_error',{serial:serial});
|
||||||
|
handleResult(r,()=>loadMonitor());
|
||||||
|
}
|
||||||
|
// 一键清除所有异常/失败设备的错误状态(运行中的任务不受影响)
|
||||||
|
async function clearAllErrors(){
|
||||||
|
if(!confirm('确认清除所有设备的异常状态?(运行中/等待重试的设备会跳过)'))return;
|
||||||
|
const r=await apiPost('/api/device/clear_all_errors');
|
||||||
|
handleResult(r,()=>loadMonitor());
|
||||||
|
}
|
||||||
|
|
||||||
// ================== 设备画面查看 ==================
|
// ================== 设备画面查看 ==================
|
||||||
let _shotTimer=null; // 自动刷新定时器
|
let _shotTimer=null; // 自动刷新定时器
|
||||||
let _shotSerial=''; // 当前查看的 serial
|
let _shotSerial=''; // 当前查看的 serial
|
||||||
@@ -245,7 +281,7 @@ function showTab(name){
|
|||||||
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
|
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
|
||||||
document.querySelectorAll('.navbar .tab').forEach(t=>t.classList.remove('active'));
|
document.querySelectorAll('.navbar .tab').forEach(t=>t.classList.remove('active'));
|
||||||
document.getElementById('tab-'+name).classList.add('active');
|
document.getElementById('tab-'+name).classList.add('active');
|
||||||
document.querySelectorAll('.navbar .tab')[['monitor','tasks','apps','groups','logs','users'].indexOf(name)].classList.add('active');
|
document.querySelectorAll('.navbar .tab')[['monitor','tasks','apps','groups','logs','users','maintenance'].indexOf(name)].classList.add('active');
|
||||||
// 停止所有定时器
|
// 停止所有定时器
|
||||||
if(_monitorTimer){clearInterval(_monitorTimer);_monitorTimer=null;}
|
if(_monitorTimer){clearInterval(_monitorTimer);_monitorTimer=null;}
|
||||||
if(_logTimer){clearInterval(_logTimer);_logTimer=null;}
|
if(_logTimer){clearInterval(_logTimer);_logTimer=null;}
|
||||||
@@ -253,6 +289,7 @@ function showTab(name){
|
|||||||
if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);}
|
if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);}
|
||||||
if(name==='tasks'){loadTasks();loadCustomActions();}
|
if(name==='tasks'){loadTasks();loadCustomActions();}
|
||||||
if(name==='apps')loadApks();
|
if(name==='apps')loadApks();
|
||||||
|
if(name==='maintenance')loadAdbDevices();
|
||||||
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();
|
||||||
@@ -456,10 +493,15 @@ function renderDeviceRow(dev){
|
|||||||
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 endInfo=(dev.worker_status==='running'&&dev.end_time)?'<div style="font-size:11px;color:#94a3b8">预计结束 '+new Date(dev.end_time*1000).toLocaleTimeString()+'</div>':'';
|
||||||
// 选择器健康警告(某步骤连续未找到元素,可能 App 改版选择器失效)
|
// 选择器健康警告(某步骤连续未找到元素,可能 App 改版选择器失效)
|
||||||
const warnInfo=dev.last_warning?'<div style="font-size:11px;color:#f59e0b" title="'+esc(dev.last_warning)+'">⚠ '+esc(dev.last_warning)+'</div>':'';
|
const warnInfo=dev.last_warning?'<div style="font-size:11px;color:#f59e0b" title="'+esc(dev.last_warning)+'">⚠ '+esc(dev.last_warning)+'</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 canDev=_can('devices');
|
||||||
|
const stopBtn=canDev&&(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>';
|
||||||
const btns='<div style="display:flex;gap:4px;flex-wrap:wrap">'+shotBtn+stopBtn+'</div>';
|
// 异常/失败设备提供"清除异常"(把状态恢复为未启动)
|
||||||
|
const clearBtn=canDev&&(dev.worker_status==='error'||dev.worker_status==='failed')
|
||||||
|
?'<button class="btn btn-xs" onclick="clearDeviceError(\''+esc(dev.serial)+'\')" title="清除该设备的异常状态,恢复为未启动">清除异常</button>':'';
|
||||||
|
const btns='<div style="display:flex;gap:4px;flex-wrap:wrap">'+shotBtn+clearBtn+stopBtn+'</div>';
|
||||||
const devName=dev.device_name||dev.model||'';
|
const devName=dev.device_name||dev.model||'';
|
||||||
const devLabel=devName?'<strong>'+esc(dev.serial)+'</strong><br><span style="font-size:11px;color:#94a3b8">'+esc(devName)+'</span>':'<strong>'+esc(dev.serial)+'</strong>';
|
const devLabel=devName?'<strong>'+esc(dev.serial)+'</strong><br><span style="font-size:11px;color:#94a3b8">'+esc(devName)+'</span>':'<strong>'+esc(dev.serial)+'</strong>';
|
||||||
// 复选框列
|
// 复选框列
|
||||||
@@ -479,6 +521,13 @@ async function loadJobsForMonitor(){
|
|||||||
|
|
||||||
// 通用进度展示:解析 progress = {done, total, unit, action_counts, elapsed}
|
// 通用进度展示:解析 progress = {done, total, unit, action_counts, elapsed}
|
||||||
// 任意 app 都能用:显示进度条 + "done/total unit" + 运行时长 + 操作计数徽章
|
// 任意 app 都能用:显示进度条 + "done/total unit" + 运行时长 + 操作计数徽章
|
||||||
|
// 下次运行时间格式化:"2026-08-12 09:00:00" → "08-12 09:00"
|
||||||
|
function fmtNextRun(iso){
|
||||||
|
if(!iso)return '';
|
||||||
|
const s=String(iso).replace('T',' ');
|
||||||
|
return s.length>=16?s.slice(5,16):s;
|
||||||
|
}
|
||||||
|
|
||||||
function fmtProgress(progress, workerStatus){
|
function fmtProgress(progress, workerStatus){
|
||||||
if(!progress||!Object.keys(progress).length){
|
if(!progress||!Object.keys(progress).length){
|
||||||
// 空闲/未运行 → 显示"-"
|
// 空闲/未运行 → 显示"-"
|
||||||
@@ -603,7 +652,7 @@ function renderTaskTable(){
|
|||||||
filter:(j,kw)=>((j.name||'').toLowerCase().includes(kw)||(j.task_type||'').toLowerCase().includes(kw)||(j.id||'').toLowerCase().includes(kw)),
|
filter:(j,kw)=>((j.name||'').toLowerCase().includes(kw)||(j.task_type||'').toLowerCase().includes(kw)||(j.id||'').toLowerCase().includes(kw)),
|
||||||
render:renderTaskRow,
|
render:renderTaskRow,
|
||||||
tbody:'tb-tasks',pager:'pager-tasks',pageSize:10,
|
tbody:'tb-tasks',pager:'pager-tasks',pageSize:10,
|
||||||
empty:'<tr><td colspan="8" class="empty">暂无任务,点击"新建任务"创建</td></tr>'
|
empty:'<tr><td colspan="9" class="empty">暂无任务,点击"新建任务"创建</td></tr>'
|
||||||
});
|
});
|
||||||
applyListPager('tasks');
|
applyListPager('tasks');
|
||||||
initListSearch('tasks','search-tasks');
|
initListSearch('tasks','search-tasks');
|
||||||
@@ -611,8 +660,14 @@ function renderTaskTable(){
|
|||||||
function renderTaskRow(j){
|
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'?('启停 '+cronToText(j.schedule.cron||'')+' → '+cronToText(j.schedule.stop_cron||'')):
|
||||||
j.schedule.mode==='cron'?('定时 '+esc(j.schedule.cron||'')):'手动';
|
j.schedule.mode==='cron'?('定时 '+cronToText(j.schedule.cron||'')):'手动';
|
||||||
|
const schedTxt=sched+((j.schedule.window&&j.schedule.window.start&&j.schedule.window.end)
|
||||||
|
?' [窗口 '+esc(j.schedule.window.start)+'-'+esc(j.schedule.window.end)+']':'');
|
||||||
|
const schedOut='<td>'+schedTxt+'</td>';
|
||||||
|
// 下次运行时间(后端已按运行窗口跳过窗口外触发点)
|
||||||
|
const nextRun=fmtNextRun(j.next_run);
|
||||||
|
const nextOut='<td>'+(nextRun||'<span class="text-muted">-</span>')+'</td>';
|
||||||
const retry=j.retry.max_attempts>1?(j.retry.max_attempts+'次/'+j.retry.delay+'s'):'不重试';
|
const retry=j.retry.max_attempts>1?(j.retry.max_attempts+'次/'+j.retry.delay+'s'):'不重试';
|
||||||
const en=j.enabled?'<span class="label label-success">启用</span>':'<span class="label label-default">停用</span>';
|
const en=j.enabled?'<span class="label label-success">启用</span>':'<span class="label label-default">停用</span>';
|
||||||
return '<tr>'+
|
return '<tr>'+
|
||||||
@@ -621,13 +676,16 @@ function renderTaskRow(j){
|
|||||||
'<td>'+esc(j.task_type)+'</td>'+
|
'<td>'+esc(j.task_type)+'</td>'+
|
||||||
'<td>'+en+'</td>'+
|
'<td>'+en+'</td>'+
|
||||||
'<td>'+esc(tgt)+(tgtDetail?'('+esc(tgtDetail)+')':'')+'</td>'+
|
'<td>'+esc(tgt)+(tgtDetail?'('+esc(tgtDetail)+')':'')+'</td>'+
|
||||||
'<td>'+sched+'</td>'+
|
schedOut+
|
||||||
|
nextOut+
|
||||||
'<td>'+retry+'</td>'+
|
'<td>'+retry+'</td>'+
|
||||||
'<td>'+
|
'<td>'+
|
||||||
|
(_can('tasks')?(
|
||||||
'<button class="btn btn-primary btn-xs" onclick="apiAction(\'/api/jobs/'+j.id+'/run\')">执行</button>'+
|
'<button class="btn btn-primary btn-xs" onclick="apiAction(\'/api/jobs/'+j.id+'/run\')">执行</button>'+
|
||||||
'<button class="btn btn-xs" onclick="apiAction(\'/api/jobs/'+j.id+'/toggle\',{enabled:'+(!j.enabled)+'})">'+(j.enabled?'停用':'启用')+'</button>'+
|
'<button class="btn btn-xs" onclick="apiAction(\'/api/jobs/'+j.id+'/toggle\',{enabled:'+(!j.enabled)+'})">'+(j.enabled?'停用':'启用')+'</button>'+
|
||||||
'<button class="btn btn-xs" onclick="openTaskModal(\''+j.id+'\')">编辑</button>'+
|
'<button class="btn btn-xs" onclick="openTaskModal(\''+j.id+'\')">编辑</button>'+
|
||||||
'<button class="btn btn-danger btn-xs" onclick="deleteTask(\''+j.id+'\')">删除</button>'+
|
'<button class="btn btn-danger btn-xs" onclick="deleteTask(\''+j.id+'\')">删除</button>'
|
||||||
|
):'<span class="text-muted">只读</span>')+
|
||||||
'</td>'+
|
'</td>'+
|
||||||
'</tr>';
|
'</tr>';
|
||||||
}
|
}
|
||||||
@@ -644,6 +702,15 @@ function openTaskModal(jobId){
|
|||||||
const sm=j.schedule.mode;
|
const sm=j.schedule.mode;
|
||||||
const startCron=(sm==='cron'||sm==='cron_stop')?(j.schedule.cron||''):'';
|
const startCron=(sm==='cron'||sm==='cron_stop')?(j.schedule.cron||''):'';
|
||||||
const stopCron=sm==='cron_stop'?(j.schedule.stop_cron||''):'';
|
const stopCron=sm==='cron_stop'?(j.schedule.stop_cron||''):'';
|
||||||
|
// 运行窗口(每天时间段,窗口外不启动)
|
||||||
|
const win=j.schedule.window||null;
|
||||||
|
const winStart=win?(win.start||'09:00'):'09:00';
|
||||||
|
const winEnd=win?(win.end||'18:00'):'18:00';
|
||||||
|
// 简单调度:把已有 cron 解析回"频率+时间",编辑时回填;解析不了落到自定义
|
||||||
|
const sc=parseCronToFreq(startCron);
|
||||||
|
const stopF=parseCronToFreq(stopCron).time;
|
||||||
|
const weekdayOpts=[0,1,2,3,4,5,6].map(w=>
|
||||||
|
'<option value="'+w+'"'+(sc.weekday===w?' selected':'')+'>'+WEEKDAY_NAMES[w]+'</option>').join('');
|
||||||
|
|
||||||
document.getElementById('modal-body').innerHTML=
|
document.getElementById('modal-body').innerHTML=
|
||||||
'<div class="form-row">'+
|
'<div class="form-row">'+
|
||||||
@@ -667,14 +734,32 @@ function openTaskModal(jobId){
|
|||||||
'<div class="form-row">'+
|
'<div class="form-row">'+
|
||||||
'<div class="form-group"><label>调度方式</label>'+
|
'<div class="form-group"><label>调度方式</label>'+
|
||||||
'<select class="form-control" id="f-schedule_mode" onchange="toggleScheduleFields()">'+
|
'<select class="form-control" id="f-schedule_mode" onchange="toggleScheduleFields()">'+
|
||||||
'<option value="once"'+(sm==='once'?' selected':'')+'>手动执行</option>'+
|
'<option value="once"'+(sm==='once'?' selected':'')+'>手动(不自动启动)</option>'+
|
||||||
'<option value="cron"'+(sm==='cron'?' selected':'')+'>定时启动</option>'+
|
'<option value="cron"'+(sm==='cron'?' selected':'')+'>定时启动</option>'+
|
||||||
'<option value="cron_stop"'+(sm==='cron_stop'?' selected':'')+'>定时启停</option>'+
|
'<option value="cron_stop"'+(sm==='cron_stop'?' selected':'')+'>定时启动+停止</option>'+
|
||||||
'</select>'+
|
'</select>'+
|
||||||
'</div>'+
|
'</div>'+
|
||||||
'<div class="form-group" id="f-cron_wrap"><label>启动 Cron 表达式</label><input type="text" class="form-control" id="f-schedule_cron" value="'+esc(startCron)+'" placeholder="如 0 9 * * * = 每天9点启动" style="font-family:monospace"><div class="help">格式:分 时 日 月 周</div></div>'+
|
'<div class="form-group" id="f-freq_wrap" style="display:none"><label>频率</label>'+
|
||||||
'<div class="form-group" id="f-stop_cron_wrap"><label>停止 Cron 表达式</label><input type="text" class="form-control" id="f-schedule_stop_cron" value="'+esc(stopCron)+'" placeholder="如 0 18 * * * = 每天18点停止" style="font-family:monospace"><div class="help">到点自动停止所有运行中的 worker</div></div>'+
|
'<select class="form-control" id="f-schedule_freq" onchange="toggleScheduleFields()">'+
|
||||||
|
'<option value="daily"'+((sc.freq==='daily')?' selected':'')+'>每天</option>'+
|
||||||
|
'<option value="hourly"'+((sc.freq==='hourly')?' selected':'')+'>每小时(整点)</option>'+
|
||||||
|
'<option value="everyN"'+((sc.freq==='everyN')?' selected':'')+'>每隔 N 小时</option>'+
|
||||||
|
'<option value="weekly"'+((sc.freq==='weekly')?' selected':'')+'>每周</option>'+
|
||||||
|
'<option value="custom"'+((sc.freq==='custom')?' selected':'')+'>自定义 cron(高级)</option>'+
|
||||||
|
'</select>'+
|
||||||
'</div>'+
|
'</div>'+
|
||||||
|
'<div class="form-group" id="f-time_wrap" style="display:none"><label>启动时间</label><input type="time" class="form-control" id="f-schedule_time" value="'+esc(sc.time||'09:00')+'"></div>'+
|
||||||
|
'<div class="form-group" id="f-interval_wrap" style="display:none"><label>间隔(小时)</label><input type="number" class="form-control" id="f-schedule_interval" value="'+(sc.interval||2)+'" min="1" max="12"><div class="help">从 0 点起算,如 2 = 0/2/4/6...点启动</div></div>'+
|
||||||
|
'<div class="form-group" id="f-weekday_wrap" style="display:none"><label>星期</label><select class="form-control" id="f-schedule_weekday">'+weekdayOpts+'</select></div>'+
|
||||||
|
'<div class="form-group" id="f-cron_wrap" style="display:none"><label>启动 Cron 表达式</label><input type="text" class="form-control" id="f-schedule_cron" value="'+esc(startCron)+'" placeholder="如 0 9 * * * = 每天9点启动" style="font-family:monospace"><div class="help">格式:分 时 日 月 周(0 0 * * 0 = 每周日零点)</div></div>'+
|
||||||
|
'<div class="form-group" id="f-stop_wrap" style="display:none"><label>停止时间(每天)</label><input type="time" class="form-control" id="f-schedule_stop_time" value="'+esc(stopF||'18:00')+'"><div class="help">到点自动停止所有运行中的 worker</div></div>'+
|
||||||
|
'</div>'+
|
||||||
|
'<div class="form-group form-check" style="margin-top:4px"><input type="checkbox" id="f-window_enable" '+(win?'checked':'')+' onchange="toggleWindowFields()"><label for="f-window_enable">启用运行窗口(只在指定时间段内运行)</label></div>'+
|
||||||
|
'<div class="form-row" id="f-window_wrap" style="display:'+(win?'flex':'none')+'">'+
|
||||||
|
'<div class="form-group"><label>允许运行·开始</label><input type="time" class="form-control" id="f-window_start" value="'+esc(winStart)+'"></div>'+
|
||||||
|
'<div class="form-group"><label>允许运行·结束</label><input type="time" class="form-control" id="f-window_end" value="'+esc(winEnd)+'"></div>'+
|
||||||
|
'</div>'+
|
||||||
|
'<div class="help" id="f-window_help" style="display:'+(win?'block':'none')+'">窗口外任务不会启动(定时触发和手动执行都受约束)。跨午夜示例:21:00-09:00 = 晚 9 点到次日早 9 点允许运行</div>'+
|
||||||
'<div class="form-row">'+
|
'<div class="form-row">'+
|
||||||
'<div class="form-group"><label>重试次数</label><input type="number" class="form-control" id="f-retry_attempts" value="'+j.retry.max_attempts+'" min="1" max="10"></div>'+
|
'<div class="form-group"><label>重试次数</label><input type="number" class="form-control" id="f-retry_attempts" value="'+j.retry.max_attempts+'" min="1" max="10"></div>'+
|
||||||
'<div class="form-group"><label>重试间隔(秒)</label><input type="number" class="form-control" id="f-retry_delay" value="'+j.retry.delay+'" min="10" max="3600"></div>'+
|
'<div class="form-group"><label>重试间隔(秒)</label><input type="number" class="form-control" id="f-retry_delay" value="'+j.retry.delay+'" min="10" max="3600"></div>'+
|
||||||
@@ -682,7 +767,7 @@ function openTaskModal(jobId){
|
|||||||
'<div class="form-group" id="f-params_wrap"><label>任务参数 (JSON)</label><textarea class="form-control" id="f-params" rows="6">'+esc(JSON.stringify(j.params||{},null,2))+'</textarea><div class="help">可留空使用默认参数。抖音任务可设 max_duration(秒,如 1800=30分钟)限制单设备运行时长</div></div>'+
|
'<div class="form-group" id="f-params_wrap"><label>任务参数 (JSON)</label><textarea class="form-control" id="f-params" rows="6">'+esc(JSON.stringify(j.params||{},null,2))+'</textarea><div class="help">可留空使用默认参数。抖音任务可设 max_duration(秒,如 1800=30分钟)限制单设备运行时长</div></div>'+
|
||||||
'<div class="step-editor" id="step-editor">'+
|
'<div class="step-editor" id="step-editor">'+
|
||||||
'<div class="se-head"><span>步骤编辑器</span><span style="font-size:11px;color:var(--text-light);flex:1;margin-left:12px">用步骤卡片上的 ☑ 选中多个步骤,可打包成自定义动作</span>'+
|
'<div class="se-head"><span>步骤编辑器</span><span style="font-size:11px;color:var(--text-light);flex:1;margin-left:12px">用步骤卡片上的 ☑ 选中多个步骤,可打包成自定义动作</span>'+
|
||||||
'<button class="btn btn-xs btn-primary" id="se-pack-btn" disabled onclick="_stepEditor.saveAsCustomAction()">打包选中步骤</button>'+
|
(_can('tasks')?'<button class="btn btn-xs btn-primary" id="se-pack-btn" disabled onclick="_stepEditor.saveAsCustomAction()">打包选中步骤</button>':'')+
|
||||||
'</div>'+
|
'</div>'+
|
||||||
'<div class="se-body">'+
|
'<div class="se-body">'+
|
||||||
'<div class="se-lib" id="se-lib"><div class="lib-title">操作库</div></div>'+
|
'<div class="se-lib" id="se-lib"><div class="lib-title">操作库</div></div>'+
|
||||||
@@ -709,10 +794,69 @@ function toggleTargetFields(){
|
|||||||
document.getElementById('f-target_serial_wrap').style.display=mode==='serial'?'block':'none';
|
document.getElementById('f-target_serial_wrap').style.display=mode==='serial'?'block':'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 简单调度:cron(5段) ↔ 频率+时间 互转 =====
|
||||||
|
const WEEKDAY_NAMES=['周日','周一','周二','周三','周四','周五','周六'];
|
||||||
|
// cron → 简单设置 {freq: daily|hourly|everyN|weekly|custom, time:"HH:MM", interval, weekday(0-6)}
|
||||||
|
function parseCronToFreq(cron){
|
||||||
|
const r={freq:'custom',time:'',interval:2,weekday:1};
|
||||||
|
const p=(cron||'').trim().split(/\s+/);
|
||||||
|
if(p.length!==5)return r;
|
||||||
|
const [mi,h,d,m,w]=p;
|
||||||
|
if(mi==='0'&&h==='*'&&d==='*'&&m==='*'&&w==='*'){r.freq='hourly';return r;}
|
||||||
|
const n=(h.match(/^\*\/(\d+)$/)||[])[1]; // 每隔N小时:时位是 */N
|
||||||
|
if(n&&mi==='0'&&d==='*'&&m==='*'&&w==='*'){r.freq='everyN';r.interval=parseInt(n)||2;return r;}
|
||||||
|
if(/^\d{1,2}$/.test(mi)&&/^\d{1,2}$/.test(h)&&d==='*'&&m==='*'&&w==='*'){
|
||||||
|
r.freq='daily';r.time=h.padStart(2,'0')+':'+mi.padStart(2,'0');return r;
|
||||||
|
}
|
||||||
|
if(/^\d{1,2}$/.test(mi)&&/^\d{1,2}$/.test(h)&&d==='*'&&m==='*'&&/^[0-7]$/.test(w)){
|
||||||
|
r.freq='weekly';r.time=h.padStart(2,'0')+':'+mi.padStart(2,'0');r.weekday=parseInt(w)%7;return r;
|
||||||
|
}
|
||||||
|
return r; // 解析不了 → 落到自定义
|
||||||
|
}
|
||||||
|
// 表单 → cron(5段)
|
||||||
|
function buildCronFromFreq(){
|
||||||
|
const freq=document.getElementById('f-schedule_freq').value;
|
||||||
|
if(freq==='custom')return document.getElementById('f-schedule_cron').value.trim();
|
||||||
|
if(freq==='hourly')return '0 * * * *';
|
||||||
|
if(freq==='everyN'){
|
||||||
|
const n=parseInt(document.getElementById('f-schedule_interval').value)||2;
|
||||||
|
return '0 */'+Math.min(Math.max(n,1),12)+' * * *';
|
||||||
|
}
|
||||||
|
const t=(document.getElementById('f-schedule_time').value||'09:00').split(':');
|
||||||
|
const mi=parseInt(t[1])||0, h=parseInt(t[0])||0;
|
||||||
|
if(freq==='weekly'){
|
||||||
|
const w=parseInt(document.getElementById('f-schedule_weekday').value)||0;
|
||||||
|
return mi+' '+h+' * * '+w;
|
||||||
|
}
|
||||||
|
return mi+' '+h+' * * *'; // daily
|
||||||
|
}
|
||||||
|
// cron(5段) → 中文描述(任务列表显示)
|
||||||
|
function cronToText(cron){
|
||||||
|
const r=parseCronToFreq(cron||'');
|
||||||
|
if(r.freq==='hourly')return '每小时';
|
||||||
|
if(r.freq==='everyN')return '每'+r.interval+'小时';
|
||||||
|
if(r.freq==='daily')return '每天 '+r.time;
|
||||||
|
if(r.freq==='weekly')return WEEKDAY_NAMES[r.weekday]+' '+r.time;
|
||||||
|
return 'cron: '+(cron||'-');
|
||||||
|
}
|
||||||
function toggleScheduleFields(){
|
function toggleScheduleFields(){
|
||||||
const mode=document.getElementById('f-schedule_mode').value;
|
const mode=document.getElementById('f-schedule_mode').value;
|
||||||
document.getElementById('f-cron_wrap').style.display=(mode==='cron'||mode==='cron_stop')?'block':'none';
|
const showSched=(mode==='cron'||mode==='cron_stop');
|
||||||
document.getElementById('f-stop_cron_wrap').style.display=mode==='cron_stop'?'block':'none';
|
document.getElementById('f-freq_wrap').style.display=showSched?'block':'none';
|
||||||
|
document.getElementById('f-stop_wrap').style.display=mode==='cron_stop'?'block':'none';
|
||||||
|
if(showSched)showFreqFields();
|
||||||
|
}
|
||||||
|
function toggleWindowFields(){
|
||||||
|
const on=document.getElementById('f-window_enable').checked;
|
||||||
|
document.getElementById('f-window_wrap').style.display=on?'flex':'none';
|
||||||
|
document.getElementById('f-window_help').style.display=on?'block':'none';
|
||||||
|
}
|
||||||
|
function showFreqFields(){
|
||||||
|
const freq=document.getElementById('f-schedule_freq').value;
|
||||||
|
document.getElementById('f-time_wrap').style.display=(freq==='daily'||freq==='weekly')?'block':'none';
|
||||||
|
document.getElementById('f-interval_wrap').style.display=freq==='everyN'?'block':'none';
|
||||||
|
document.getElementById('f-weekday_wrap').style.display=freq==='weekly'?'block':'none';
|
||||||
|
document.getElementById('f-cron_wrap').style.display=freq==='custom'?'block':'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTaskTypeChange(){
|
function onTaskTypeChange(){
|
||||||
@@ -725,7 +869,16 @@ function onTaskTypeChange(){
|
|||||||
// ================== 步骤编辑器 ==================
|
// ================== 步骤编辑器 ==================
|
||||||
var STEP_LIB=[
|
var STEP_LIB=[
|
||||||
{type:'open_app',label:'打开App',icon:'▶',params:{package:'',wait_home:false,home_feature:''}},
|
{type:'open_app',label:'打开App',icon:'▶',params:{package:'',wait_home:false,home_feature:''}},
|
||||||
|
{type:'stop_app',label:'结束App',icon:'⏹',params:{package:''}},
|
||||||
|
{type:'screen_on',label:'亮屏',icon:'💡',params:{}},
|
||||||
|
{type:'screen_off',label:'息屏',icon:'🌙',params:{}},
|
||||||
|
{type:'keep_screen',label:'保持亮屏',icon:'🔆',params:{mode:'on'}},
|
||||||
|
{type:'key_event',label:'按键',icon:'⌨️',params:{key:'back'}},
|
||||||
{type:'swipe',label:'滑动',icon:'↕',params:{direction:'up',duration_min:0.25,duration_max:0.50}},
|
{type:'swipe',label:'滑动',icon:'↕',params:{direction:'up',duration_min:0.25,duration_max:0.50}},
|
||||||
|
{type:'swipe_until',label:'滑动直到元素',icon:'🔍',params:{selector_type:'xpath',selector_value:'',direction:'up',max_swipes:8,click_when_found:true}},
|
||||||
|
{type:'click_xy',label:'点击坐标',icon:'🎯',params:{x:50,y:50}},
|
||||||
|
{type:'long_click',label:'长按元素',icon:'👆',params:{selector_type:'xpath',selector_value:'',duration:1.0,wait_timeout:2}},
|
||||||
|
{type:'wait_el',label:'等待元素',icon:'⏳',params:{selector_type:'xpath',selector_value:'',timeout:10}},
|
||||||
{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}},
|
||||||
@@ -735,7 +888,25 @@ var STEP_LIB=[
|
|||||||
var _customActions=[]; // 后端加载的自定义动作
|
var _customActions=[]; // 后端加载的自定义动作
|
||||||
var _stepIdCounter=0;
|
var _stepIdCounter=0;
|
||||||
function _newStepId(){_stepIdCounter++;return 'step_'+Date.now().toString(36)+'_'+_stepIdCounter;}
|
function _newStepId(){_stepIdCounter++;return 'step_'+Date.now().toString(36)+'_'+_stepIdCounter;}
|
||||||
|
// 递归去掉步骤 id(保存到自定义动作库/拖入画布时重新生成,避免重复拖入后 id 冲突导致多选错乱)
|
||||||
|
function _stripIds(steps){
|
||||||
|
steps.forEach(function(s){
|
||||||
|
delete s.id;
|
||||||
|
if(s.params&&s.params.children&&Array.isArray(s.params.children))_stripIds(s.params.children);
|
||||||
|
});
|
||||||
|
}
|
||||||
function _findLib(type){return STEP_LIB.find(function(s){return s.type===type;});}
|
function _findLib(type){return STEP_LIB.find(function(s){return s.type===type;});}
|
||||||
|
// 选择器组件(类型下拉 + 值输入 + 抓取元素 + 测试按钮),click/long_click/swipe_until/wait_el 复用
|
||||||
|
function _selRowHtml(p, extra){
|
||||||
|
var opts=['xpath','description','text','resourceId','descriptionContains','className'].map(function(d){
|
||||||
|
return '<option value="'+d+'"'+(p.selector_type===d?' selected':'')+'>'+d+'</option>';}).join('');
|
||||||
|
var pick=_can('devices')?'<button class="btn btn-xs btn-pick" onclick="_stepEditor.openPicker(this)">抓取元素</button>':'';
|
||||||
|
var test=_can('devices')?'<button class="btn btn-xs" onclick="_stepEditor._testStep(this)" title="在设备上试执行本步骤,验证选择器是否命中">测试</button>':'';
|
||||||
|
return '<div class="form-row"><div class="form-group"><label>选择器类型</label><select class="form-control" data-key="selector_type">'+opts+'</select></div>'+
|
||||||
|
'<div class="form-group" style="grid-column:span 2"><label>选择器值</label><div class="sel-picker">'+
|
||||||
|
'<input type="text" class="form-control" data-key="selector_value" value="'+esc(p.selector_value||'')+'" placeholder="如 点赞">'+pick+test+
|
||||||
|
'</div></div></div>'+(extra||'');
|
||||||
|
}
|
||||||
|
|
||||||
// 加载自定义动作库
|
// 加载自定义动作库
|
||||||
function _loadCustomActions(){
|
function _loadCustomActions(){
|
||||||
@@ -752,6 +923,7 @@ var _stepEditor={
|
|||||||
init:function(steps,maxDuration,draftKey,canvasId,libId){
|
init:function(steps,maxDuration,draftKey,canvasId,libId){
|
||||||
this._draftKey=draftKey||'new';
|
this._draftKey=draftKey||'new';
|
||||||
this._selected=[];
|
this._selected=[];
|
||||||
|
this._updatePackBtns();
|
||||||
this._dragSuppressClick=false;
|
this._dragSuppressClick=false;
|
||||||
// 可指定编辑容器(任务编辑器 vs 动作编辑器),默认任务编辑器
|
// 可指定编辑容器(任务编辑器 vs 动作编辑器),默认任务编辑器
|
||||||
this._canvas=document.getElementById(canvasId||'se-canvas');
|
this._canvas=document.getElementById(canvasId||'se-canvas');
|
||||||
@@ -873,6 +1045,7 @@ var _stepEditor={
|
|||||||
card.draggable=true;
|
card.draggable=true;
|
||||||
card.dataset.idx=idx;
|
card.dataset.idx=idx;
|
||||||
card.dataset.stepId=step.id||'';
|
card.dataset.stepId=step.id||'';
|
||||||
|
card.dataset.stepType=step.type;
|
||||||
if(this._selected&&this._selected.indexOf(step.id)>=0)card.classList.add('selected');
|
if(this._selected&&this._selected.indexOf(step.id)>=0)card.classList.add('selected');
|
||||||
// head
|
// head
|
||||||
var head=document.createElement('div');
|
var head=document.createElement('div');
|
||||||
@@ -937,16 +1110,48 @@ var _stepEditor={
|
|||||||
h+='<div class="form-group"><label>包名</label><input type="text" class="form-control" data-key="package" value="'+esc(p.package||'')+'" placeholder="如 com.ss.android.ugc.aweme"></div>';
|
h+='<div class="form-group"><label>包名</label><input type="text" class="form-control" data-key="package" value="'+esc(p.package||'')+'" placeholder="如 com.ss.android.ugc.aweme"></div>';
|
||||||
h+='<div class="form-row"><div class="form-group form-check"><input type="checkbox" data-key="wait_home" '+(p.wait_home?'checked':'')+'><label>等待进入首页</label></div>';
|
h+='<div class="form-row"><div class="form-group form-check"><input type="checkbox" data-key="wait_home" '+(p.wait_home?'checked':'')+'><label>等待进入首页</label></div>';
|
||||||
h+='<div class="form-group"><label>首页特征词</label><input type="text" class="form-control" data-key="home_feature" value="'+esc(p.home_feature||'')+'" placeholder="如 拍摄"></div></div>';
|
h+='<div class="form-group"><label>首页特征词</label><input type="text" class="form-control" data-key="home_feature" value="'+esc(p.home_feature||'')+'" placeholder="如 拍摄"></div></div>';
|
||||||
|
}else if(step.type==='stop_app'){
|
||||||
|
h+='<div class="form-group"><label>包名</label><input type="text" class="form-control" data-key="package" value="'+esc(p.package||'')+'" placeholder="如 com.ss.android.ugc.aweme"></div>';
|
||||||
|
h+='<div class="help">强制结束该 App(am force-stop),清除后台进程,下次打开为冷启动。常配合"打开App"使用:结束→重新打开</div>';
|
||||||
|
}else if(step.type==='screen_on'){
|
||||||
|
h+='<div class="help">亮屏(息屏时唤醒并滑动解锁)。任务执行前用——息屏状态下 u2 无法点击操作</div>';
|
||||||
|
}else if(step.type==='screen_off'){
|
||||||
|
h+='<div class="help">息屏。任务结束后用,避免长时间亮屏导致烧屏/发热</div>';
|
||||||
|
}else if(step.type==='keep_screen'){
|
||||||
|
h+='<div class="form-group"><label>模式</label><select class="form-control" data-key="mode">'+
|
||||||
|
'<option value="on"'+(p.mode!=='off'?' selected':'')+'>保持亮屏(充电时屏幕常亮)</option>'+
|
||||||
|
'<option value="off"'+(p.mode==='off'?' selected':'')+'>恢复自动息屏</option></select></div>';
|
||||||
|
h+='<div class="help">长任务(如养号看视频几小时)放"保持亮屏"防屏幕超时熄灭;任务结束前放"恢复自动息屏"</div>';
|
||||||
}else if(step.type==='swipe'){
|
}else if(step.type==='swipe'){
|
||||||
h+='<div class="form-row"><div class="form-group"><label>方向</label><select class="form-control" data-key="direction">'+
|
h+='<div class="form-row"><div class="form-group"><label>方向</label><select class="form-control" data-key="direction">'+
|
||||||
['up','down','left','right'].map(function(d){return '<option value="'+d+'"'+(p.direction===d?' selected':'')+'>'+d+'</option>';}).join('')+'</select></div>';
|
['up','down','left','right'].map(function(d){return '<option value="'+d+'"'+(p.direction===d?' selected':'')+'>'+d+'</option>';}).join('')+'</select></div>';
|
||||||
h+='<div class="form-group"><label>最短时长(秒)</label><input type="number" class="form-control" data-key="duration_min" value="'+(p.duration_min||0.25)+'" step="0.05" min="0.05"></div>';
|
h+='<div class="form-group"><label>最短时长(秒)</label><input type="number" class="form-control" data-key="duration_min" value="'+(p.duration_min||0.25)+'" step="0.05" min="0.05"></div>';
|
||||||
h+='<div class="form-group"><label>最长时长(秒)</label><input type="number" class="form-control" data-key="duration_max" value="'+(p.duration_max||0.50)+'" step="0.05" min="0.05"></div></div>';
|
h+='<div class="form-group"><label>最长时长(秒)</label><input type="number" class="form-control" data-key="duration_max" value="'+(p.duration_max||0.50)+'" step="0.05" min="0.05"></div></div>';
|
||||||
}else if(step.type==='click'){
|
}else if(step.type==='click'){
|
||||||
h+='<div class="form-row"><div class="form-group"><label>选择器类型</label><select class="form-control" data-key="selector_type">'+
|
h+=_selRowHtml(p,'<div class="form-group"><label>等待超时(秒)</label><input type="number" class="form-control" data-key="wait_timeout" value="'+(p.wait_timeout||2)+'" step="0.5" min="0.5"></div>');
|
||||||
['xpath','description','text','resourceId','descriptionContains','className'].map(function(d){return '<option value="'+d+'"'+(p.selector_type===d?' selected':'')+'>'+d+'</option>';}).join('')+'</select></div>';
|
}else if(step.type==='long_click'){
|
||||||
h+='<div class="form-group" style="grid-column:span 2"><label>选择器值</label><div class="sel-picker"><input type="text" class="form-control" data-key="selector_value" value="'+esc(p.selector_value||'')+'" placeholder="如 点赞"><button class="btn btn-xs btn-pick" onclick="_stepEditor.openPicker(this)">抓取元素</button></div></div></div>';
|
h+=_selRowHtml(p,'<div class="form-row"><div class="form-group"><label>长按时长(秒)</label><input type="number" class="form-control" data-key="duration" value="'+(p.duration||1.0)+'" step="0.5" min="0.3"></div>'+
|
||||||
h+='<div class="form-group"><label>等待超时(秒)</label><input type="number" class="form-control" data-key="wait_timeout" value="'+(p.wait_timeout||2)+'" step="0.5" min="0.5"></div>';
|
'<div class="form-group"><label>等待超时(秒)</label><input type="number" class="form-control" data-key="wait_timeout" value="'+(p.wait_timeout||2)+'" step="0.5" min="0.5"></div></div>');
|
||||||
|
}else if(step.type==='swipe_until'){
|
||||||
|
h+=_selRowHtml(p,'<div class="form-row"><div class="form-group"><label>方向</label><select class="form-control" data-key="direction">'+
|
||||||
|
'<option value="up"'+(p.direction!=='down'?' selected':'')+'>上滑</option>'+
|
||||||
|
'<option value="down"'+(p.direction==='down'?' selected':'')+'>下滑</option></select></div>'+
|
||||||
|
'<div class="form-group"><label>最多滑动次数</label><input type="number" class="form-control" data-key="max_swipes" value="'+(p.max_swipes||8)+'" min="1" max="50"></div></div>'+
|
||||||
|
'<div class="form-group form-check"><input type="checkbox" data-key="click_when_found" '+(p.click_when_found!==false?'checked':'')+'><label>找到后点击</label></div>'+
|
||||||
|
'<div class="help">上滑/下滑直到目标元素出现(每次滑动后检查一次),找不到则跳过继续</div>');
|
||||||
|
}else if(step.type==='wait_el'){
|
||||||
|
h+=_selRowHtml(p,'<div class="form-group"><label>最长等待(秒)</label><input type="number" class="form-control" data-key="timeout" value="'+(p.timeout||10)+'" min="1" max="120"></div>'+
|
||||||
|
'<div class="help">等待目标元素出现(替代固定秒数等待),超时则继续</div>');
|
||||||
|
}else if(step.type==='click_xy'){
|
||||||
|
h+='<div class="form-row"><div class="form-group"><label>X(屏幕% )</label><input type="number" class="form-control" data-key="x" value="'+(p.x||50)+'" min="0" max="100"></div>'+
|
||||||
|
'<div class="form-group"><label>Y(屏幕% )</label><input type="number" class="form-control" data-key="y" value="'+(p.y||50)+'" min="0" max="100"></div></div>'+
|
||||||
|
'<div class="help">屏幕百分比坐标,50/50=屏幕中心。无选择器时的兜底点击</div>';
|
||||||
|
}else if(step.type==='key_event'){
|
||||||
|
h+='<div class="form-group"><label>按键</label><select class="form-control" data-key="key">'+
|
||||||
|
[['back','返回键'],['home','Home键'],['enter','回车键'],['menu','菜单键'],['recent','最近任务'],['delete','删除键']].map(function(k){
|
||||||
|
return '<option value="'+k[0]+'"'+(p.key===k[0]?' selected':'')+'>'+k[1]+'</option>';
|
||||||
|
}).join('')+'</select></div>';
|
||||||
|
h+='<div class="help">返回键用于退出评论/返回上一页,比结束App轻量</div>';
|
||||||
}else if(step.type==='wait'){
|
}else if(step.type==='wait'){
|
||||||
h+='<div class="form-row"><div class="form-group"><label>最短(秒)</label><input type="number" class="form-control" data-key="min" value="'+(p.min||1.0)+'" step="0.5" min="0.1"></div>';
|
h+='<div class="form-row"><div class="form-group"><label>最短(秒)</label><input type="number" class="form-control" data-key="min" value="'+(p.min||1.0)+'" step="0.5" min="0.1"></div>';
|
||||||
h+='<div class="form-group"><label>最长(秒)</label><input type="number" class="form-control" data-key="max" value="'+(p.max||3.0)+'" step="0.5" min="0.1"></div></div>';
|
h+='<div class="form-group"><label>最长(秒)</label><input type="number" class="form-control" data-key="max" value="'+(p.max||3.0)+'" step="0.5" min="0.1"></div></div>';
|
||||||
@@ -957,6 +1162,7 @@ var _stepEditor={
|
|||||||
'<option value="fixed"'+(im==='fixed'?' selected':'')+'>指定文字</option></select></div>';
|
'<option value="fixed"'+(im==='fixed'?' selected':'')+'>指定文字</option></select></div>';
|
||||||
h+='<div class="form-group" data-mode="random" style="display:'+(im==='random'?'block':'none')+'"><label>候选文字(每行一个,随机选一条)</label><textarea class="form-control" data-key="texts" rows="4" style="font-family:monospace;min-height:80px">'+esc(p.texts||'你好\n有趣\n支持')+'</textarea></div>';
|
h+='<div class="form-group" data-mode="random" style="display:'+(im==='random'?'block':'none')+'"><label>候选文字(每行一个,随机选一条)</label><textarea class="form-control" data-key="texts" rows="4" style="font-family:monospace;min-height:80px">'+esc(p.texts||'你好\n有趣\n支持')+'</textarea></div>';
|
||||||
h+='<div class="form-group" data-mode="fixed" style="display:'+(im==='fixed'?'block':'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:'+(im==='fixed'?'block':'none')+'"><label>指定文字</label><input type="text" class="form-control" data-key="fixed_text" value="'+esc(p.fixed_text||'')+'"></div>';
|
||||||
|
h+='<div class="form-group form-check"><input type="checkbox" data-key="clear_first" '+(p.clear_first!==false?'checked':'')+'><label>输入前先清空</label></div>';
|
||||||
h+='<div class="help">需先用"点击元素"步骤定位到输入框,本步骤只负责输入文字</div>';
|
h+='<div class="help">需先用"点击元素"步骤定位到输入框,本步骤只负责输入文字</div>';
|
||||||
}else if(step.type==='loop'){
|
}else if(step.type==='loop'){
|
||||||
const lm=p.loop_mode==='time'?'time':'rounds';
|
const lm=p.loop_mode==='time'?'time':'rounds';
|
||||||
@@ -968,7 +1174,7 @@ var _stepEditor={
|
|||||||
h+='<div class="help">将下方子步骤(拖拽到本卡片内)循环执行指定轮次或时长;按时间时配合"运行时长上限"看预计结束时间</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(\''+esc(step.id||'')+'\')">存为自定义动作</button> <span class="help" style="display:inline">把本组步骤保存到左侧自定义动作库,方便复用</span></div>';
|
||||||
}
|
}
|
||||||
// 共享:触发概率(所有步骤通用,0~100)
|
// 共享:触发概率(所有步骤通用,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>';
|
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>';
|
||||||
@@ -996,9 +1202,9 @@ var _stepEditor={
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation(); // 阻止冒泡到外层卡片/容器,避免重复添加
|
e.stopPropagation(); // 阻止冒泡到外层卡片/容器,避免重复添加
|
||||||
container.classList.remove('drag-over');
|
container.classList.remove('drag-over');
|
||||||
if(e.target===container||e.target.classList.contains('se-empty')){
|
// 子卡片都会 stopPropagation,能冒泡到这里的 drop 一定落在空白区
|
||||||
|
// (含 .se-empty 的提示文字),直接追加到数组末尾
|
||||||
self._handleDrop(e,self._dropArrs.get(container),-1);
|
self._handleDrop(e,self._dropArrs.get(container),-1);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
_handleDrop:function(e,arr,idx){
|
_handleDrop:function(e,arr,idx){
|
||||||
@@ -1009,17 +1215,32 @@ var _stepEditor={
|
|||||||
// 延迟到下一帧渲染,保证一次拖入只加一次。
|
// 延迟到下一帧渲染,保证一次拖入只加一次。
|
||||||
function rerender(){ setTimeout(function(){self._renderCanvas();},0); }
|
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._arrId(arr)&&fromIdx!==idx){
|
var srcArr=this._resolveArr(fromPath);
|
||||||
|
if(srcArr){
|
||||||
|
if(srcArr===arr){
|
||||||
|
// 同层级排序
|
||||||
|
if(fromIdx===idx)return;
|
||||||
this._syncFromDom();
|
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);
|
||||||
rerender();
|
rerender();
|
||||||
|
}else{
|
||||||
|
// 跨层级移入;禁止移进自己或自己的子孙(否则渲染会无限递归)
|
||||||
|
var moved2=srcArr[fromIdx];
|
||||||
|
if(moved2&&!this._containsArr(moved2,arr)){
|
||||||
|
this._syncFromDom();
|
||||||
|
srcArr.splice(fromIdx,1);
|
||||||
|
if(idx<0||idx>=arr.length)arr.push(moved2);
|
||||||
|
else arr.splice(idx,0,moved2);
|
||||||
|
rerender();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}else if(data.startsWith('action:')){
|
}else if(data.startsWith('action:')){
|
||||||
// 从自定义动作库拖入 → 展开为 group 步骤
|
// 从自定义动作库拖入 → 展开为 group 步骤
|
||||||
@@ -1029,6 +1250,7 @@ var _stepEditor={
|
|||||||
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));
|
||||||
|
_stripIds(ns.params.children); // 重新生成 id,避免同一动作拖入多次后 id 冲突
|
||||||
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);
|
||||||
@@ -1049,6 +1271,50 @@ var _stepEditor={
|
|||||||
_makeStep:function(lib){
|
_makeStep:function(lib){
|
||||||
return {id:_newStepId(),type:lib.type,label:lib.label,params:JSON.parse(JSON.stringify(lib.params))};
|
return {id:_newStepId(),type:lib.type,label:lib.label,params:JSON.parse(JSON.stringify(lib.params))};
|
||||||
},
|
},
|
||||||
|
// 测试此步骤:在选中设备上试执行当前步骤,验证选择器
|
||||||
|
_testStep:function(btn){
|
||||||
|
var card=btn.closest('.step-card');
|
||||||
|
if(!card)return;
|
||||||
|
var type=card.dataset.stepType||'';
|
||||||
|
var params={};
|
||||||
|
card.querySelectorAll('[data-key]').forEach(function(inp){
|
||||||
|
if(inp.type==='checkbox')params[inp.dataset.key]=inp.checked;
|
||||||
|
else if(inp.type==='number')params[inp.dataset.key]=parseFloat(inp.value)||0;
|
||||||
|
else params[inp.dataset.key]=inp.value;
|
||||||
|
});
|
||||||
|
this._pendingTest={type:type,label:'测试',params:params};
|
||||||
|
this._showTestDevicePicker();
|
||||||
|
},
|
||||||
|
_showTestDevicePicker:function(){
|
||||||
|
document.getElementById('test-modal-title').textContent='测试步骤 - 选择设备';
|
||||||
|
document.getElementById('test-modal-body').innerHTML=
|
||||||
|
'<div class="el-device-grid"><div class="egd-title">选择在哪个设备上试执行</div><div id="test-device-list">加载中...</div></div>';
|
||||||
|
document.getElementById('test-overlay').classList.add('show');
|
||||||
|
var self=this;
|
||||||
|
apiGet('/api/uiauto/devices').then(function(r){
|
||||||
|
var list=document.getElementById('test-device-list');
|
||||||
|
if(!list)return;
|
||||||
|
if(!r||!r.ok){list.innerHTML='<div class="se-empty">'+esc((r&&r.error)||'获取设备失败')+'</div>';return;}
|
||||||
|
list.innerHTML=(r.devices||[]).map(function(d){
|
||||||
|
return '<div class="el-device-card" onclick="_stepEditor._runTestStep(\''+esc(d.serial)+'\')">'+
|
||||||
|
'<div class="edc-icon">📱</div>'+
|
||||||
|
'<div class="edc-info"><div class="edc-serial">'+esc(d.serial)+'</div>'+
|
||||||
|
'<div class="edc-model">'+esc(d.model||d.product||'')+'</div></div></div>';
|
||||||
|
}).join('')||'<div class="se-empty">无可用设备</div>';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
_runTestStep:async function(serial){
|
||||||
|
var step=this._pendingTest;
|
||||||
|
var body=document.getElementById('test-modal-body');
|
||||||
|
if(!step||!body)return;
|
||||||
|
body.innerHTML='<div class="se-empty">正在 '+esc(serial)+' 上执行(adb + u2 连接约需几秒)...<div class="hint">请稍候,期间不要操作该设备</div></div>';
|
||||||
|
var r=await apiPost('/api/steps/test',{serial:serial,step:step});
|
||||||
|
if(!r)return;
|
||||||
|
var icon=r.result==='命中'?'✅':(r.result==='未找到'?'❌':'▶');
|
||||||
|
body.innerHTML='<div class="se-empty" style="padding:20px">'+icon+' <strong>'+esc(r.result||'已执行')+'</strong>'+
|
||||||
|
'<div class="hint" style="margin-top:8px">'+esc(r.msg||'')+'</div>'+
|
||||||
|
'<button class="btn btn-primary" style="margin-top:14px" onclick="closeTestModal()">完成</button></div>';
|
||||||
|
},
|
||||||
addStep:function(type){
|
addStep:function(type){
|
||||||
var lib=_findLib(type);
|
var lib=_findLib(type);
|
||||||
if(!lib)return;
|
if(!lib)return;
|
||||||
@@ -1063,15 +1329,17 @@ var _stepEditor={
|
|||||||
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));
|
||||||
|
_stripIds(ns.params.children); // 重新生成 id,避免同一动作拖入多次后 id 冲突
|
||||||
this._ensureIds(ns.params.children);
|
this._ensureIds(ns.params.children);
|
||||||
this._steps.push(ns);
|
this._steps.push(ns);
|
||||||
this._renderCanvas();
|
this._renderCanvas();
|
||||||
},
|
},
|
||||||
delCustomAction:function(actionId){
|
delCustomAction:function(actionId){
|
||||||
if(!confirm('确定删除这个自定义动作?'))return;
|
if(!confirm('确定删除这个自定义动作?'))return;
|
||||||
fetch('/api/custom_actions/'+actionId,{method:'DELETE'}).then(function(r){return r.json();}).then(function(res){
|
apiDelete('/api/custom_actions/'+actionId).then(function(res){
|
||||||
if(res.ok){_loadCustomActions();toast('已删除','success');}
|
if(!res)return;
|
||||||
else toast(res.error||'删除失败','error');
|
if(res.ok){_loadCustomActions();showToast('已删除','success');}
|
||||||
|
else showToast(res.error||'删除失败','error');
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
toggleSelect:function(stepId){
|
toggleSelect:function(stepId){
|
||||||
@@ -1081,44 +1349,74 @@ var _stepEditor={
|
|||||||
if(i>=0)this._selected.splice(i,1);
|
if(i>=0)this._selected.splice(i,1);
|
||||||
else this._selected.push(stepId);
|
else this._selected.push(stepId);
|
||||||
this._renderCanvas();
|
this._renderCanvas();
|
||||||
// 更新工具栏按钮状态
|
this._updatePackBtns();
|
||||||
var bar=document.getElementById('se-pack-btn');
|
|
||||||
if(bar)bar.disabled=this._selected.length<1;
|
|
||||||
},
|
},
|
||||||
saveAsCustomAction:function(){
|
// 同步所有"打包选中步骤"按钮的可用状态(任务编辑器 + 动作编辑器)
|
||||||
// 从 group 步骤的"存为自定义动作"按钮调用:收集当前展开的 group 步骤的 children
|
_updatePackBtns:function(){
|
||||||
// 或从选中步骤收集
|
var n=this._selected&&this._selected.length;
|
||||||
|
['se-pack-btn','se-pack-btn2'].forEach(function(id){
|
||||||
|
var bar=document.getElementById(id);
|
||||||
|
if(bar)bar.disabled=n<1;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
saveAsCustomAction:function(stepId){
|
||||||
|
// 从 group 步骤的"存为自定义动作"按钮调用时传入该 group 的 id:
|
||||||
|
// 优先收集该 group 的子步骤;否则收集 ☑ 选中的步骤
|
||||||
var stepsToSave=[];
|
var stepsToSave=[];
|
||||||
if(this._selected&&this._selected.length){
|
if(stepId){
|
||||||
|
var group=this._findStepById(this._steps,stepId);
|
||||||
|
if(group&&group.params&&group.params.children&&group.params.children.length){
|
||||||
|
stepsToSave=JSON.parse(JSON.stringify(group.params.children));
|
||||||
|
_stripIds(stepsToSave);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(!stepsToSave.length&&this._selected&&this._selected.length){
|
||||||
// 从选中步骤收集
|
// 从选中步骤收集
|
||||||
stepsToSave=this._collectByIds(this._steps,this._selected);
|
stepsToSave=this._collectByIds(this._steps,this._selected);
|
||||||
}
|
}
|
||||||
if(!stepsToSave.length){
|
if(!stepsToSave.length){
|
||||||
toast('请先用 ☑ 选中要打包的步骤','error');
|
showToast('请先用 ☑ 选中要打包的步骤','error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var name=prompt('输入动作名称:','我的动作');
|
var name=prompt('输入动作名称:','我的动作');
|
||||||
if(!name)return;
|
if(!name)return;
|
||||||
var self=this;
|
var self=this;
|
||||||
fetch('/api/custom_actions',{method:'POST',headers:{'Content-Type':'application/json'},
|
apiPost('/api/custom_actions',{name:name,steps:stepsToSave})
|
||||||
body:JSON.stringify({name:name,steps:stepsToSave})})
|
|
||||||
.then(function(r){return r.json();})
|
|
||||||
.then(function(res){
|
.then(function(res){
|
||||||
|
if(!res)return;
|
||||||
if(res.ok){
|
if(res.ok){
|
||||||
toast('已保存到自定义动作库','success');
|
showToast('已保存到自定义动作库','success');
|
||||||
self._selected=[];
|
self._selected=[];
|
||||||
_loadCustomActions();
|
_loadCustomActions();
|
||||||
self._renderCanvas();
|
self._renderCanvas();
|
||||||
}else toast(res.error||'保存失败','error');
|
self._updatePackBtns();
|
||||||
|
}else showToast(res.error||'保存失败','error');
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
// 在嵌套结构中按 id 查找步骤(group/loop 的子步骤也找得到)
|
||||||
|
_findStepById:function(steps,id){
|
||||||
|
for(var i=0;i<steps.length;i++){
|
||||||
|
var s=steps[i];
|
||||||
|
if(s.id===id)return s;
|
||||||
|
if(s.params&&s.params.children&&Array.isArray(s.params.children)){
|
||||||
|
var f=this._findStepById(s.params.children,id);
|
||||||
|
if(f)return f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
_collectByIds:function(steps,ids){
|
_collectByIds:function(steps,ids){
|
||||||
// 收集选中的步骤(深拷贝,去掉 id)
|
// 收集选中的步骤(深拷贝,去掉 id)。
|
||||||
|
// 递归搜索嵌套子步骤:☑ 也能选中 group/loop 内部的步骤,只搜顶层会漏
|
||||||
var result=[];
|
var result=[];
|
||||||
|
var self=this;
|
||||||
steps.forEach(function(s){
|
steps.forEach(function(s){
|
||||||
if(ids.indexOf(s.id)>=0){
|
if(ids.indexOf(s.id)>=0){
|
||||||
var copy=JSON.parse(JSON.stringify(s));
|
var copy=JSON.parse(JSON.stringify(s));
|
||||||
result.push(copy);
|
_stripIds([copy]);
|
||||||
|
result.push(copy); // 容器选中时子步骤已包含在拷贝里,不再单独收集
|
||||||
|
}else if(s.params&&s.params.children&&Array.isArray(s.params.children)){
|
||||||
|
result=result.concat(self._collectByIds(s.params.children,ids));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
@@ -1157,6 +1455,15 @@ var _stepEditor={
|
|||||||
if(path==='root')return this._steps;
|
if(path==='root')return this._steps;
|
||||||
return this._arrById&&this._arrById[path];
|
return this._arrById&&this._arrById[path];
|
||||||
},
|
},
|
||||||
|
// 判断 step 的子孙数组中是否包含 arr(防循环块套进自己导致渲染无限递归)
|
||||||
|
_containsArr:function(step,arr){
|
||||||
|
if(!step||!step.params||!Array.isArray(step.params.children))return false;
|
||||||
|
if(step.params.children===arr)return true;
|
||||||
|
for(var i=0;i<step.params.children.length;i++){
|
||||||
|
if(this._containsArr(step.params.children[i],arr))return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
move:function(idx,dir,path){
|
move:function(idx,dir,path){
|
||||||
this._syncFromDom(); // 先同步 DOM 编辑,避免重渲染丢内容
|
this._syncFromDom(); // 先同步 DOM 编辑,避免重渲染丢内容
|
||||||
var arr=this._resolveArr(path);
|
var arr=this._resolveArr(path);
|
||||||
@@ -1200,9 +1507,18 @@ var _stepEditor={
|
|||||||
if(!s.params.selector_value){
|
if(!s.params.selector_value){
|
||||||
warnings.push(name+' "'+label+'": 选择器值为空,点击步骤会跳过。请用"抓取元素"按钮选择目标。');
|
warnings.push(name+' "'+label+'": 选择器值为空,点击步骤会跳过。请用"抓取元素"按钮选择目标。');
|
||||||
}
|
}
|
||||||
}else if(s.type==='open_app'){
|
}else if(s.type==='long_click'||s.type==='swipe_until'||s.type==='wait_el'){
|
||||||
|
if(!s.params.selector_value){
|
||||||
|
warnings.push(name+' "'+label+'": 选择器值为空,该步骤会跳过。请用"抓取元素"或"测试"按钮选择目标。');
|
||||||
|
}
|
||||||
|
}else if(s.type==='click_xy'){
|
||||||
|
const x=parseFloat(s.params.x), y=parseFloat(s.params.y);
|
||||||
|
if(isNaN(x)||isNaN(y)||x<0||x>100||y<0||y>100){
|
||||||
|
warnings.push(name+' "'+label+'": 坐标需在 0-100 之间(屏幕百分比)。');
|
||||||
|
}
|
||||||
|
}else if(s.type==='open_app'||s.type==='stop_app'){
|
||||||
if(!s.params.package){
|
if(!s.params.package){
|
||||||
warnings.push(name+' "'+label+'": 包名为空,无法启动App');
|
warnings.push(name+' "'+label+'": 包名为空,无法'+(s.type==='open_app'?'启动':'结束')+'App');
|
||||||
}
|
}
|
||||||
}else if(s.type==='input_text'){
|
}else if(s.type==='input_text'){
|
||||||
if(s.params.mode==='fixed'&&!s.params.fixed_text){
|
if(s.params.mode==='fixed'&&!s.params.fixed_text){
|
||||||
@@ -1303,7 +1619,7 @@ var _stepEditor={
|
|||||||
'<div class="el-picker-main">'+
|
'<div class="el-picker-main">'+
|
||||||
'<div class="el-shot-wrap">'+
|
'<div class="el-shot-wrap">'+
|
||||||
'<div class="el-shot-toolbar">'+
|
'<div class="el-shot-toolbar">'+
|
||||||
'<button class="btn" onclick="_stepEditor._refreshShot()">刷新截图</button>'+
|
'<button class="btn" onclick="_stepEditor._refreshShot()" title="重新拉取截图和元素树(界面变化后刷新用)">刷新截图+元素</button>'+
|
||||||
'<span class="el-shot-info" id="el-shot-info">加载中...</span>'+
|
'<span class="el-shot-info" id="el-shot-info">加载中...</span>'+
|
||||||
'</div>'+
|
'</div>'+
|
||||||
'<div class="el-shot-container" id="el-shot-container">'+
|
'<div class="el-shot-container" id="el-shot-container">'+
|
||||||
@@ -1318,21 +1634,35 @@ var _stepEditor={
|
|||||||
document.getElementById('el-picker-footer').innerHTML=
|
document.getElementById('el-picker-footer').innerHTML=
|
||||||
'<button class="btn" onclick="_stepEditor._showDevicePicker()">← 返回选设备</button>'+
|
'<button class="btn" onclick="_stepEditor._showDevicePicker()">← 返回选设备</button>'+
|
||||||
'<button class="btn" onclick="closeElPicker()">取消</button>';
|
'<button class="btn" onclick="closeElPicker()">取消</button>';
|
||||||
// 加载截图和元素树
|
// 加载截图和元素树(_refreshShot 内部同时刷新两者)
|
||||||
self._refreshShot();
|
self._refreshShot();
|
||||||
fetch('/api/uiauto/elements?serial='+encodeURIComponent(serial))
|
},
|
||||||
|
// 拉取元素树(进入页面和刷新时共用;带时间戳防缓存)
|
||||||
|
_loadElData:function(){
|
||||||
|
var serial=this._elSerial;
|
||||||
|
if(!serial)return;
|
||||||
|
var listEl=document.getElementById('el-list');
|
||||||
|
if(listEl)listEl.innerHTML='<div class="se-empty">加载元素树...</div>';
|
||||||
|
var self=this;
|
||||||
|
fetch('/api/uiauto/elements?serial='+encodeURIComponent(serial)+'&_='+Date.now())
|
||||||
.then(function(r){return r.json();})
|
.then(function(r){return r.json();})
|
||||||
.then(function(data){
|
.then(function(data){
|
||||||
|
var le=document.getElementById('el-list');
|
||||||
|
if(!le)return;
|
||||||
if(!data.ok){
|
if(!data.ok){
|
||||||
document.getElementById('el-list').innerHTML='<div class="se-empty">'+esc(data.error||'抓取失败')+'</div>';
|
le.innerHTML='<div class="se-empty">'+esc(data.error||'抓取失败')+'</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self._elData=data.elements||[];
|
self._elData=data.elements||[];
|
||||||
self._renderEl(self._elData);
|
self._renderEl(self._elData);
|
||||||
|
// 刷新后保持搜索过滤状态
|
||||||
|
var search=document.getElementById('el-search');
|
||||||
|
if(search&&search.value)self._filterEl(search.value);
|
||||||
self._renderOverlays();
|
self._renderOverlays();
|
||||||
})
|
})
|
||||||
.catch(function(e){
|
.catch(function(e){
|
||||||
document.getElementById('el-list').innerHTML='<div class="se-empty">请求失败: '+esc(e)+'</div>';
|
var le=document.getElementById('el-list');
|
||||||
|
if(le)le.innerHTML='<div class="se-empty">请求失败: '+esc(e)+'</div>';
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
// 刷新截图
|
// 刷新截图
|
||||||
@@ -1360,6 +1690,8 @@ var _stepEditor={
|
|||||||
if(infoEl)infoEl.textContent='失败';
|
if(infoEl)infoEl.textContent='失败';
|
||||||
};
|
};
|
||||||
img.src='/api/uiauto/screenshot?serial='+encodeURIComponent(serial)+'&_='+Date.now();
|
img.src='/api/uiauto/screenshot?serial='+encodeURIComponent(serial)+'&_='+Date.now();
|
||||||
|
// 同时重新拉取元素树——只刷截图会导致"截图是新的、元素框是旧的"错位
|
||||||
|
this._loadElData();
|
||||||
},
|
},
|
||||||
// 在截图上渲染元素边界框(可点击)
|
// 在截图上渲染元素边界框(可点击)
|
||||||
_renderOverlays:function(){
|
_renderOverlays:function(){
|
||||||
@@ -1437,6 +1769,12 @@ var _stepEditor={
|
|||||||
item.classList.add('active');
|
item.classList.add('active');
|
||||||
item.scrollIntoView({block:'nearest',behavior:'smooth'});
|
item.scrollIntoView({block:'nearest',behavior:'smooth'});
|
||||||
}
|
}
|
||||||
|
// 信息栏显示将填入的选择器,确认是否精确命中目标
|
||||||
|
var infoEl=document.getElementById('el-shot-info');
|
||||||
|
if(infoEl&&this._elData&&this._elData[i]){
|
||||||
|
var sg=this._elData[i].suggested||{};
|
||||||
|
infoEl.textContent='已选中: '+(sg.type||'')+' '+(sg.value||'');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
_renderEl:function(list){
|
_renderEl:function(list){
|
||||||
var self=this;
|
var self=this;
|
||||||
@@ -1447,12 +1785,14 @@ var _stepEditor={
|
|||||||
if(el.text)attrs.push('<span>text:'+esc(el.text)+'</span>');
|
if(el.text)attrs.push('<span>text:'+esc(el.text)+'</span>');
|
||||||
if(el.description)attrs.push('<span>desc:'+esc(el.description)+'</span>');
|
if(el.description)attrs.push('<span>desc:'+esc(el.description)+'</span>');
|
||||||
var indent=' '.repeat(Math.min(el.depth||0,6));
|
var indent=' '.repeat(Math.min(el.depth||0,6));
|
||||||
return '<div class="el-picker-item" data-idx="'+idx+'" onclick="_stepEditor._pickEl('+idx+')">'+
|
var sg=el.suggested||{};
|
||||||
|
return '<div class="el-picker-item" data-idx="'+idx+'" title="点击填入: '+esc(sg.value||'')+'" onclick="_stepEditor._pickEl('+idx+')">'+
|
||||||
'<span class="ep-depth">'+(el.depth||0)+'</span>'+
|
'<span class="ep-depth">'+(el.depth||0)+'</span>'+
|
||||||
'<div class="ep-info"><div class="ep-name">'+indent+esc(el.name||el.class||'')+'</div>'+
|
'<div class="ep-info"><div class="ep-name">'+indent+esc(el.name||el.class||'')+'</div>'+
|
||||||
'<div class="ep-attrs">'+attrs.join('')+'</div></div>'+
|
'<div class="ep-attrs">'+attrs.join('')+'</div></div>'+
|
||||||
'<span class="ep-sel">'+esc(el.suggested.type)+'</span>'+
|
'<span class="ep-sel">'+esc(sg.type)+'</span>'+
|
||||||
(el.suggested&&el.suggested.broad?' <span class="label label-warning" title="无唯一属性,选择器可能误匹配其他元素,建议选带文字/id 的元素">⚠宽泛</span>':'')+
|
(sg.indexed?' <span class="label label-info" title="界面中同属性元素共 '+sg.total+' 个,按从上到下这是第 '+sg.occ+' 个,选择器已带序号精确定位">#'+sg.occ+'/'+sg.total+'</span>':'')+
|
||||||
|
(sg.broad?' <span class="label label-warning" title="无唯一属性,选择器可能误匹配其他元素,建议选带文字/id 的元素">⚠宽泛</span>':'')+
|
||||||
'</div>';
|
'</div>';
|
||||||
}).join('');
|
}).join('');
|
||||||
document.getElementById('el-list').innerHTML=html||'<div class="se-empty">无元素</div>';
|
document.getElementById('el-list').innerHTML=html||'<div class="se-empty">无元素</div>';
|
||||||
@@ -1511,11 +1851,19 @@ async function saveTask(jobId){
|
|||||||
const schedMode=document.getElementById('f-schedule_mode').value;
|
const schedMode=document.getElementById('f-schedule_mode').value;
|
||||||
let schedule={mode:'once'};
|
let schedule={mode:'once'};
|
||||||
if(schedMode==='cron'){
|
if(schedMode==='cron'){
|
||||||
schedule={mode:'cron',cron:document.getElementById('f-schedule_cron').value.trim()};
|
schedule={mode:'cron',cron:buildCronFromFreq()};
|
||||||
}else if(schedMode==='cron_stop'){
|
}else if(schedMode==='cron_stop'){
|
||||||
|
const st=(document.getElementById('f-schedule_stop_time').value||'18:00').split(':');
|
||||||
schedule={mode:'cron_stop',
|
schedule={mode:'cron_stop',
|
||||||
cron:document.getElementById('f-schedule_cron').value.trim(),
|
cron:buildCronFromFreq(),
|
||||||
stop_cron:document.getElementById('f-schedule_stop_cron').value.trim()};
|
stop_cron:(parseInt(st[1])||0)+' '+(parseInt(st[0])||0)+' * * *'};
|
||||||
|
}
|
||||||
|
// 运行窗口:窗口外(定时触发 + 手动执行)不启动
|
||||||
|
if(document.getElementById('f-window_enable').checked){
|
||||||
|
schedule.window={
|
||||||
|
start:document.getElementById('f-window_start').value||'09:00',
|
||||||
|
end:document.getElementById('f-window_end').value||'18:00',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const data={
|
const data={
|
||||||
@@ -1567,8 +1915,10 @@ function renderActionRow(a){
|
|||||||
'<td>'+n+'</td>'+
|
'<td>'+n+'</td>'+
|
||||||
'<td style="font-size:12px;color:#94a3b8">'+esc(a.created_at||'-')+'</td>'+
|
'<td style="font-size:12px;color:#94a3b8">'+esc(a.created_at||'-')+'</td>'+
|
||||||
'<td>'+
|
'<td>'+
|
||||||
|
(_can('tasks')?(
|
||||||
'<button class="btn btn-xs" onclick="openActionModal(\''+a.id+'\')">编辑</button> '+
|
'<button class="btn btn-xs" onclick="openActionModal(\''+a.id+'\')">编辑</button> '+
|
||||||
'<button class="btn btn-danger btn-xs" onclick="deleteAction(\''+a.id+'\')">删除</button>'+
|
'<button class="btn btn-danger btn-xs" onclick="deleteAction(\''+a.id+'\')">删除</button>'
|
||||||
|
):'<span class="text-muted">只读</span>')+
|
||||||
'</td>'+
|
'</td>'+
|
||||||
'</tr>';
|
'</tr>';
|
||||||
}
|
}
|
||||||
@@ -1583,7 +1933,8 @@ function openActionModal(actionId){
|
|||||||
'</div>'+
|
'</div>'+
|
||||||
'<div class="help" style="margin-bottom:8px">用下方步骤编辑器编排动作内容,保存后可拖入任务步骤编辑器复用</div>'+
|
'<div class="help" style="margin-bottom:8px">用下方步骤编辑器编排动作内容,保存后可拖入任务步骤编辑器复用</div>'+
|
||||||
'<div class="step-editor" style="display:block">'+
|
'<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-head"><span>动作步骤</span><span style="font-size:11px;color:var(--text-light);margin-left:12px">支持循环/分组/概率触发/拖拽排序</span>'+
|
||||||
|
(_can('tasks')?'<button class="btn btn-xs btn-primary" id="se-pack-btn2" disabled onclick="_stepEditor.saveAsCustomAction()">打包选中步骤</button>':'')+'</div>'+
|
||||||
'<div class="se-body">'+
|
'<div class="se-body">'+
|
||||||
'<div class="se-lib" id="action-lib"><div class="lib-title">操作库</div></div>'+
|
'<div class="se-lib" id="action-lib"><div class="lib-title">操作库</div></div>'+
|
||||||
'<div class="se-canvas" id="action-canvas"></div>'+
|
'<div class="se-canvas" id="action-canvas"></div>'+
|
||||||
@@ -1601,6 +1952,7 @@ async function saveAction(actionId){
|
|||||||
if(!name){showToast('动作名不能为空','error');return;}
|
if(!name){showToast('动作名不能为空','error');return;}
|
||||||
const icon=document.getElementById('a-icon').value.trim()||'📦';
|
const icon=document.getElementById('a-icon').value.trim()||'📦';
|
||||||
const steps=_stepEditor.collect();
|
const steps=_stepEditor.collect();
|
||||||
|
_stripIds(steps); // 入库前去掉 id,避免重复使用后 id 冲突
|
||||||
if(!steps.length){showToast('至少需要1个步骤','error');return;}
|
if(!steps.length){showToast('至少需要1个步骤','error');return;}
|
||||||
const data={name:name,icon:icon,steps:steps};
|
const data={name:name,icon:icon,steps:steps};
|
||||||
if(actionId){
|
if(actionId){
|
||||||
@@ -1649,8 +2001,10 @@ function renderGroupRow(g){
|
|||||||
'<td>'+serials.length+'</td>'+
|
'<td>'+serials.length+'</td>'+
|
||||||
'<td class="text-muted" style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+(serials.length?serials.map(esc).join(', '):'-')+'</td>'+
|
'<td class="text-muted" style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+(serials.length?serials.map(esc).join(', '):'-')+'</td>'+
|
||||||
'<td>'+
|
'<td>'+
|
||||||
|
(_can('tasks')?(
|
||||||
'<button class="btn btn-xs" onclick="openGroupModal(\''+esc(g.name)+'\')">编辑</button>'+
|
'<button class="btn btn-xs" onclick="openGroupModal(\''+esc(g.name)+'\')">编辑</button>'+
|
||||||
'<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>'
|
||||||
|
):'<span class="text-muted">只读</span>')+
|
||||||
'</td>'+
|
'</td>'+
|
||||||
'</tr>';
|
'</tr>';
|
||||||
}
|
}
|
||||||
@@ -1738,17 +2092,20 @@ async function loadUsers(){
|
|||||||
filter:(u,kw)=>(u.username||'').toLowerCase().includes(kw),
|
filter:(u,kw)=>(u.username||'').toLowerCase().includes(kw),
|
||||||
render:renderUserRow,
|
render:renderUserRow,
|
||||||
tbody:'tb-users',pager:'pager-users',pageSize:10,
|
tbody:'tb-users',pager:'pager-users',pageSize:10,
|
||||||
empty:'<tr><td colspan="4" class="empty">暂无用户</td></tr>'
|
empty:'<tr><td colspan="5" class="empty">暂无用户</td></tr>'
|
||||||
});
|
});
|
||||||
applyListPager('users');
|
applyListPager('users');
|
||||||
initListSearch('users','search-users');
|
initListSearch('users','search-users');
|
||||||
}
|
}
|
||||||
function renderUserRow(u){
|
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>';
|
||||||
|
const perms=(u.perms||[]).map(p=>'<span class="label label-info">'+(PERM_LABELS[p]||p)+'</span>').join(' ')
|
||||||
|
||'<span class="text-muted">-</span>';
|
||||||
return '<tr>'+
|
return '<tr>'+
|
||||||
'<td>'+u.id+'</td>'+
|
'<td>'+u.id+'</td>'+
|
||||||
'<td><strong>'+esc(u.username)+'</strong></td>'+
|
'<td><strong>'+esc(u.username)+'</strong></td>'+
|
||||||
'<td>'+admin+'</td>'+
|
'<td>'+admin+'</td>'+
|
||||||
|
'<td>'+perms+'</td>'+
|
||||||
'<td>'+
|
'<td>'+
|
||||||
'<button class="btn btn-xs" onclick="openUserModal('+u.id+')">编辑</button>'+
|
'<button class="btn btn-xs" onclick="openUserModal('+u.id+')">编辑</button>'+
|
||||||
(u.username==='admin'?'':'<button class="btn btn-danger btn-xs" onclick="deleteUser('+u.id+')">删除</button>')+
|
(u.username==='admin'?'':'<button class="btn btn-danger btn-xs" onclick="deleteUser('+u.id+')">删除</button>')+
|
||||||
@@ -1756,13 +2113,29 @@ function renderUserRow(u){
|
|||||||
'</tr>';
|
'</tr>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function togglePermBoxes(){
|
||||||
|
// 勾选"管理员权限"时:权限位全选并禁用(管理员拥有全部权限)
|
||||||
|
const isAdmin=document.getElementById('u-is_admin').checked;
|
||||||
|
document.querySelectorAll('.u-perm').forEach(cb=>{
|
||||||
|
cb.disabled=isAdmin;
|
||||||
|
if(isAdmin)cb.checked=true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function openUserModal(uid){
|
function openUserModal(uid){
|
||||||
const isEdit=!!uid;
|
const isEdit=!!uid;
|
||||||
document.getElementById('modal-title').textContent=isEdit?'编辑用户':'新建用户';
|
document.getElementById('modal-title').textContent=isEdit?'编辑用户':'新建用户';
|
||||||
|
const permBoxes=Object.keys(PERM_LABELS).map(p=>
|
||||||
|
'<label style="margin-right:12px"><input type="checkbox" class="u-perm" value="'+p+'">'+PERM_LABELS[p]+'</label>'
|
||||||
|
).join('');
|
||||||
document.getElementById('modal-body').innerHTML=
|
document.getElementById('modal-body').innerHTML=
|
||||||
'<div class="form-group"><label>用户名</label><input type="text" class="form-control" id="u-username" '+(isEdit?'disabled':'')+' placeholder="用户名"></div>'+
|
'<div class="form-group"><label>用户名</label><input type="text" class="form-control" id="u-username" '+(isEdit?'disabled':'')+' placeholder="用户名"></div>'+
|
||||||
'<div class="form-group"><label>密码'+(isEdit?'(留空不修改)':'')+'</label><input type="password" class="form-control" id="u-password" placeholder="密码"></div>'+
|
'<div class="form-group"><label>密码'+(isEdit?'(留空不修改)':'')+'</label><input type="password" class="form-control" id="u-password" placeholder="密码"></div>'+
|
||||||
'<div class="form-group form-check"><input type="checkbox" id="u-is_admin" checked><label for="u-is_admin">管理员权限</label></div>';
|
'<div class="form-group form-check"><input type="checkbox" id="u-is_admin" onchange="togglePermBoxes()" checked><label for="u-is_admin">管理员权限(拥有全部权限)</label></div>'+
|
||||||
|
'<div class="form-group"><label>业务权限(管理员自动拥有全部,无需勾选)</label>'+
|
||||||
|
'<div>'+permBoxes+'</div>'+
|
||||||
|
'<div class="help">任务管理=任务/自定义动作/分组;设备控制=停止/释放/清异常/扫描/抓元素;应用管理=APK上传安装;日志查看=日志页</div>'+
|
||||||
|
'</div>';
|
||||||
|
|
||||||
document.getElementById('modal-footer').innerHTML=
|
document.getElementById('modal-footer').innerHTML=
|
||||||
'<button class="btn" onclick="closeModal()">取消</button>'+
|
'<button class="btn" onclick="closeModal()">取消</button>'+
|
||||||
@@ -1770,13 +2143,19 @@ function openUserModal(uid){
|
|||||||
|
|
||||||
if(isEdit){
|
if(isEdit){
|
||||||
// 预填充(从表格数据获取)
|
// 预填充(从表格数据获取)
|
||||||
fetch('/api/users').then(r=>r.json()).then(r=>{
|
apiGet('/api/users').then(r=>{
|
||||||
const u=r.users.find(x=>x.id===uid);
|
const u=(r&&r.users||[]).find(x=>x.id===uid);
|
||||||
if(u){
|
if(u){
|
||||||
document.getElementById('u-username').value=u.username;
|
document.getElementById('u-username').value=u.username;
|
||||||
document.getElementById('u-is_admin').checked=u.is_admin;
|
document.getElementById('u-is_admin').checked=u.is_admin;
|
||||||
|
document.querySelectorAll('.u-perm').forEach(cb=>{
|
||||||
|
cb.checked=(u.perms||[]).includes(cb.value);
|
||||||
|
});
|
||||||
|
togglePermBoxes();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}else{
|
||||||
|
togglePermBoxes();
|
||||||
}
|
}
|
||||||
showModal();
|
showModal();
|
||||||
}
|
}
|
||||||
@@ -1785,15 +2164,16 @@ async function saveUser(uid){
|
|||||||
const username=document.getElementById('u-username').value.trim();
|
const username=document.getElementById('u-username').value.trim();
|
||||||
const password=document.getElementById('u-password').value;
|
const password=document.getElementById('u-password').value;
|
||||||
const is_admin=document.getElementById('u-is_admin').checked;
|
const is_admin=document.getElementById('u-is_admin').checked;
|
||||||
|
const perms=[...document.querySelectorAll('.u-perm:checked')].map(cb=>cb.value);
|
||||||
|
|
||||||
if(uid){
|
if(uid){
|
||||||
const data={is_admin:is_admin};
|
const data={is_admin:is_admin,perms:perms};
|
||||||
if(password)data.password=password;
|
if(password)data.password=password;
|
||||||
const r=await apiPut('/api/users/'+uid,data);
|
const r=await apiPut('/api/users/'+uid,data);
|
||||||
handleResult(r,()=>{closeModal();loadUsers();});
|
handleResult(r,()=>{closeModal();loadUsers();});
|
||||||
}else{
|
}else{
|
||||||
if(!username||!password){showToast('用户名和密码不能为空','error');return;}
|
if(!username||!password){showToast('用户名和密码不能为空','error');return;}
|
||||||
const r=await apiPost('/api/users',{username:username,password:password,is_admin:is_admin});
|
const r=await apiPost('/api/users',{username:username,password:password,is_admin:is_admin,perms:perms});
|
||||||
handleResult(r,()=>{closeModal();loadUsers();});
|
handleResult(r,()=>{closeModal();loadUsers();});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1804,6 +2184,89 @@ async function deleteUser(uid){
|
|||||||
handleResult(r,()=>loadUsers());
|
handleResult(r,()=>loadUsers());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ================== Tab: 维护(仅管理员) ==================
|
||||||
|
// 服务器类命令(不需要 -s 目标设备)
|
||||||
|
const _NO_DEVICE_CMDS=['devices','version','help','start-server','connect','kill-server','disconnect'];
|
||||||
|
function _selectedSerial(){
|
||||||
|
const sel=document.getElementById('adb-device');
|
||||||
|
return sel?sel.value:'';
|
||||||
|
}
|
||||||
|
// 选中目标设备时自动附加 -s <serial>;服务器类命令保持原样
|
||||||
|
function _attachDevice(cmd){
|
||||||
|
const serial=_selectedSerial();
|
||||||
|
if(!serial)return cmd;
|
||||||
|
if(/^-s\s/.test(cmd))return cmd; // 已手写 -s 不再重复附加
|
||||||
|
const first=(cmd.split(/\s+/)[0]||'').toLowerCase();
|
||||||
|
if(_NO_DEVICE_CMDS.indexOf(first)>=0)return cmd;
|
||||||
|
return '-s '+serial+' '+cmd;
|
||||||
|
}
|
||||||
|
async function loadAdbDevices(){
|
||||||
|
const sel=document.getElementById('adb-device');
|
||||||
|
if(!sel)return;
|
||||||
|
const r=await apiGet('/api/adb/devices');
|
||||||
|
if(!r||!r.ok)return;
|
||||||
|
const cur=sel.value;
|
||||||
|
const stateTxt={device:'在线',offline:'离线',stf:'STF池(未连接)'};
|
||||||
|
const opts=['<option value="">(不指定设备)</option>'].concat(
|
||||||
|
(r.devices||[]).map(d=>
|
||||||
|
'<option value="'+esc(d.serial)+'">'+esc(d.serial)+' ('+(stateTxt[d.state]||d.state)+')</option>'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
sel.innerHTML=opts.join('');
|
||||||
|
if(cur)sel.value=cur;
|
||||||
|
}
|
||||||
|
function fillAdbCmd(cmd){
|
||||||
|
document.getElementById('adb-cmd-input').value=cmd;
|
||||||
|
}
|
||||||
|
async function _runAdbFull(full){
|
||||||
|
const out=document.getElementById('adb-output');
|
||||||
|
out.textContent='$ '+full+'\n执行中...';
|
||||||
|
const r=await apiPost('/api/adb/cmd',{cmd:full});
|
||||||
|
if(!r)return;
|
||||||
|
if(!r.ok){out.textContent='$ '+full+'\n⚠ '+r.error;return;}
|
||||||
|
out.textContent='$ '+full+'\n\n'+(r.stdout||'')+(r.stderr?'\n[stderr] '+r.stderr:'')+'\n\n[exit '+r.code+']';
|
||||||
|
out.scrollTop=out.scrollHeight;
|
||||||
|
}
|
||||||
|
async function runAdbCmd(){
|
||||||
|
const input=document.getElementById('adb-cmd-input');
|
||||||
|
let cmd=(input.value||'').trim();
|
||||||
|
if(!cmd){showToast('请输入 adb 命令','error');return;}
|
||||||
|
cmd=cmd.replace(/^adb\s+/i,''); // 兼容手输/快捷按钮带的 "adb " 前缀,避免拼成 "adb adb devices"
|
||||||
|
cmd=_attachDevice(cmd);
|
||||||
|
await _runAdbFull('adb '+cmd);
|
||||||
|
}
|
||||||
|
function uninstallStfAgent(){
|
||||||
|
const serial=_selectedSerial();
|
||||||
|
if(!serial){showToast('请先选择目标设备','error');return;}
|
||||||
|
if(!confirm('确定卸载 '+serial+' 上的 STF agent(jp.co.cyberagent.stf)?\n\n卸载后 STF 会在设备重连时自动重新推送安装。'))return;
|
||||||
|
_runAdbFull('adb -s '+serial+' uninstall jp.co.cyberagent.stf');
|
||||||
|
}
|
||||||
|
function reconnectAdbDevice(){
|
||||||
|
const serial=_selectedSerial();
|
||||||
|
if(!serial){showToast('请先选择目标设备','error');return;}
|
||||||
|
_runAdbFull('adb connect '+serial);
|
||||||
|
}
|
||||||
|
function checkStfAgent(){
|
||||||
|
const serial=_selectedSerial();
|
||||||
|
if(!serial){showToast('请先选择目标设备','error');return;}
|
||||||
|
_runAdbFull('adb -s '+serial+' shell pm list packages | grep cyberagent');
|
||||||
|
}
|
||||||
|
async function restartStf(){
|
||||||
|
if(!confirm('确定重启 STF 容器?\n\n影响:STF 约 30 秒不可用,期间所有设备占用状态重置,正在执行的任务会受影响。\n\n确认继续吗?'))return;
|
||||||
|
const st=document.getElementById('stf-restart-status');
|
||||||
|
st.textContent='正在重启 STF 容器(SSH 执行中,最长 60s)...';
|
||||||
|
const r=await apiPost('/api/stf/restart',{});
|
||||||
|
if(!r)return;
|
||||||
|
if(r.ok){st.textContent='✅ '+r.msg+(r.detail?' ['+r.detail+']':'');showToast(r.msg,'success');}
|
||||||
|
else{st.textContent='❌ '+r.error;showToast(r.error,'error');}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试此步骤弹窗关闭
|
||||||
|
function closeTestModal(){
|
||||||
|
const ov=document.getElementById('test-overlay');
|
||||||
|
if(ov)ov.classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
// ================== Tab: 应用 ==================
|
// ================== Tab: 应用 ==================
|
||||||
let _apksCache=[];
|
let _apksCache=[];
|
||||||
let _installTimer=null;
|
let _installTimer=null;
|
||||||
@@ -1952,11 +2415,10 @@ async function deleteApk(id){
|
|||||||
async function openInstallModal(apkId){
|
async function openInstallModal(apkId){
|
||||||
const apk=_apksCache.find(a=>a.id===apkId);
|
const apk=_apksCache.find(a=>a.id===apkId);
|
||||||
const apkName=apk?(apk.display_name||'APK'):'APK';
|
const apkName=apk?(apk.display_name||'APK'):'APK';
|
||||||
const sr=await apiGet('/api/status');if(!sr||!sr.ok)return;
|
const r=await apiGet('/api/apks/install/devices');
|
||||||
const devices=sr.devices||[];
|
const online=(r&&r.ok)?(r.devices||[]):[];
|
||||||
const online=devices.filter(d=>d.present);
|
|
||||||
if(!online.length){
|
if(!online.length){
|
||||||
showToast('没有在线设备','error');return;
|
showToast('没有可用设备(STF 池或本机 adb 均无)','error');return;
|
||||||
}
|
}
|
||||||
const body=
|
const body=
|
||||||
'<div style="margin-bottom:12px">'+
|
'<div style="margin-bottom:12px">'+
|
||||||
@@ -1970,11 +2432,11 @@ async function openInstallModal(apkId){
|
|||||||
'</div>'+
|
'</div>'+
|
||||||
'<div id="install-dev-list" class="device-list">'+
|
'<div id="install-dev-list" class="device-list">'+
|
||||||
online.map(d=>{
|
online.map(d=>{
|
||||||
const running=d.worker_status==='running'||d.worker_status==='connecting';
|
const isUsb=d.source==='usb';
|
||||||
const st='<span class="label label-'+(running?'success':'default')+'" style="margin-left:6px">'+(running?'运行中':'空闲')+'</span>';
|
const st='<span class="label label-'+(isUsb?'primary':'default')+'" style="margin-left:6px">'+(isUsb?'本地USB':(d.source==='stf'?'STF':'adb'))+'</span>';
|
||||||
// serial 唯一,显示 IP + 型号便于区分同型号设备
|
// serial 唯一,显示 IP + 型号便于区分同型号设备
|
||||||
const label=(d.serial||'').replace(/:5555$/,'')+' · '+(d.device_name||d.model||'设备');
|
const label=(d.serial||'').replace(/:5555$/,'')+' · '+(d.model||(isUsb?'USB 有线设备':'设备'));
|
||||||
return '<label><input type="checkbox" value="'+esc(d.serial)+'"'+(running?'':' checked')+'>'+esc(label)+st+'</label>';
|
return '<label><input type="checkbox" value="'+esc(d.serial)+'" checked>'+esc(label)+st+'</label>';
|
||||||
}).join('')+
|
}).join('')+
|
||||||
'</div>';
|
'</div>';
|
||||||
|
|
||||||
@@ -2108,4 +2570,5 @@ async function scanForeground(){
|
|||||||
|
|
||||||
// ================== 初始化 ==================
|
// ================== 初始化 ==================
|
||||||
initCsrf(); // 获取 CSRF token(非 GET 请求需携带)
|
initCsrf(); // 获取 CSRF token(非 GET 请求需携带)
|
||||||
|
loadMe(); // 获取当前用户权限,隐藏无权限的功能入口
|
||||||
showTab('monitor');
|
showTab('monitor');
|
||||||
|
|||||||
+237
-6
@@ -13,9 +13,18 @@ worker 按 steps 顺序执行,支持 loop 步骤循环、停止信号、进度
|
|||||||
|
|
||||||
支持的步骤类型:
|
支持的步骤类型:
|
||||||
open_app - 启动 app
|
open_app - 启动 app
|
||||||
|
stop_app - 强制结束 app(am force-stop,下次打开冷启动)
|
||||||
|
screen_on - 亮屏(息屏时唤醒并滑动解锁)
|
||||||
|
screen_off - 息屏
|
||||||
|
keep_screen - 保持亮屏/恢复自动息屏(svc power stayon,设备充电时常亮)
|
||||||
|
key_event - 按键(返回/Home/回车/菜单等,d.press)
|
||||||
swipe - 滑动(方向/时长)
|
swipe - 滑动(方向/时长)
|
||||||
|
swipe_until - 滑动直到元素出现(最多 N 次,可选中找到后点击)
|
||||||
click - 点击元素(选择器)
|
click - 点击元素(选择器)
|
||||||
input_text - 输入文字(随机候选/指定文字)
|
click_xy - 点击坐标(屏幕百分比,无选择器时兜底)
|
||||||
|
long_click - 长按元素(选择器 + 时长)
|
||||||
|
wait_el - 等待元素出现(条件等待,替代固定时长 wait)
|
||||||
|
input_text - 输入文字(随机候选/指定文字,可先清空)
|
||||||
wait - 等待时长
|
wait - 等待时长
|
||||||
loop - 循环块(含 children 步骤列表 + max_iterations)
|
loop - 循环块(含 children 步骤列表 + max_iterations)
|
||||||
group - 动作组(含 children 步骤列表,按序执行一次,可折叠复用)
|
group - 动作组(含 children 步骤列表,按序执行一次,可折叠复用)
|
||||||
@@ -35,12 +44,31 @@ _log = get_logger("task.generic")
|
|||||||
STEP_TYPES = [
|
STEP_TYPES = [
|
||||||
{"type": "open_app", "label": "打开App", "icon": "▶",
|
{"type": "open_app", "label": "打开App", "icon": "▶",
|
||||||
"params": {"package": "", "wait_home": False, "home_feature": ""}},
|
"params": {"package": "", "wait_home": False, "home_feature": ""}},
|
||||||
|
{"type": "stop_app", "label": "结束App", "icon": "⏹",
|
||||||
|
"params": {"package": ""}},
|
||||||
|
{"type": "screen_on", "label": "亮屏", "icon": "💡",
|
||||||
|
"params": {}},
|
||||||
|
{"type": "screen_off", "label": "息屏", "icon": "🌙",
|
||||||
|
"params": {}},
|
||||||
|
{"type": "keep_screen", "label": "保持亮屏", "icon": "🔆",
|
||||||
|
"params": {"mode": "on"}},
|
||||||
|
{"type": "key_event", "label": "按键", "icon": "⌨️",
|
||||||
|
"params": {"key": "back"}},
|
||||||
{"type": "swipe", "label": "滑动", "icon": "↕",
|
{"type": "swipe", "label": "滑动", "icon": "↕",
|
||||||
"params": {"direction": "up", "duration_min": 0.25, "duration_max": 0.50}},
|
"params": {"direction": "up", "duration_min": 0.25, "duration_max": 0.50}},
|
||||||
|
{"type": "swipe_until", "label": "滑动直到元素", "icon": "🔍",
|
||||||
|
"params": {"selector_type": "xpath", "selector_value": "", "direction": "up",
|
||||||
|
"max_swipes": 8, "click_when_found": True}},
|
||||||
{"type": "click", "label": "点击元素", "icon": "✦",
|
{"type": "click", "label": "点击元素", "icon": "✦",
|
||||||
"params": {"selector_type": "description", "selector_value": "", "wait_timeout": 2}},
|
"params": {"selector_type": "description", "selector_value": "", "wait_timeout": 2}},
|
||||||
|
{"type": "click_xy", "label": "点击坐标", "icon": "🎯",
|
||||||
|
"params": {"x": 50, "y": 50}},
|
||||||
|
{"type": "long_click", "label": "长按元素", "icon": "👆",
|
||||||
|
"params": {"selector_type": "xpath", "selector_value": "", "duration": 1.0, "wait_timeout": 2}},
|
||||||
|
{"type": "wait_el", "label": "等待元素", "icon": "⏳",
|
||||||
|
"params": {"selector_type": "xpath", "selector_value": "", "timeout": 10}},
|
||||||
{"type": "input_text", "label": "输入文字", "icon": "⌨",
|
{"type": "input_text", "label": "输入文字", "icon": "⌨",
|
||||||
"params": {"mode": "random", "texts": "你好\n有趣\n支持", "fixed_text": ""}},
|
"params": {"mode": "random", "texts": "你好\n有趣\n支持", "fixed_text": "", "clear_first": True}},
|
||||||
{"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": "↻",
|
||||||
@@ -140,10 +168,11 @@ class GenericStepsWorker(BaseWorker):
|
|||||||
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}")
|
||||||
|
|
||||||
|
ret = None
|
||||||
handler = getattr(self, f"_exec_{stype}", None)
|
handler = getattr(self, f"_exec_{stype}", None)
|
||||||
if handler:
|
if handler:
|
||||||
try:
|
try:
|
||||||
handler(d, params, depth)
|
ret = handler(d, params, depth)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log.error(f"[{self.serial}] 步骤 {label}({stype}) 异常: {e}")
|
_log.error(f"[{self.serial}] 步骤 {label}({stype}) 异常: {e}")
|
||||||
else:
|
else:
|
||||||
@@ -156,8 +185,54 @@ class GenericStepsWorker(BaseWorker):
|
|||||||
self.set_progress(done=sum(self._action_counts.values()),
|
self.set_progress(done=sum(self._action_counts.values()),
|
||||||
total=0, unit="操作",
|
total=0, unit="操作",
|
||||||
action_counts=dict(self._action_counts), elapsed=elapsed)
|
action_counts=dict(self._action_counts), elapsed=elapsed)
|
||||||
|
return ret # 命中结果(True/False/None),"测试此步骤"用
|
||||||
|
|
||||||
# ================== 步骤执行器 ==================
|
# ================== 步骤执行器 ==================
|
||||||
|
def _exec_screen_on(self, d, params, depth=0):
|
||||||
|
"""亮屏:息屏时唤醒并滑动解锁。任务执行前用(息屏时 u2 无法操作)。"""
|
||||||
|
try:
|
||||||
|
d.unlock()
|
||||||
|
_log.info(f"[{self.serial}] 亮屏")
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"[{self.serial}] 亮屏异常: {e}")
|
||||||
|
|
||||||
|
def _exec_screen_off(self, d, params, depth=0):
|
||||||
|
"""息屏:任务结束后用,避免长时间亮屏(烧屏/发热)。"""
|
||||||
|
try:
|
||||||
|
d.screen_off()
|
||||||
|
_log.info(f"[{self.serial}] 息屏")
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"[{self.serial}] 息屏异常: {e}")
|
||||||
|
|
||||||
|
def _exec_keep_screen(self, d, params, depth=0):
|
||||||
|
"""保持亮屏 / 恢复自动息屏。
|
||||||
|
|
||||||
|
长任务(养号看视频数小时)时屏幕会按系统超时自动熄灭导致任务无法执行,
|
||||||
|
用 svc power stayon 让充电时屏幕常亮;任务结束前用 mode=off 恢复自动息屏。
|
||||||
|
"""
|
||||||
|
mode = params.get("mode", "on")
|
||||||
|
try:
|
||||||
|
if mode == "off":
|
||||||
|
d.shell("svc power stayon false")
|
||||||
|
_log.info(f"[{self.serial}] 恢复自动息屏")
|
||||||
|
else:
|
||||||
|
d.shell("svc power stayon true")
|
||||||
|
_log.info(f"[{self.serial}] 保持亮屏(充电时屏幕常亮)")
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"[{self.serial}] keep_screen({mode}) 异常: {e}")
|
||||||
|
|
||||||
|
def _exec_stop_app(self, d, params, depth=0):
|
||||||
|
"""强制结束 App(am force-stop)。清后台不留进程,下次打开为冷启动。"""
|
||||||
|
package = params.get("package", "")
|
||||||
|
if not package:
|
||||||
|
_log.warning(f"[{self.serial}] stop_app 缺少 package 参数")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
d.app_stop(package)
|
||||||
|
_log.info(f"[{self.serial}] 已强制结束 {package}")
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"[{self.serial}] 结束 {package} 异常: {e}")
|
||||||
|
|
||||||
def _exec_open_app(self, d, params, depth=0):
|
def _exec_open_app(self, d, params, depth=0):
|
||||||
package = params.get("package", "")
|
package = params.get("package", "")
|
||||||
if not package:
|
if not package:
|
||||||
@@ -171,6 +246,125 @@ class GenericStepsWorker(BaseWorker):
|
|||||||
if not wait_for_app_home(d, package, home_check, timeout=40):
|
if not wait_for_app_home(d, package, home_check, timeout=40):
|
||||||
self.set_action(f"{package} 首页加载超时,继续")
|
self.set_action(f"{package} 首页加载超时,继续")
|
||||||
|
|
||||||
|
def _exec_key_event(self, d, params, depth=0):
|
||||||
|
"""按键:返回/Home/回车等(d.press)。退出评论、返回上一页必备。"""
|
||||||
|
key = params.get("key", "back")
|
||||||
|
try:
|
||||||
|
d.press(key)
|
||||||
|
_log.info(f"[{self.serial}] 按键: {key}")
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"[{self.serial}] 按键 {key} 异常: {e}")
|
||||||
|
|
||||||
|
def _exec_swipe_until(self, d, params, depth=0):
|
||||||
|
"""滑动直到元素出现(最多 max_swipes 次),可选找到后点击。
|
||||||
|
|
||||||
|
养号核心动作:信息流刷到目标按钮(点赞/评论)再操作。
|
||||||
|
返回 True=找到, False=未找到, None=配置缺选择器。
|
||||||
|
"""
|
||||||
|
sel_type = params.get("selector_type", "xpath")
|
||||||
|
sel_val = params.get("selector_value", "")
|
||||||
|
direction = params.get("direction", "up")
|
||||||
|
max_swipes = max(1, int(params.get("max_swipes", 8)))
|
||||||
|
click_when_found = params.get("click_when_found", True)
|
||||||
|
if not sel_val:
|
||||||
|
_log.warning("swipe_until 缺少选择器")
|
||||||
|
return None
|
||||||
|
info = d.info
|
||||||
|
w, h = info["displayWidth"], info["displayHeight"]
|
||||||
|
cx = int(w * 0.5)
|
||||||
|
if direction == "down":
|
||||||
|
sy, ey = int(h * 0.2), int(h * 0.8)
|
||||||
|
else:
|
||||||
|
sy, ey = int(h * 0.8), int(h * 0.2)
|
||||||
|
for i in range(1, max_swipes + 1):
|
||||||
|
if self.stopped() or self.is_time_up():
|
||||||
|
return None
|
||||||
|
found = False
|
||||||
|
try:
|
||||||
|
if sel_type == "xpath":
|
||||||
|
el = d.xpath(sel_val)
|
||||||
|
found = el.wait(timeout=1)
|
||||||
|
else:
|
||||||
|
el = d(**{sel_type: sel_val})
|
||||||
|
found = el.exists(timeout=1)
|
||||||
|
except Exception:
|
||||||
|
found = False
|
||||||
|
if found:
|
||||||
|
self.set_action(f"滑动找到元素(第{i}次)")
|
||||||
|
if click_when_found:
|
||||||
|
try:
|
||||||
|
el.click()
|
||||||
|
_log.info(f"[{self.serial}] 滑动{i}次后找到并点击: {sel_val}")
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"[{self.serial}] 找到但点击失败: {e}")
|
||||||
|
else:
|
||||||
|
_log.info(f"[{self.serial}] 滑动{i}次后找到: {sel_val}")
|
||||||
|
self._track_selector_health(sel_val, True)
|
||||||
|
return True
|
||||||
|
d.swipe(cx, sy, cx, ey, 0.3)
|
||||||
|
time.sleep(0.5)
|
||||||
|
self._track_selector_health(sel_val, False)
|
||||||
|
_log.warning(f"[{self.serial}] 滑动{max_swipes}次未找到元素: {sel_val}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _exec_click_xy(self, d, params, depth=0):
|
||||||
|
"""点击坐标(屏幕百分比 0-100,50/50=屏幕中心)。无选择器时兜底。"""
|
||||||
|
x_pct = float(params.get("x", 50))
|
||||||
|
y_pct = float(params.get("y", 50))
|
||||||
|
info = d.info
|
||||||
|
w, h = info["displayWidth"], info["displayHeight"]
|
||||||
|
x = int(w * min(max(x_pct, 0), 100) / 100)
|
||||||
|
y = int(h * min(max(y_pct, 0), 100) / 100)
|
||||||
|
d.click(x, y)
|
||||||
|
_log.info(f"[{self.serial}] 点击坐标 ({x},{y})")
|
||||||
|
|
||||||
|
def _exec_long_click(self, d, params, depth=0):
|
||||||
|
"""长按元素(选择器 + 时长秒)。复制链接/呼出菜单用。"""
|
||||||
|
sel_type = params.get("selector_type", "xpath")
|
||||||
|
sel_val = params.get("selector_value", "")
|
||||||
|
duration = float(params.get("duration", 1.0))
|
||||||
|
timeout = float(params.get("wait_timeout", 2))
|
||||||
|
if not sel_val:
|
||||||
|
_log.warning("long_click 缺少选择器")
|
||||||
|
return None
|
||||||
|
found = False
|
||||||
|
try:
|
||||||
|
if sel_type == "xpath":
|
||||||
|
el = d.xpath(sel_val)
|
||||||
|
if el.wait(timeout=timeout):
|
||||||
|
el.long_click(duration=duration)
|
||||||
|
found = True
|
||||||
|
else:
|
||||||
|
el = d(**{sel_type: sel_val})
|
||||||
|
if el.exists(timeout=timeout):
|
||||||
|
el.long_click(duration=duration)
|
||||||
|
found = True
|
||||||
|
self._track_selector_health(sel_val, found)
|
||||||
|
_log.info(f"[{self.serial}] 长按 {'命中' if found else '未找到'}: {sel_val}")
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"[{self.serial}] 长按异常: {e}")
|
||||||
|
return found
|
||||||
|
|
||||||
|
def _exec_wait_el(self, d, params, depth=0):
|
||||||
|
"""等待元素出现(条件等待)。返回 True=出现, False=超时, None=缺选择器。"""
|
||||||
|
sel_type = params.get("selector_type", "xpath")
|
||||||
|
sel_val = params.get("selector_value", "")
|
||||||
|
timeout = float(params.get("timeout", 10))
|
||||||
|
if not sel_val:
|
||||||
|
_log.warning("wait_el 缺少选择器")
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if sel_type == "xpath":
|
||||||
|
found = d.xpath(sel_val).wait(timeout=timeout)
|
||||||
|
else:
|
||||||
|
found = d(**{sel_type: sel_val}).exists(timeout=timeout)
|
||||||
|
self._track_selector_health(sel_val, found)
|
||||||
|
_log.info(f"[{self.serial}] 等待元素 {'出现' if found else '超时未出现'}: {sel_val}")
|
||||||
|
return found
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"[{self.serial}] 等待元素异常: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
def _exec_swipe(self, d, params, depth=0):
|
def _exec_swipe(self, d, params, depth=0):
|
||||||
direction = params.get("direction", "up")
|
direction = params.get("direction", "up")
|
||||||
dur_min = float(params.get("duration_min", 0.25))
|
dur_min = float(params.get("duration_min", 0.25))
|
||||||
@@ -221,6 +415,7 @@ class GenericStepsWorker(BaseWorker):
|
|||||||
self._track_selector_health(sel_val, found)
|
self._track_selector_health(sel_val, found)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log.warning(f"[{self.serial}] click 异常: {e}")
|
_log.warning(f"[{self.serial}] click 异常: {e}")
|
||||||
|
return found # 命中结果,"测试此步骤"用
|
||||||
|
|
||||||
def _exec_wait(self, d, params, depth=0):
|
def _exec_wait(self, d, params, depth=0):
|
||||||
min_s = float(params.get("min", 1.0))
|
min_s = float(params.get("min", 1.0))
|
||||||
@@ -284,15 +479,18 @@ class GenericStepsWorker(BaseWorker):
|
|||||||
text = random.choice(candidates)
|
text = random.choice(candidates)
|
||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
|
clear_first = params.get("clear_first", True)
|
||||||
try:
|
try:
|
||||||
# 优先用 u2 的 send_keys(对当前焦点元素输入)
|
# 优先用 u2 的 send_keys(对当前焦点元素输入);clear=True 先清空再输入
|
||||||
d.send_keys(text)
|
d.send_keys(text, clear=clear_first)
|
||||||
_log.info(f"[{self.serial}] input_text: {text}")
|
_log.info(f"[{self.serial}] input_text: {text}" + ("(已先清空)" if clear_first else ""))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 兜底:找 EditText 设置文本
|
# 兜底:找 EditText 设置文本
|
||||||
try:
|
try:
|
||||||
edit = d(className="android.widget.EditText")
|
edit = d(className="android.widget.EditText")
|
||||||
if edit.exists:
|
if edit.exists:
|
||||||
|
if clear_first:
|
||||||
|
edit.clear_text()
|
||||||
edit.set_text(text)
|
edit.set_text(text)
|
||||||
_log.info(f"[{self.serial}] input_text(set_text): {text}")
|
_log.info(f"[{self.serial}] input_text(set_text): {text}")
|
||||||
else:
|
else:
|
||||||
@@ -321,3 +519,36 @@ class GenericStepsTask(BaseTask):
|
|||||||
def create_worker(self, stf, serial, params):
|
def create_worker(self, stf, serial, params):
|
||||||
merged = {**DEFAULT_PARAMS, **(params or {})}
|
merged = {**DEFAULT_PARAMS, **(params or {})}
|
||||||
return GenericStepsWorker(stf, serial, params=merged)
|
return GenericStepsWorker(stf, serial, params=merged)
|
||||||
|
|
||||||
|
|
||||||
|
def test_step(serial, step):
|
||||||
|
"""在设备上单步试执行(编辑器"测试此步骤"按钮用)。
|
||||||
|
|
||||||
|
直接 adb connect + u2 连接后执行单个步骤,验证选择器是否命中。
|
||||||
|
返回 (ok, msg, result):result 为执行器返回的命中结果(True/False/None)。
|
||||||
|
与运行中的任务互不干扰(只读连接,不占用/不释放 STF)。
|
||||||
|
"""
|
||||||
|
import uiautomator2 as u2
|
||||||
|
from concurrent.futures import ThreadPoolExecutor # 与 device_worker 一致(本机 stdlib threading 无此属性)
|
||||||
|
from core.adb_helper import adb_connect
|
||||||
|
from core.device_worker import _U2_CONNECT_TIMEOUT
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not adb_connect(serial):
|
||||||
|
return False, f"adb connect {serial} 失败", None
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"adb connect 异常: {e}", None
|
||||||
|
try:
|
||||||
|
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||||
|
fut = pool.submit(u2.connect, serial)
|
||||||
|
d = fut.result(timeout=_U2_CONNECT_TIMEOUT)
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"u2 连接失败: {e}", None
|
||||||
|
if d is None:
|
||||||
|
return False, "u2 连接超时", None
|
||||||
|
try:
|
||||||
|
w = GenericStepsWorker(None, serial, {})
|
||||||
|
result = w._exec_one(d, step)
|
||||||
|
return True, f"步骤已执行({step.get('label') or step.get('type')})", result
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"执行异常: {e}", None
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ body{background:var(--bg);font-family:-apple-system,"Segoe UI",Roboto,"PingFang
|
|||||||
.list-pager{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin:8px 0 20px}
|
.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-pager .lp-total{font-size:12px;color:var(--text-light);margin-right:auto}
|
||||||
.list-search{width:240px}
|
.list-search{width:240px}
|
||||||
|
.adb-console{background:#0f172a;color:#e2e8f0;font-family:monospace;font-size:12px;padding:12px;border-radius:8px;min-height:200px;max-height:420px;overflow:auto;white-space:pre-wrap;word-break:break-all}
|
||||||
|
|
||||||
/* 区块标题 */
|
/* 区块标题 */
|
||||||
.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}
|
||||||
@@ -267,13 +268,14 @@ select.form-control{cursor:pointer}
|
|||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button class="tab active" onclick="showTab('monitor')">监控</button>
|
<button class="tab active" onclick="showTab('monitor')">监控</button>
|
||||||
<button class="tab" onclick="showTab('tasks')">任务</button>
|
<button class="tab" onclick="showTab('tasks')">任务</button>
|
||||||
<button class="tab" onclick="showTab('apps')">应用</button>
|
<button class="tab" onclick="showTab('apps')" data-perm="apks">应用</button>
|
||||||
<button class="tab" onclick="showTab('groups')">分组</button>
|
<button class="tab" onclick="showTab('groups')">分组</button>
|
||||||
<button class="tab" onclick="showTab('logs')">日志</button>
|
<button class="tab" onclick="showTab('logs')" data-perm="logs">日志</button>
|
||||||
<button class="tab" onclick="showTab('users')">用户</button>
|
<button class="tab" onclick="showTab('users')" data-perm="admin">用户</button>
|
||||||
|
<button class="tab" onclick="showTab('maintenance')" data-perm="admin">维护</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<span>admin</span>
|
<span id="me-username">...</span>
|
||||||
<a href="/logout">退出</a>
|
<a href="/logout">退出</a>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -289,10 +291,11 @@ select.form-control{cursor:pointer}
|
|||||||
|
|
||||||
<div class="section-title">设备列表</div>
|
<div class="section-title">设备列表</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button class="btn btn-danger" onclick="apiAction('/api/stop_all')">停止全部</button>
|
<button class="btn btn-danger" onclick="apiAction('/api/stop_all')" data-perm="devices">停止全部</button>
|
||||||
<button class="btn btn-danger" id="btn-stop-selected" onclick="stopSelectedDevices()" title="停止所有勾选的运行中设备">停止选中</button>
|
<button class="btn btn-danger" id="btn-stop-selected" onclick="stopSelectedDevices()" title="停止所有勾选的运行中设备" data-perm="devices">停止选中</button>
|
||||||
<button class="btn" onclick="apiAction('/api/release')">强制释放占用</button>
|
<button class="btn btn-danger" onclick="clearAllErrors()" title="清除所有异常/失败设备的错误状态(运行中/等待重试的跳过)" data-perm="devices">清除全部异常</button>
|
||||||
<button class="btn btn-primary" id="btn-scan-fg" onclick="scanForeground()">扫描前台App</button>
|
<button class="btn" onclick="apiAction('/api/release')" data-perm="devices">强制释放占用</button>
|
||||||
|
<button class="btn btn-primary" id="btn-scan-fg" onclick="scanForeground()" data-perm="devices">扫描前台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/任务...">
|
<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>
|
||||||
@@ -334,13 +337,13 @@ select.form-control{cursor:pointer}
|
|||||||
<div class="page-title">任务计划管理</div>
|
<div class="page-title">任务计划管理</div>
|
||||||
<div class="page-sub">新建、编辑、删除任务计划,支持立即执行和定时调度</div>
|
<div class="page-sub">新建、编辑、删除任务计划,支持立即执行和定时调度</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button class="btn btn-primary" onclick="openTaskModal()">新建任务</button>
|
<button class="btn btn-primary" onclick="openTaskModal()" data-perm="tasks">新建任务</button>
|
||||||
<button class="btn" onclick="loadTasks()">刷新</button>
|
<button class="btn" onclick="loadTasks()">刷新</button>
|
||||||
<input class="form-control list-search" id="search-tasks" placeholder="搜索任务名/类型/ID...">
|
<input class="form-control list-search" id="search-tasks" placeholder="搜索任务名/类型/ID...">
|
||||||
</div>
|
</div>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th>ID</th><th>任务名</th><th>类型</th><th>启用</th><th>目标</th><th>调度</th><th>重试</th><th>操作</th>
|
<th>ID</th><th>任务名</th><th>类型</th><th>启用</th><th>目标</th><th>调度</th><th>下次运行</th><th>重试</th><th>操作</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody id="tb-tasks"></tbody>
|
<tbody id="tb-tasks"></tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -351,7 +354,7 @@ select.form-control{cursor:pointer}
|
|||||||
<div class="page-title" style="font-size:16px">自定义动作管理</div>
|
<div class="page-title" style="font-size:16px">自定义动作管理</div>
|
||||||
<div class="page-sub">把常用步骤打包成可复用的动作,拖入任务步骤编辑器使用;也可在任务编辑器里"☑选中步骤→打包"生成</div>
|
<div class="page-sub">把常用步骤打包成可复用的动作,拖入任务步骤编辑器使用;也可在任务编辑器里"☑选中步骤→打包"生成</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button class="btn btn-primary" onclick="openActionModal()">新建动作</button>
|
<button class="btn btn-primary" onclick="openActionModal()" data-perm="tasks">新建动作</button>
|
||||||
<button class="btn" onclick="loadCustomActions()">刷新</button>
|
<button class="btn" onclick="loadCustomActions()">刷新</button>
|
||||||
<input class="form-control list-search" id="search-actions" placeholder="搜索动作名...">
|
<input class="form-control list-search" id="search-actions" placeholder="搜索动作名...">
|
||||||
</div>
|
</div>
|
||||||
@@ -415,7 +418,7 @@ select.form-control{cursor:pointer}
|
|||||||
<div class="page-title">设备分组管理</div>
|
<div class="page-title">设备分组管理</div>
|
||||||
<div class="page-sub">管理设备分组,用于任务按分组执行</div>
|
<div class="page-sub">管理设备分组,用于任务按分组执行</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button class="btn btn-primary" onclick="openGroupModal()">新建分组</button>
|
<button class="btn btn-primary" onclick="openGroupModal()" data-perm="tasks">新建分组</button>
|
||||||
<button class="btn" onclick="loadGroups()">刷新</button>
|
<button class="btn" onclick="loadGroups()">刷新</button>
|
||||||
<input class="form-control list-search" id="search-groups" placeholder="搜索分组名/描述...">
|
<input class="form-control list-search" id="search-groups" placeholder="搜索分组名/描述...">
|
||||||
</div>
|
</div>
|
||||||
@@ -463,15 +466,73 @@ select.form-control{cursor:pointer}
|
|||||||
</div>
|
</div>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th>ID</th><th>用户名</th><th>管理员</th><th>操作</th>
|
<th>ID</th><th>用户名</th><th>管理员</th><th>权限</th><th>操作</th>
|
||||||
</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 class="list-pager" id="pager-users"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ========== Tab 7: 维护(仅管理员) ========== -->
|
||||||
|
<div id="tab-maintenance" class="tab-panel">
|
||||||
|
<div class="page-title">维护</div>
|
||||||
|
<div class="page-sub">管理员专用:STF 服务管理与 adb 远程终端</div>
|
||||||
|
|
||||||
|
<div class="section-title">STF 服务</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="btn btn-danger" onclick="restartStf()">一键重启 STF 容器</button>
|
||||||
|
<span id="stf-restart-status" class="text-muted" style="margin-left:10px"></span>
|
||||||
|
</div>
|
||||||
|
<div class="help" style="margin-top:8px">
|
||||||
|
通过 SSH 到部署机执行 <code>docker restart</code>(目标与容器名可用环境变量
|
||||||
|
<code>STF_SSH_TARGET</code> / <code>STF_DOCKER_CONTAINER</code> 配置)。
|
||||||
|
影响:STF 约 30 秒不可用,期间设备占用状态重置;<b>有任务在运行时会被拒绝执行</b>。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-title" style="margin-top:16px">adb 远程终端</div>
|
||||||
|
<div class="help">选中目标设备后,命令里的 <code>-s <设备></code> 自动附加,直接写
|
||||||
|
<code>shell ls /sdcard</code> 即可。离线设备也可选,用"重连设备"恢复。
|
||||||
|
<b>禁止 kill-server / disconnect</b>(会中断 STF provider 的设备监控)。</div>
|
||||||
|
<div style="display:flex;gap:8px;margin:8px 0;align-items:center">
|
||||||
|
<label style="font-size:12px;color:var(--text-light)">目标设备</label>
|
||||||
|
<select id="adb-device" class="form-control" style="flex:1;max-width:340px;font-family:monospace"></select>
|
||||||
|
<button class="btn btn-xs" onclick="loadAdbDevices()" title="刷新设备列表">刷新</button>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;margin:8px 0">
|
||||||
|
<input type="text" id="adb-cmd-input" class="form-control" style="flex:1;font-family:monospace"
|
||||||
|
placeholder="shell ls /sdcard(选中设备后 -s 自动附加)"
|
||||||
|
onkeydown="if(event.key==='Enter')runAdbCmd()">
|
||||||
|
<button class="btn btn-primary" onclick="runAdbCmd()">执行</button>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap">
|
||||||
|
<button class="btn btn-xs" onclick="fillAdbCmd('adb devices')">adb devices</button>
|
||||||
|
<button class="btn btn-xs" onclick="fillAdbCmd('shell dumpsys window | grep mCurrentFocus')">前台App</button>
|
||||||
|
<button class="btn btn-xs" onclick="fillAdbCmd('shell ls /sdcard')">列文件</button>
|
||||||
|
<button class="btn btn-xs" onclick="fillAdbCmd('shell screencap -p /sdcard/shot.png')">截屏</button>
|
||||||
|
<button class="btn btn-xs" onclick="fillAdbCmd('shell pm list packages -3')">已装应用</button>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap">
|
||||||
|
<button class="btn btn-xs btn-danger" onclick="uninstallStfAgent()" title="卸载 jp.co.cyberagent.stf,STF 会在设备重连时重新推送">卸载 STF agent(卡死时用)</button>
|
||||||
|
<button class="btn btn-xs" onclick="reconnectAdbDevice()" title="重新 adb connect 该设备">重连设备</button>
|
||||||
|
<button class="btn btn-xs" onclick="checkStfAgent()" title="查看 jp.co.cyberagent.stf 是否已安装">检查 agent</button>
|
||||||
|
</div>
|
||||||
|
<pre id="adb-output" class="adb-console">(执行命令后输出显示在这里)</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div><!-- /.content -->
|
</div><!-- /.content -->
|
||||||
|
|
||||||
|
<!-- ========== 测试此步骤弹窗 ========== -->
|
||||||
|
<div id="test-overlay" class="modal-overlay" onclick="if(event.target===this)closeTestModal()">
|
||||||
|
<div class="modal" id="test-modal-box">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="test-modal-title">测试步骤</h3>
|
||||||
|
<button class="close" onclick="closeTestModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="test-modal-body"></div>
|
||||||
|
<div class="modal-footer"><button class="btn" onclick="closeTestModal()">关闭</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ========== 模态框 ========== -->
|
<!-- ========== 模态框 ========== -->
|
||||||
<div id="modal-overlay" class="modal-overlay" onclick="if(event.target===this)closeModal()">
|
<div id="modal-overlay" class="modal-overlay" onclick="if(event.target===this)closeModal()">
|
||||||
<div class="modal" id="modal-box">
|
<div class="modal" id="modal-box">
|
||||||
|
|||||||
+365
-39
@@ -18,6 +18,8 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
import atexit
|
import atexit
|
||||||
import secrets
|
import secrets
|
||||||
|
import functools
|
||||||
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -28,12 +30,13 @@ from flask_login import (LoginManager, login_user, logout_user,
|
|||||||
|
|
||||||
from core.stf_client import STFClient
|
from core.stf_client import STFClient
|
||||||
from core.task_manager import TaskManager
|
from core.task_manager import TaskManager
|
||||||
from core.device_worker import get_all_worker_status
|
from core.device_worker import clear_worker_error, clear_all_worker_errors
|
||||||
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, list_installed_apps
|
from core.adb_helper import screenshot, list_installed_apps, adb_connect_light, _ADB_LOCK, _adb
|
||||||
from core import uiauto_helper
|
from core import uiauto_helper
|
||||||
|
from config import ADB_PATH, STF_SSH_TARGET, STF_DOCKER_CONTAINER
|
||||||
from tasks import list_task_types, get_task_class
|
from tasks import list_task_types, get_task_class
|
||||||
|
|
||||||
_log = get_logger("web")
|
_log = get_logger("web")
|
||||||
@@ -90,6 +93,77 @@ def api_csrf():
|
|||||||
return jsonify({"ok": True, "token": _csrf_token()})
|
return jsonify({"ok": True, "token": _csrf_token()})
|
||||||
|
|
||||||
|
|
||||||
|
# ================== 权限控制 ==================
|
||||||
|
# 业务权限位(is_admin 管理员拥有全部权限,不受 perms 限制):
|
||||||
|
# tasks — 任务管理:任务/自定义动作/分组的创建、修改、删除、启停、执行
|
||||||
|
# devices — 设备控制:停止设备、释放占用、清除异常、前台扫描、元素抓取
|
||||||
|
# apks — 应用管理:APK 上传、安装、删除
|
||||||
|
# logs — 日志查看
|
||||||
|
# 查看类接口(GET 状态/列表)所有登录用户可用;用户管理仅管理员可用(admin_required)。
|
||||||
|
PERM_TASKS = "tasks"
|
||||||
|
PERM_DEVICES = "devices"
|
||||||
|
PERM_APKS = "apks"
|
||||||
|
PERM_LOGS = "logs"
|
||||||
|
ALL_PERMS = (PERM_TASKS, PERM_DEVICES, PERM_APKS, PERM_LOGS)
|
||||||
|
_PERM_LABELS = {PERM_TASKS: "任务管理", PERM_DEVICES: "设备控制",
|
||||||
|
PERM_APKS: "应用管理", PERM_LOGS: "日志查看"}
|
||||||
|
|
||||||
|
|
||||||
|
def _has_perm(perm):
|
||||||
|
"""当前用户是否拥有指定权限。管理员恒为 True。"""
|
||||||
|
u = current_user
|
||||||
|
return bool(u and (u.is_admin or u.has_perm(perm)))
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_perms(raw):
|
||||||
|
"""校验权限位列表:只保留合法值、去重。返回合法列表。"""
|
||||||
|
valid = set(ALL_PERMS)
|
||||||
|
out = []
|
||||||
|
for p in raw or []:
|
||||||
|
if p in valid and p not in out:
|
||||||
|
out.append(p)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def perm_required(perm):
|
||||||
|
"""路由装饰器:要求登录且拥有指定业务权限,否则 403。"""
|
||||||
|
def deco(fn):
|
||||||
|
@functools.wraps(fn)
|
||||||
|
@login_required
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
if not _has_perm(perm):
|
||||||
|
return jsonify({"ok": False,
|
||||||
|
"error": f"无权限执行此操作(需要权限: {_PERM_LABELS.get(perm, perm)})"}), 403
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
return deco
|
||||||
|
|
||||||
|
|
||||||
|
def admin_required(fn):
|
||||||
|
"""路由装饰器:仅管理员可用(用户管理类接口),否则 403。"""
|
||||||
|
@functools.wraps(fn)
|
||||||
|
@login_required
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
if not current_user.is_admin:
|
||||||
|
return jsonify({"ok": False, "error": "仅管理员可执行此操作"}), 403
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/me")
|
||||||
|
@login_required
|
||||||
|
def api_me():
|
||||||
|
"""当前登录用户信息(含权限位),前端据此隐藏无权限的功能入口。"""
|
||||||
|
u = current_user
|
||||||
|
return jsonify({"ok": True, "user": {
|
||||||
|
"id": u.id,
|
||||||
|
"username": u.username,
|
||||||
|
"is_admin": bool(u.is_admin),
|
||||||
|
# 管理员返回全部权限位,前端统一用 perms 判断
|
||||||
|
"perms": list(ALL_PERMS) if u.is_admin else u.get_perms(),
|
||||||
|
}})
|
||||||
|
|
||||||
|
|
||||||
# ================== 页面路由 ==================
|
# ================== 页面路由 ==================
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
@login_required
|
@login_required
|
||||||
@@ -206,7 +280,7 @@ def api_status():
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/api/scan_foreground", methods=["POST"])
|
@app.route("/api/scan_foreground", methods=["POST"])
|
||||||
@login_required
|
@perm_required(PERM_DEVICES)
|
||||||
def api_scan_foreground():
|
def api_scan_foreground():
|
||||||
"""手动触发前台 App 扫描(不打扰设备)。"""
|
"""手动触发前台 App 扫描(不打扰设备)。"""
|
||||||
started = mgr._fg_scanner.scan_once()
|
started = mgr._fg_scanner.scan_once()
|
||||||
@@ -244,15 +318,26 @@ def api_devices():
|
|||||||
|
|
||||||
|
|
||||||
# ================== API:任务计划 CRUD ==================
|
# ================== API:任务计划 CRUD ==================
|
||||||
|
def _job_next_run(job):
|
||||||
|
"""任务下次执行时间,格式化为 "YYYY-MM-DD HH:MM"(Flask 序列化 aware datetime 会变 GMT 格式)。"""
|
||||||
|
nr = mgr.next_run_of(job)
|
||||||
|
return nr.strftime("%Y-%m-%d %H:%M") if nr else None
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/jobs")
|
@app.route("/api/jobs")
|
||||||
@login_required
|
@login_required
|
||||||
def api_jobs_list():
|
def api_jobs_list():
|
||||||
return jsonify({"ok": True, "jobs": [j.to_dict() for j in mgr.jobs.values()],
|
jobs = []
|
||||||
|
for j in mgr.jobs.values():
|
||||||
|
d = j.to_dict()
|
||||||
|
d["next_run"] = _job_next_run(j)
|
||||||
|
jobs.append(d)
|
||||||
|
return jsonify({"ok": True, "jobs": jobs,
|
||||||
"task_types": list_task_types()})
|
"task_types": list_task_types()})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/jobs", methods=["POST"])
|
@app.route("/api/jobs", methods=["POST"])
|
||||||
@login_required
|
@perm_required(PERM_TASKS)
|
||||||
def api_jobs_create():
|
def api_jobs_create():
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
name = (data.get("name") or "").strip()
|
name = (data.get("name") or "").strip()
|
||||||
@@ -268,11 +353,13 @@ def api_jobs_create():
|
|||||||
schedule=data.get("schedule", {"mode": "once"}),
|
schedule=data.get("schedule", {"mode": "once"}),
|
||||||
retry=data.get("retry", {"max_attempts": 1, "delay": 60}),
|
retry=data.get("retry", {"max_attempts": 1, "delay": 60}),
|
||||||
enabled=data.get("enabled", True))
|
enabled=data.get("enabled", True))
|
||||||
return jsonify({"ok": True, "msg": "任务已创建", "job": job.to_dict()})
|
d = job.to_dict()
|
||||||
|
d["next_run"] = _job_next_run(job)
|
||||||
|
return jsonify({"ok": True, "msg": "任务已创建", "job": d})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/jobs/<job_id>", methods=["PUT"])
|
@app.route("/api/jobs/<job_id>", methods=["PUT"])
|
||||||
@login_required
|
@perm_required(PERM_TASKS)
|
||||||
def api_jobs_update(job_id):
|
def api_jobs_update(job_id):
|
||||||
job = mgr.jobs.get(job_id)
|
job = mgr.jobs.get(job_id)
|
||||||
if not job:
|
if not job:
|
||||||
@@ -285,11 +372,13 @@ def api_jobs_update(job_id):
|
|||||||
if "task_type" in fields and not get_task_class(fields["task_type"]):
|
if "task_type" in fields and not get_task_class(fields["task_type"]):
|
||||||
return jsonify({"ok": False, "error": f"未知任务类型: {fields['task_type']}"}), 400
|
return jsonify({"ok": False, "error": f"未知任务类型: {fields['task_type']}"}), 400
|
||||||
mgr.update_job(job_id, **fields)
|
mgr.update_job(job_id, **fields)
|
||||||
return jsonify({"ok": True, "msg": "任务已更新", "job": job.to_dict()})
|
d = job.to_dict()
|
||||||
|
d["next_run"] = _job_next_run(job)
|
||||||
|
return jsonify({"ok": True, "msg": "任务已更新", "job": d})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/jobs/<job_id>", methods=["DELETE"])
|
@app.route("/api/jobs/<job_id>", methods=["DELETE"])
|
||||||
@login_required
|
@perm_required(PERM_TASKS)
|
||||||
def api_jobs_delete(job_id):
|
def api_jobs_delete(job_id):
|
||||||
if mgr.delete_job(job_id):
|
if mgr.delete_job(job_id):
|
||||||
return jsonify({"ok": True, "msg": "任务已删除"})
|
return jsonify({"ok": True, "msg": "任务已删除"})
|
||||||
@@ -297,13 +386,13 @@ def api_jobs_delete(job_id):
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/api/jobs/<job_id>/run", methods=["POST"])
|
@app.route("/api/jobs/<job_id>/run", methods=["POST"])
|
||||||
@login_required
|
@perm_required(PERM_TASKS)
|
||||||
def api_jobs_run(job_id):
|
def api_jobs_run(job_id):
|
||||||
return jsonify(mgr.run_job_now(job_id))
|
return jsonify(mgr.run_job_now(job_id))
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/jobs/<job_id>/toggle", methods=["POST"])
|
@app.route("/api/jobs/<job_id>/toggle", methods=["POST"])
|
||||||
@login_required
|
@perm_required(PERM_TASKS)
|
||||||
def api_jobs_toggle(job_id):
|
def api_jobs_toggle(job_id):
|
||||||
enabled = (request.json or {}).get("enabled", True)
|
enabled = (request.json or {}).get("enabled", True)
|
||||||
job = mgr.toggle_job(job_id, enabled)
|
job = mgr.toggle_job(job_id, enabled)
|
||||||
@@ -320,7 +409,7 @@ def api_groups_list():
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/api/groups", methods=["POST"])
|
@app.route("/api/groups", methods=["POST"])
|
||||||
@login_required
|
@perm_required(PERM_TASKS)
|
||||||
def api_groups_create():
|
def api_groups_create():
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
name = (data.get("name") or "").strip()
|
name = (data.get("name") or "").strip()
|
||||||
@@ -333,7 +422,7 @@ def api_groups_create():
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/api/groups/<name>", methods=["PUT"])
|
@app.route("/api/groups/<name>", methods=["PUT"])
|
||||||
@login_required
|
@perm_required(PERM_TASKS)
|
||||||
def api_groups_update(name):
|
def api_groups_update(name):
|
||||||
if name not in mgr.groups:
|
if name not in mgr.groups:
|
||||||
return jsonify({"ok": False, "error": "分组不存在"}), 404
|
return jsonify({"ok": False, "error": "分组不存在"}), 404
|
||||||
@@ -345,7 +434,7 @@ def api_groups_update(name):
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/api/groups/<name>", methods=["DELETE"])
|
@app.route("/api/groups/<name>", methods=["DELETE"])
|
||||||
@login_required
|
@perm_required(PERM_TASKS)
|
||||||
def api_groups_delete(name):
|
def api_groups_delete(name):
|
||||||
if mgr.delete_group(name):
|
if mgr.delete_group(name):
|
||||||
return jsonify({"ok": True, "msg": "分组已删除"})
|
return jsonify({"ok": True, "msg": "分组已删除"})
|
||||||
@@ -354,14 +443,15 @@ def api_groups_delete(name):
|
|||||||
|
|
||||||
# ================== API:用户管理 CRUD ==================
|
# ================== API:用户管理 CRUD ==================
|
||||||
@app.route("/api/users")
|
@app.route("/api/users")
|
||||||
@login_required
|
@admin_required
|
||||||
def api_users_list():
|
def api_users_list():
|
||||||
users = [{"id": u.id, "username": u.username, "is_admin": u.is_admin} for u in User.query.all()]
|
users = [{"id": u.id, "username": u.username,
|
||||||
|
"is_admin": u.is_admin, "perms": u.get_perms()} for u in User.query.all()]
|
||||||
return jsonify({"ok": True, "users": users})
|
return jsonify({"ok": True, "users": users})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/users", methods=["POST"])
|
@app.route("/api/users", methods=["POST"])
|
||||||
@login_required
|
@admin_required
|
||||||
def api_users_create():
|
def api_users_create():
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
username = (data.get("username") or "").strip()
|
username = (data.get("username") or "").strip()
|
||||||
@@ -370,16 +460,18 @@ def api_users_create():
|
|||||||
return jsonify({"ok": False, "error": "用户名和密码不能为空"}), 400
|
return jsonify({"ok": False, "error": "用户名和密码不能为空"}), 400
|
||||||
if User.query.filter_by(username=username).first():
|
if User.query.filter_by(username=username).first():
|
||||||
return jsonify({"ok": False, "error": "用户名已存在"}), 400
|
return jsonify({"ok": False, "error": "用户名已存在"}), 400
|
||||||
u = User(username=username, is_admin=data.get("is_admin", True))
|
# 默认普通用户(最小权限);管理员由 is_admin 决定,权限位照常保存(降级后生效)
|
||||||
|
u = User(username=username, is_admin=bool(data.get("is_admin", False)))
|
||||||
|
u.set_perms(_validate_perms(data.get("perms") or []))
|
||||||
u.set_password(password)
|
u.set_password(password)
|
||||||
db.session.add(u)
|
db.session.add(u)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
_log.info(f"创建用户 {username}")
|
_log.info(f"创建用户 {username} (admin={u.is_admin}, perms={u.get_perms()})")
|
||||||
return jsonify({"ok": True, "msg": "用户已创建"})
|
return jsonify({"ok": True, "msg": "用户已创建"})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/users/<int:uid>", methods=["PUT"])
|
@app.route("/api/users/<int:uid>", methods=["PUT"])
|
||||||
@login_required
|
@admin_required
|
||||||
def api_users_update(uid):
|
def api_users_update(uid):
|
||||||
u = User.query.get(uid)
|
u = User.query.get(uid)
|
||||||
if not u:
|
if not u:
|
||||||
@@ -388,14 +480,19 @@ def api_users_update(uid):
|
|||||||
if "password" in data and data["password"]:
|
if "password" in data and data["password"]:
|
||||||
u.set_password(data["password"])
|
u.set_password(data["password"])
|
||||||
if "is_admin" in data:
|
if "is_admin" in data:
|
||||||
u.is_admin = bool(data["is_admin"])
|
new_admin = bool(data["is_admin"])
|
||||||
|
if u.is_admin and not new_admin and User.query.filter_by(is_admin=True).count() <= 1:
|
||||||
|
return jsonify({"ok": False, "error": "不能取消最后一个管理员"}), 400
|
||||||
|
u.is_admin = new_admin
|
||||||
|
if "perms" in data:
|
||||||
|
u.set_perms(_validate_perms(data["perms"]))
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
_log.info(f"更新用户 {u.username}")
|
_log.info(f"更新用户 {u.username} (admin={u.is_admin}, perms={u.get_perms()})")
|
||||||
return jsonify({"ok": True, "msg": "用户已更新"})
|
return jsonify({"ok": True, "msg": "用户已更新"})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/users/<int:uid>", methods=["DELETE"])
|
@app.route("/api/users/<int:uid>", methods=["DELETE"])
|
||||||
@login_required
|
@admin_required
|
||||||
def api_users_delete(uid):
|
def api_users_delete(uid):
|
||||||
u = User.query.get(uid)
|
u = User.query.get(uid)
|
||||||
if not u:
|
if not u:
|
||||||
@@ -404,6 +501,8 @@ def api_users_delete(uid):
|
|||||||
return jsonify({"ok": False, "error": "不能删除默认管理员"}), 400
|
return jsonify({"ok": False, "error": "不能删除默认管理员"}), 400
|
||||||
if u.id == current_user.id:
|
if u.id == current_user.id:
|
||||||
return jsonify({"ok": False, "error": "不能删除当前登录用户"}), 400
|
return jsonify({"ok": False, "error": "不能删除当前登录用户"}), 400
|
||||||
|
if u.is_admin and User.query.filter_by(is_admin=True).count() <= 1:
|
||||||
|
return jsonify({"ok": False, "error": "不能删除最后一个管理员"}), 400
|
||||||
db.session.delete(u)
|
db.session.delete(u)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
_log.info(f"删除用户 {u.username}")
|
_log.info(f"删除用户 {u.username}")
|
||||||
@@ -412,7 +511,7 @@ def api_users_delete(uid):
|
|||||||
|
|
||||||
# ================== API:日志查看 ==================
|
# ================== API:日志查看 ==================
|
||||||
@app.route("/api/logs")
|
@app.route("/api/logs")
|
||||||
@login_required
|
@perm_required(PERM_LOGS)
|
||||||
def api_logs():
|
def api_logs():
|
||||||
files = list(_MODULE_FILES.values())
|
files = list(_MODULE_FILES.values())
|
||||||
current = request.args.get("file", "core.log")
|
current = request.args.get("file", "core.log")
|
||||||
@@ -430,7 +529,7 @@ def api_logs():
|
|||||||
|
|
||||||
# ================== API:运行控制 ==================
|
# ================== API:运行控制 ==================
|
||||||
@app.route("/api/stop_device", methods=["POST"])
|
@app.route("/api/stop_device", methods=["POST"])
|
||||||
@login_required
|
@perm_required(PERM_DEVICES)
|
||||||
def api_stop_device():
|
def api_stop_device():
|
||||||
serial = (request.json or {}).get("serial", "")
|
serial = (request.json or {}).get("serial", "")
|
||||||
if mgr.stop_device(serial):
|
if mgr.stop_device(serial):
|
||||||
@@ -439,19 +538,45 @@ def api_stop_device():
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/api/stop_all", methods=["POST"])
|
@app.route("/api/stop_all", methods=["POST"])
|
||||||
@login_required
|
@perm_required(PERM_DEVICES)
|
||||||
def api_stop_all():
|
def api_stop_all():
|
||||||
stopped = mgr.stop_all()
|
stopped = mgr.stop_all()
|
||||||
return jsonify({"ok": True, "stopped": stopped})
|
return jsonify({"ok": True, "stopped": stopped})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/release", methods=["POST"])
|
@app.route("/api/release", methods=["POST"])
|
||||||
@login_required
|
@perm_required(PERM_DEVICES)
|
||||||
def api_release():
|
def api_release():
|
||||||
released, failed = stf.release_all_mine()
|
released, failed = stf.release_all_mine()
|
||||||
return jsonify({"ok": True, "released": released, "failed": failed})
|
return jsonify({"ok": True, "released": released, "failed": failed})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/device/clear_error", methods=["POST"])
|
||||||
|
@perm_required(PERM_DEVICES)
|
||||||
|
def api_device_clear_error():
|
||||||
|
"""清除单台设备的异常状态(error/failed → idle),供设备列表"清除异常"按钮使用。"""
|
||||||
|
serial = (request.json or {}).get("serial", "")
|
||||||
|
if not serial:
|
||||||
|
return jsonify({"ok": False, "error": "缺少 serial"}), 400
|
||||||
|
if serial in mgr.get_running():
|
||||||
|
return jsonify({"ok": False, "error": "设备正在运行或等待重试,无法清除"}), 400
|
||||||
|
if not clear_worker_error(serial):
|
||||||
|
return jsonify({"ok": False, "error": "设备正在运行任务,无法清除"}), 400
|
||||||
|
_log.info(f"清除设备 {serial} 的异常状态")
|
||||||
|
return jsonify({"ok": True, "msg": f"已清除 {serial} 的异常状态"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/device/clear_all_errors", methods=["POST"])
|
||||||
|
@perm_required(PERM_DEVICES)
|
||||||
|
def api_device_clear_all_errors():
|
||||||
|
"""一键清除所有异常/失败设备(跳过正在运行/等待重试的)。"""
|
||||||
|
running = set(mgr.get_running())
|
||||||
|
cleared = clear_all_worker_errors(exclude=running)
|
||||||
|
_log.info(f"一键清除异常:共清除 {cleared} 台")
|
||||||
|
return jsonify({"ok": True, "cleared": cleared,
|
||||||
|
"msg": f"已清除 {cleared} 台设备的异常状态"})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/device/screenshot")
|
@app.route("/api/device/screenshot")
|
||||||
@login_required
|
@login_required
|
||||||
def api_device_screenshot():
|
def api_device_screenshot():
|
||||||
@@ -491,7 +616,7 @@ def api_custom_actions_list():
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/api/custom_actions", methods=["POST"])
|
@app.route("/api/custom_actions", methods=["POST"])
|
||||||
@login_required
|
@perm_required(PERM_TASKS)
|
||||||
def api_custom_actions_create():
|
def api_custom_actions_create():
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
name = (data.get("name") or "").strip()
|
name = (data.get("name") or "").strip()
|
||||||
@@ -510,7 +635,7 @@ def api_custom_actions_create():
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/api/custom_actions/<action_id>", methods=["PUT"])
|
@app.route("/api/custom_actions/<action_id>", methods=["PUT"])
|
||||||
@login_required
|
@perm_required(PERM_TASKS)
|
||||||
def api_custom_actions_update(action_id):
|
def api_custom_actions_update(action_id):
|
||||||
row = CustomAction.query.get(action_id)
|
row = CustomAction.query.get(action_id)
|
||||||
if not row:
|
if not row:
|
||||||
@@ -532,7 +657,7 @@ def api_custom_actions_update(action_id):
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/api/custom_actions/<action_id>", methods=["DELETE"])
|
@app.route("/api/custom_actions/<action_id>", methods=["DELETE"])
|
||||||
@login_required
|
@perm_required(PERM_TASKS)
|
||||||
def api_custom_actions_delete(action_id):
|
def api_custom_actions_delete(action_id):
|
||||||
row = CustomAction.query.get(action_id)
|
row = CustomAction.query.get(action_id)
|
||||||
if not row:
|
if not row:
|
||||||
@@ -543,7 +668,7 @@ def api_custom_actions_delete(action_id):
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/api/uiauto/elements")
|
@app.route("/api/uiauto/elements")
|
||||||
@login_required
|
@perm_required(PERM_DEVICES)
|
||||||
def api_uiauto_elements():
|
def api_uiauto_elements():
|
||||||
"""获取设备当前 UI 元素树(供步骤编辑器"抓取元素"用)。
|
"""获取设备当前 UI 元素树(供步骤编辑器"抓取元素"用)。
|
||||||
|
|
||||||
@@ -569,18 +694,73 @@ def api_uiauto_status():
|
|||||||
return jsonify({"ok": True, "running": uiauto_helper.is_running()})
|
return jsonify({"ok": True, "running": uiauto_helper.is_running()})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/steps/test", methods=["POST"])
|
||||||
|
@perm_required(PERM_DEVICES)
|
||||||
|
def api_steps_test():
|
||||||
|
"""测试单个步骤:在指定设备上试执行,验证选择器是否命中(步骤编辑器"测试此步骤")。
|
||||||
|
|
||||||
|
请求: {"serial": "100.100.10.11:5555", "step": {"type": "click", "params": {...}}}
|
||||||
|
只读连接(adb connect + u2),不占用/释放 STF,与运行中任务互不干扰。
|
||||||
|
返回 result: "命中" / "未找到" / "已执行"。
|
||||||
|
"""
|
||||||
|
data = request.json or {}
|
||||||
|
serial = (data.get("serial") or "").strip()
|
||||||
|
step = data.get("step") or {}
|
||||||
|
if not serial:
|
||||||
|
return jsonify({"ok": False, "error": "缺少 serial"}), 400
|
||||||
|
if not step.get("type"):
|
||||||
|
return jsonify({"ok": False, "error": "缺少步骤类型"}), 400
|
||||||
|
from tasks.generic.task import test_step
|
||||||
|
ok, msg, result = test_step(serial, step)
|
||||||
|
res_txt = {True: "命中", False: "未找到"}.get(result, "已执行")
|
||||||
|
_log.info(f"测试步骤 {step.get('type')} @ {serial}: {res_txt} ({msg})")
|
||||||
|
return jsonify({"ok": ok, "msg": msg, "result": res_txt})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/uiauto/devices")
|
@app.route("/api/uiauto/devices")
|
||||||
@login_required
|
@perm_required(PERM_DEVICES)
|
||||||
def api_uiauto_devices():
|
def api_uiauto_devices():
|
||||||
"""获取 uiauto2 已连接的设备列表(供抓取元素时选择设备)。"""
|
"""获取可选设备列表(抓取元素用)。
|
||||||
|
|
||||||
|
uiautodev 只认识本地 adb 已连接的设备;平台设备池(STF)里在线但未
|
||||||
|
本地连接的设备(如 100.100.10.10)在这里补全,并先做一次轻量 adb
|
||||||
|
connect(单次尝试,绝不 kill-server),让它们可被 uiautodev 抓取。
|
||||||
|
"""
|
||||||
ok, data = uiauto_helper.list_devices()
|
ok, data = uiauto_helper.list_devices()
|
||||||
if ok:
|
if not ok:
|
||||||
return jsonify({"ok": True, "devices": data})
|
|
||||||
return jsonify({"ok": False, "error": data}), 503
|
return jsonify({"ok": False, "error": data}), 503
|
||||||
|
seen = {d.get("serial") for d in data}
|
||||||
|
try:
|
||||||
|
pool = stf.list_all_devices()
|
||||||
|
except Exception:
|
||||||
|
pool = []
|
||||||
|
added = 0
|
||||||
|
for d in pool:
|
||||||
|
serial = d.get("serial", "")
|
||||||
|
if not serial or serial in seen:
|
||||||
|
continue
|
||||||
|
if not (d.get("present") and d.get("ready")):
|
||||||
|
continue
|
||||||
|
# 轻量连接(单次尝试,不重试不 kill-server);连不上也照样列出,
|
||||||
|
# 前端抓取时会报明确错误,不影响其他设备的连接
|
||||||
|
adb_connect_light(serial)
|
||||||
|
data.append({
|
||||||
|
"serial": serial,
|
||||||
|
"model": d.get("model") or d.get("product") or "",
|
||||||
|
"product": d.get("product") or "",
|
||||||
|
"name": serial,
|
||||||
|
"status": "device",
|
||||||
|
"enabled": True,
|
||||||
|
})
|
||||||
|
seen.add(serial)
|
||||||
|
added += 1
|
||||||
|
if added:
|
||||||
|
_log.info(f"抓取元素设备列表补全 {added} 台 STF 在线设备: {list(seen)}")
|
||||||
|
return jsonify({"ok": True, "devices": data})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/uiauto/screenshot")
|
@app.route("/api/uiauto/screenshot")
|
||||||
@login_required
|
@perm_required(PERM_DEVICES)
|
||||||
def api_uiauto_screenshot():
|
def api_uiauto_screenshot():
|
||||||
"""通过 uiauto2 获取设备截图(供抓取元素时显示设备画面)。
|
"""通过 uiauto2 获取设备截图(供抓取元素时显示设备画面)。
|
||||||
|
|
||||||
@@ -606,7 +786,7 @@ def api_apks_list():
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/api/apks/upload", methods=["POST"])
|
@app.route("/api/apks/upload", methods=["POST"])
|
||||||
@login_required
|
@perm_required(PERM_APKS)
|
||||||
def api_apks_upload():
|
def api_apks_upload():
|
||||||
"""上传 APK 文件(multipart/form-data,字段名 file)。"""
|
"""上传 APK 文件(multipart/form-data,字段名 file)。"""
|
||||||
file = request.files.get("file")
|
file = request.files.get("file")
|
||||||
@@ -620,7 +800,7 @@ def api_apks_upload():
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/api/apks/<apk_id>", methods=["DELETE"])
|
@app.route("/api/apks/<apk_id>", methods=["DELETE"])
|
||||||
@login_required
|
@perm_required(PERM_APKS)
|
||||||
def api_apks_delete(apk_id):
|
def api_apks_delete(apk_id):
|
||||||
"""删除 APK 文件和记录。"""
|
"""删除 APK 文件和记录。"""
|
||||||
ok, msg = apk_mgr.delete(apk_id)
|
ok, msg = apk_mgr.delete(apk_id)
|
||||||
@@ -629,8 +809,36 @@ def api_apks_delete(apk_id):
|
|||||||
return jsonify({"ok": False, "error": msg}), 400
|
return jsonify({"ok": False, "error": msg}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/apks/install/devices")
|
||||||
|
@perm_required(PERM_APKS)
|
||||||
|
def api_apks_install_devices():
|
||||||
|
"""可安装设备列表(应用管理):STF 在线设备池 + 本机 adb 设备(含 USB 有线连接)。
|
||||||
|
|
||||||
|
source: stf=STF 池 / usb=本机有线 adb / adb=本机网络 adb。
|
||||||
|
"""
|
||||||
|
devices, seen = [], set()
|
||||||
|
try:
|
||||||
|
for d in stf.list_all_devices():
|
||||||
|
serial = d.get("serial", "")
|
||||||
|
if serial and d.get("present") and d.get("ready"):
|
||||||
|
devices.append({"serial": serial,
|
||||||
|
"model": d.get("model") or d.get("manufacturer") or "",
|
||||||
|
"source": "stf"})
|
||||||
|
seen.add(serial)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
out = _adb("devices")
|
||||||
|
for line in (out or "").splitlines()[1:]:
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 2 and parts[0] and parts[0] not in seen:
|
||||||
|
devices.append({"serial": parts[0], "model": "",
|
||||||
|
"source": "usb" if ":" not in parts[0] else "adb"})
|
||||||
|
seen.add(parts[0])
|
||||||
|
return jsonify({"ok": True, "devices": devices})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/apks/install", methods=["POST"])
|
@app.route("/api/apks/install", methods=["POST"])
|
||||||
@login_required
|
@perm_required(PERM_APKS)
|
||||||
def api_apks_install():
|
def api_apks_install():
|
||||||
"""批量安装 APK 到指定设备。参数: {apk_id, serials:[]}"""
|
"""批量安装 APK 到指定设备。参数: {apk_id, serials:[]}"""
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
@@ -650,6 +858,124 @@ def api_apks_install_status():
|
|||||||
return jsonify({"ok": True, "status": status})
|
return jsonify({"ok": True, "status": status})
|
||||||
|
|
||||||
|
|
||||||
|
# ================== API:维护(仅管理员) ==================
|
||||||
|
# 维护功能:STF 容器一键重启 + adb 远程终端。两个接口都仅管理员可用。
|
||||||
|
# adb 终端安全红线(技术约束):绝不 kill-server / 绝不 disconnect IP:5555——
|
||||||
|
# STF provider 共享该 adb transport,断开会让 STF 误判全部设备离线并触发重连。
|
||||||
|
_ADB_BLOCKED_PATTERNS = ("kill-server", "disconnect")
|
||||||
|
|
||||||
|
|
||||||
|
def _blocked_adb_cmd(cmd):
|
||||||
|
"""命中红线的 adb 命令(kill-server / disconnect)直接拒绝。"""
|
||||||
|
low = cmd.lower()
|
||||||
|
return any(p in low for p in _ADB_BLOCKED_PATTERNS)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/adb/devices")
|
||||||
|
@admin_required
|
||||||
|
def api_adb_devices():
|
||||||
|
"""维护终端设备列表(仅管理员):本地 adb 已连接 + STF 在线设备池。
|
||||||
|
|
||||||
|
供终端设备选择器使用——选中后自动附加 `-s <serial>`,
|
||||||
|
离线/未连接的设备也可选,配合"重连设备"按钮恢复。
|
||||||
|
"""
|
||||||
|
states = {}
|
||||||
|
out = _adb("devices")
|
||||||
|
for line in (out or "").splitlines()[1:]:
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 2 and parts[0] and not parts[0].startswith("*"):
|
||||||
|
states[parts[0]] = parts[1]
|
||||||
|
try:
|
||||||
|
for d in stf.list_all_devices():
|
||||||
|
serial = d.get("serial", "")
|
||||||
|
if serial and d.get("present") and d.get("ready") and serial not in states:
|
||||||
|
states[serial] = "stf"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
devices = [{"serial": s, "state": st} for s, st in sorted(states.items())]
|
||||||
|
return jsonify({"ok": True, "devices": devices})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/adb/cmd", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def api_adb_cmd():
|
||||||
|
"""维护终端:执行 adb 命令(仅管理员)。
|
||||||
|
|
||||||
|
请求: {"cmd": "adb -s 100.100.10.11:5555 shell ls /sdcard"}
|
||||||
|
用平台 adb 二进制执行(config.ADB_PATH),带 20s 超时。
|
||||||
|
"""
|
||||||
|
data = request.json or {}
|
||||||
|
cmd = (data.get("cmd") or "").strip()
|
||||||
|
if not cmd:
|
||||||
|
return jsonify({"ok": False, "error": "命令不能为空"}), 400
|
||||||
|
if _blocked_adb_cmd(cmd):
|
||||||
|
return jsonify({"ok": False,
|
||||||
|
"error": "禁止执行 kill-server / disconnect(会中断 STF 设备监控,影响所有任务)"}), 400
|
||||||
|
tokens = shlex.split(cmd)
|
||||||
|
if tokens and tokens[0] == "adb":
|
||||||
|
tokens = tokens[1:]
|
||||||
|
if not tokens:
|
||||||
|
return jsonify({"ok": False, "error": "命令不能为空"}), 400
|
||||||
|
try:
|
||||||
|
# 与 worker 的 adb 调用共用锁,避免并发操作同一 adb server
|
||||||
|
with _ADB_LOCK:
|
||||||
|
r = subprocess.run([ADB_PATH, *tokens], capture_output=True, timeout=20)
|
||||||
|
out = (r.stdout or b"").decode("utf-8", errors="replace")
|
||||||
|
err = (r.stderr or b"").decode("utf-8", errors="replace")
|
||||||
|
_log.info(f"维护终端执行 adb {' '.join(tokens)} -> exit {r.returncode}")
|
||||||
|
return jsonify({"ok": True, "stdout": out, "stderr": err, "code": r.returncode})
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return jsonify({"ok": False, "error": "命令执行超时(20s)"}), 408
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"维护终端 adb 执行异常: {e}")
|
||||||
|
return jsonify({"ok": False, "error": f"执行失败: {e}"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/stf/restart", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
def api_stf_restart():
|
||||||
|
"""一键重启 STF Docker 容器(仅管理员)。
|
||||||
|
|
||||||
|
通过 SSH(config.STF_SSH_TARGET,默认 [email protected])执行
|
||||||
|
`docker restart <容器>`(config.STF_DOCKER_CONTAINER,默认 stf),
|
||||||
|
随后轮询 STF API 确认恢复。
|
||||||
|
影响:STF 约 30s 不可用,期间占用状态重置、任务中断——有任务在跑时拒绝执行。
|
||||||
|
"""
|
||||||
|
running = mgr.get_running()
|
||||||
|
if running:
|
||||||
|
return jsonify({"ok": False,
|
||||||
|
"error": f"有 {len(running)} 台设备正在运行任务,请先停止再维护"}), 409
|
||||||
|
ssh_cmd = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
|
||||||
|
"-o", "StrictHostKeyChecking=no", STF_SSH_TARGET,
|
||||||
|
f"docker restart {STF_DOCKER_CONTAINER} && "
|
||||||
|
f"docker ps --filter name={STF_DOCKER_CONTAINER} --format '{{{{.Names}}}}: {{{{.Status}}}}'"]
|
||||||
|
try:
|
||||||
|
r = subprocess.run(ssh_cmd, capture_output=True, timeout=60)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return jsonify({"ok": False, "error": "SSH 执行超时(60s)"}), 408
|
||||||
|
except FileNotFoundError:
|
||||||
|
return jsonify({"ok": False, "error": "本机未安装 ssh,无法远程操作"}), 500
|
||||||
|
out = (r.stdout or b"").decode("utf-8", errors="replace")
|
||||||
|
err = (r.stderr or b"").decode("utf-8", errors="replace")
|
||||||
|
if r.returncode != 0:
|
||||||
|
_log.error(f"STF 容器重启失败: {err or out}")
|
||||||
|
return jsonify({"ok": False,
|
||||||
|
"error": f"SSH 执行失败:{err or out}(请确认本机可免密 SSH 到 {STF_SSH_TARGET},且容器名 {STF_DOCKER_CONTAINER} 正确)"}), 502
|
||||||
|
# 轮询 STF API 确认恢复(最长 30s)
|
||||||
|
alive = False
|
||||||
|
for _ in range(30):
|
||||||
|
try:
|
||||||
|
stf.list_all_devices()
|
||||||
|
alive = True
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
time.sleep(1)
|
||||||
|
_log.info(f"STF 容器 {STF_DOCKER_CONTAINER} 已重启,API 恢复={alive}")
|
||||||
|
return jsonify({"ok": True,
|
||||||
|
"msg": "STF 容器已重启" + ("" if alive else "(STF API 尚未恢复,请稍后刷新)"),
|
||||||
|
"detail": out.strip(), "api_alive": alive})
|
||||||
|
|
||||||
|
|
||||||
# ================== uiautodev 自动启动 ==================
|
# ================== uiautodev 自动启动 ==================
|
||||||
_uiauto_proc = None
|
_uiauto_proc = None
|
||||||
# PID 文件:web_server 异常退出(kill -9/崩溃)时 uiautodev 成孤儿残留,
|
# PID 文件:web_server 异常退出(kill -9/崩溃)时 uiautodev 成孤儿残留,
|
||||||
|
|||||||
Reference in New Issue
Block a user