用户场景(他原话):一台手机登录 5 个抖音号、一共 5 台手机,每个任务只让其中一个
目标号评论;每天跑一次但不知道什么时候跑完,于是"一直重复跑" → 结果
"一个手机还没评论到,一个手机都评论两次了"。
**根因不是"单设备重复",是跨设备没有共享的判断 + 进度不可见。** 所以做两件事:
① 幂等;② 把"谁做过了、还差谁"摆到台面上(不然只能靠重跑确认,而重跑又在制造重复)。
- `core/models.py`:新表 `done_mark`(迁移账本补 v7)。**判据只有 `scope_key` 的
唯一索引**——多台设备会同时判断"没做过","先查后插"有竞态(两台都插),
唯一索引 + `INSERT ... ON DUPLICATE KEY`/`INSERT OR IGNORE` 的**受影响行数**才原子。
- `core/dedup.py`(新):`build_key`(`任务|身份|时间桶`)/ `check` / `mark` /
`list_marks`(带"今天做了几台/几个号"统计)/ `delete_mark` / `clear_job` / `purge_old`。
自建 app context(照 device_pool 的 `_ctx()`),任务线程/Web/清理都不用关心。
- 任务侧两个部件(**检查在前、记账在后**):
· `if_el` 新增条件类型 `selector_type="dedup"`:命中=这个身份做过了 → 走 then 分支。
身份元素在 `ident_type`/`ident_value`(留空 = 用设备 serial,一号一机场景)。
· 新步骤 `mark_done`「记为已做」(22 种步骤):放动作**成功之后**。
拆两步的用意:动作失败就不记账,下次重跑还会重试该设备 —— 失败不丢。
- 有效期(`dedup_reset` = day/all/hours)放**任务级**:检查与记账两处各填一份的话,
填不一致就算出两个 key、去重会**静默失效**,所以强制只配一处(编辑器顶部下拉)。
- 三条防误伤规则(都有测试兜着):
· 身份读不到 / 身份值过长 → **不去重、当没做过照常执行**。绝不能把"读不到"
当成空身份——那会让所有设备共用一个 key、第一台记账后其余全被误判成"做过"。
· `kind='all'`(只做一次)的记录**永不清理**(清了等于语义失效);清理只删 day/hours。
· 去重的两个易错点在保存时直接告警:身份元素两边不一致、有检查没记账/有记账没检查。
- 「任务 → 去重记录」新子分栏(`static/admin/dedup.js`):统计行 + 明细表 +
删单条(那个号重跑)/ 清空任务(整批重跑)。接口 3 个(GET/delete/clear,PERM_TASKS)。
- 每日 04:23 清理(挂现有 APScheduler),`TABLE_LABELS` 补中文名(备份覆盖自动派生)。
- AI 建任务草稿校验同步:`dedup` 走自己的规则(要 ident_value、xpath 前缀校验),
没填身份元素只警告不拦(用设备当身份是合法用法);普通条件空选择器仍然拦。
- 文档:TASK_DEV §4.6(去重专章 + App 内检测的兜底配方与它的三个局限)、
DATA_MODEL §2.9、API 三个接口、ARCHITECTURE(分层/装配/子分栏/JS 分工/清理)、
DEPLOY §5.2(15 张表)、步骤数 21→22 全库同步。
自测:单元 + 集成 33 项(**含 8 线程抢同一个身份、恰好一个成功**的原子性断言,
以及"all 记录不被清理""身份读不到不去重""清了能重跑")、
**真机端到端**(cs1 上"检查→动作→记账"跑两遍:第二遍被拦、换 serial 的"另一台设备"
同样被拦、删记录后能重跑)、草稿校验 5 项、GET 冒烟 56 路由 0 个 500。
(注:本分支基于 feat/if-el-multi-value,因为它俩都要改 task.py 的 STEP_TYPES 与
editor.js 的 STEP_LIB 同一区域,分开从 dev 拉必然冲突——这份是超集,合一次两份都进。)
580 lines
23 KiB
Python
580 lines
23 KiB
Python
"""任务域 API:任务计划/分组/自定义动作/元素抓取/步骤测试。"""
|
||
import re
|
||
import time
|
||
import uuid
|
||
import threading
|
||
from datetime import datetime
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError as FuturesTimeout
|
||
|
||
from flask import (Blueprint, jsonify, request, Response)
|
||
from flask_login import login_required, current_user
|
||
|
||
from core import device_pool, uiauto_helper, step_defaults, dedup
|
||
from core.adb_helper import adb_connect_light
|
||
from core.models import db, DeviceGroup, TaskJob, CustomAction
|
||
from core.logger import get_logger
|
||
|
||
_log = get_logger("web")
|
||
from web import context
|
||
from web.auth import perm_required, PERM_DEVICES, PERM_TASKS
|
||
from tasks import list_task_types, get_task_class
|
||
from web.common import _merged_device_list
|
||
|
||
bp = Blueprint("tasks", __name__)
|
||
|
||
@bp.route("/api/task_types")
|
||
@login_required
|
||
def api_task_types():
|
||
return jsonify({"ok": True, "task_types": list_task_types()})
|
||
|
||
@bp.route("/api/actions")
|
||
@login_required
|
||
def api_actions():
|
||
"""返回指定任务类型支持的专属操作。"""
|
||
task_type = request.args.get("task_type", "")
|
||
task_cls = get_task_class(task_type)
|
||
if not task_cls:
|
||
return jsonify({"ok": False, "error": "未知任务类型", "actions": []})
|
||
actions = task_cls.list_action_types()
|
||
return jsonify({"ok": True, "actions": actions})
|
||
|
||
@bp.route("/api/devices")
|
||
@login_required
|
||
def api_devices():
|
||
"""在线设备列表(供分组表单/任务目标/AI 的「选择设备」下拉用)。
|
||
|
||
- `devices`:serial 字符串列表(兼容既有调用方)
|
||
- `items`:`[{serial, name, model}]` —— 界面显示**设备名称**用
|
||
(名称是设备的身份,见 doc/API.md §6)
|
||
"""
|
||
try:
|
||
serials = context.mgr.list_all_serials()
|
||
info = {}
|
||
try:
|
||
for d in device_pool.list_devices():
|
||
info[d["serial"]] = d
|
||
except Exception:
|
||
pass
|
||
items = [{"serial": s,
|
||
"name": (info.get(s) or {}).get("name") or "",
|
||
"model": (info.get(s) or {}).get("model") or ""} for s in serials]
|
||
return jsonify({"ok": True, "devices": serials, "items": items})
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": str(e)}), 500
|
||
|
||
|
||
# ================== API:任务计划 CRUD ==================
|
||
def _job_next_run(job):
|
||
"""任务下次执行时间,格式化为 "YYYY-MM-DD HH:MM"(Flask 序列化 aware datetime 会变 GMT 格式)。"""
|
||
nr = context.mgr.next_run_of(job)
|
||
return nr.strftime("%Y-%m-%d %H:%M") if nr else None
|
||
|
||
def _job_coverage(job, configured):
|
||
"""任务覆盖的设备(监控页「任务运行概况」展示用)。
|
||
|
||
与 TaskJob.resolve_serials 的差别:这里按 **target 定义**给出「这台任务会覆盖
|
||
哪些设备」,不因设备当前是否空闲而变(在线/运行中由前端拿实时状态标注),
|
||
也不做离线过滤、不写调度日志——它是每 5s 轮询的展示接口。
|
||
"""
|
||
tgt = job.target or {}
|
||
mode = tgt.get("mode", "all")
|
||
try:
|
||
if mode == "serial":
|
||
serials = [tgt.get("serial") or ""]
|
||
elif mode == "group":
|
||
g = context.mgr.groups.get(tgt.get("group_name"))
|
||
serials = [s for s in (list(g.serials) if g else []) if s in configured]
|
||
else:
|
||
serials = sorted(configured)
|
||
serials = [s for s in serials if s]
|
||
return {"mode": mode, "serials": serials, "total": len(serials)}
|
||
except Exception as e:
|
||
_log.warning(f"任务「{job.name}」覆盖设备解析失败: {e}")
|
||
return {"mode": mode, "serials": [], "total": 0, "error": str(e)[:80]}
|
||
|
||
|
||
def _configured_set():
|
||
"""设备池清单(coverage 计算用,取一次给所有任务复用)。"""
|
||
try:
|
||
return set(device_pool.list_configured())
|
||
except Exception:
|
||
return set()
|
||
|
||
|
||
@bp.route("/api/jobs")
|
||
@login_required
|
||
def api_jobs_list():
|
||
configured = _configured_set()
|
||
jobs = []
|
||
for j in context.mgr.jobs.values():
|
||
d = j.to_dict()
|
||
d["next_run"] = _job_next_run(j)
|
||
d["coverage"] = _job_coverage(j, configured)
|
||
jobs.append(d)
|
||
return jsonify({"ok": True, "jobs": jobs,
|
||
"task_types": list_task_types()})
|
||
|
||
@bp.route("/api/jobs", methods=["POST"])
|
||
@perm_required(PERM_TASKS)
|
||
def api_jobs_create():
|
||
data = request.json or {}
|
||
name = (data.get("name") or "").strip()
|
||
if not name:
|
||
return jsonify({"ok": False, "error": "任务名不能为空"}), 400
|
||
task_type = data.get("task_type", "generic_steps")
|
||
if not get_task_class(task_type):
|
||
return jsonify({"ok": False, "error": f"未知任务类型: {task_type}"}), 400
|
||
job = context.mgr.add_job(
|
||
name=name, task_type=task_type,
|
||
target=data.get("target", {"mode": "all"}),
|
||
params=data.get("params", {}),
|
||
schedule=data.get("schedule", {"mode": "once"}),
|
||
retry=data.get("retry", {"max_attempts": 1, "delay": 60}),
|
||
enabled=data.get("enabled", True))
|
||
d = job.to_dict()
|
||
d["next_run"] = _job_next_run(job)
|
||
d["coverage"] = _job_coverage(job, _configured_set())
|
||
return jsonify({"ok": True, "msg": "任务已创建", "job": d})
|
||
|
||
@bp.route("/api/jobs/<job_id>", methods=["PUT"])
|
||
@perm_required(PERM_TASKS)
|
||
def api_jobs_update(job_id):
|
||
job = context.mgr.jobs.get(job_id)
|
||
if not job:
|
||
return jsonify({"ok": False, "error": "任务不存在"}), 404
|
||
data = request.json or {}
|
||
fields = {}
|
||
for k in ("name", "task_type", "target", "params", "schedule", "retry", "enabled"):
|
||
if k in data:
|
||
fields[k] = data[k]
|
||
if "task_type" in fields and not get_task_class(fields["task_type"]):
|
||
return jsonify({"ok": False, "error": f"未知任务类型: {fields['task_type']}"}), 400
|
||
context.mgr.update_job(job_id, **fields)
|
||
d = job.to_dict()
|
||
d["next_run"] = _job_next_run(job)
|
||
d["coverage"] = _job_coverage(job, _configured_set())
|
||
return jsonify({"ok": True, "msg": "任务已更新", "job": d})
|
||
|
||
@bp.route("/api/jobs/<job_id>", methods=["DELETE"])
|
||
@perm_required(PERM_TASKS)
|
||
def api_jobs_delete(job_id):
|
||
if context.mgr.delete_job(job_id):
|
||
return jsonify({"ok": True, "msg": "任务已删除"})
|
||
return jsonify({"ok": False, "error": "任务不存在"}), 404
|
||
|
||
@bp.route("/api/jobs/<job_id>/run", methods=["POST"])
|
||
@perm_required(PERM_TASKS)
|
||
def api_jobs_run(job_id):
|
||
return jsonify(context.mgr.run_job_now(job_id))
|
||
|
||
@bp.route("/api/jobs/<job_id>/toggle", methods=["POST"])
|
||
@perm_required(PERM_TASKS)
|
||
def api_jobs_toggle(job_id):
|
||
enabled = (request.json or {}).get("enabled", True)
|
||
job = context.mgr.toggle_job(job_id, enabled)
|
||
if not job:
|
||
return jsonify({"ok": False, "error": "任务不存在"}), 404
|
||
return jsonify({"ok": True, "msg": f"任务已{'启用' if enabled else '停用'}"})
|
||
|
||
|
||
# ================== API:设备分组 CRUD ==================
|
||
|
||
@bp.route("/api/groups")
|
||
@login_required
|
||
def api_groups_list():
|
||
return jsonify({"ok": True, "groups": [g.to_dict() for g in context.mgr.groups.values()]})
|
||
|
||
@bp.route("/api/groups", methods=["POST"])
|
||
@perm_required(PERM_TASKS)
|
||
def api_groups_create():
|
||
data = request.json or {}
|
||
name = (data.get("name") or "").strip()
|
||
if not name:
|
||
return jsonify({"ok": False, "error": "分组名不能为空"}), 400
|
||
if name in context.mgr.groups:
|
||
return jsonify({"ok": False, "error": "分组名已存在"}), 400
|
||
context.mgr.add_group(name, data.get("serials", []), data.get("description", ""))
|
||
return jsonify({"ok": True, "msg": "分组已创建"})
|
||
|
||
@bp.route("/api/groups/<name>", methods=["PUT"])
|
||
@perm_required(PERM_TASKS)
|
||
def api_groups_update(name):
|
||
if name not in context.mgr.groups:
|
||
return jsonify({"ok": False, "error": "分组不存在"}), 404
|
||
data = request.json or {}
|
||
context.mgr.update_group(name,
|
||
serials=data.get("serials"),
|
||
description=data.get("description"))
|
||
return jsonify({"ok": True, "msg": "分组已更新"})
|
||
|
||
@bp.route("/api/groups/<name>", methods=["DELETE"])
|
||
@perm_required(PERM_TASKS)
|
||
def api_groups_delete(name):
|
||
if context.mgr.delete_group(name):
|
||
return jsonify({"ok": True, "msg": "分组已删除"})
|
||
return jsonify({"ok": False, "error": "分组不存在"}), 404
|
||
|
||
|
||
# ================== API:录制手势(手机端真手指轨迹) ==================
|
||
#
|
||
# 用 getevent 读**真触屏**设备抓手指轨迹(见 core/gesture.py)。合成的注入事件不会
|
||
# 出现在真触屏节点上,所以录到的只有人手的动作。
|
||
# 录制器实例存在内存里(单进程;不落库——录完的点列由前端放进步骤/动作再保存)。
|
||
|
||
_gesture_recorders = {} # token -> PhoneRecorder
|
||
_gesture_lock = threading.Lock()
|
||
_GESTURE_TTL = 300 # 超过 5 分钟自动收摊,避免忘了点"停止"
|
||
|
||
|
||
def _gesture_sweep():
|
||
"""清掉超时的录制器(停止并释放 getevent 进程)。"""
|
||
now = time.time()
|
||
with _gesture_lock:
|
||
stale = [t for t, r in _gesture_recorders.items()
|
||
if now - r._started > _GESTURE_TTL]
|
||
for t in stale:
|
||
_gesture_recorders.pop(t, None)
|
||
for t in stale:
|
||
try:
|
||
r.stop()
|
||
except Exception:
|
||
pass
|
||
_log.info("录制手势超时自动停止: %s", t)
|
||
|
||
|
||
@bp.route("/api/gesture/record/start", methods=["POST"])
|
||
@perm_required(PERM_DEVICES)
|
||
def api_gesture_record_start():
|
||
"""开始录:{serial} → {token}。之后在手机屏幕上用手指划,停止时取回轨迹。"""
|
||
from core import gesture
|
||
_gesture_sweep()
|
||
serial = ((request.json or {}).get("serial") or "").strip()
|
||
if not serial:
|
||
return jsonify({"ok": False, "error": "缺少 serial"}), 400
|
||
with _gesture_lock:
|
||
if _gesture_recorders:
|
||
return jsonify({"ok": False, "error": "已有录制在进行中,先停止"}), 409
|
||
try:
|
||
w, h = 0, 0
|
||
try:
|
||
import uiautomator2 as u2
|
||
from core.u2_helper import screen_size
|
||
d = u2.connect(serial)
|
||
d.unlock() # 息屏时 u2 抓树会退化到几十秒,先唤醒
|
||
w, h = screen_size(d, serial)
|
||
except Exception as e:
|
||
_log.warning("录制前取屏幕尺寸失败(按原始坐标范围换算): %s", e)
|
||
rec = gesture.PhoneRecorder(serial, w, h)
|
||
rec.start()
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": f"开始录制失败: {e}"}), 502
|
||
token = uuid.uuid4().hex[:12]
|
||
with _gesture_lock:
|
||
_gesture_recorders[token] = rec
|
||
_log.info("开始录制手势: %s @ %s(%s)by %s", token, serial, rec.device,
|
||
getattr(current_user, "username", ""))
|
||
return jsonify({"ok": True, "token": token, "device": rec.device,
|
||
"screen": [w, h],
|
||
"msg": "现在请用手指在手机上划,划完点「停止」"})
|
||
|
||
|
||
@bp.route("/api/gesture/record/stop", methods=["POST"])
|
||
@perm_required(PERM_DEVICES)
|
||
def api_gesture_record_stop():
|
||
"""停止录制:{token} → {gestures: [[[x,y,t_ms],...], ...]}。"""
|
||
token = ((request.json or {}).get("token") or "").strip()
|
||
with _gesture_lock:
|
||
rec = _gesture_recorders.pop(token, None)
|
||
if rec is None:
|
||
return jsonify({"ok": False, "error": "录制会话不存在或已超时"}), 404
|
||
try:
|
||
gestures = rec.stop()
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": f"停止录制失败: {e}"}), 500
|
||
_log.info("停止录制手势: %s → %s 条", token, len(gestures))
|
||
return jsonify({"ok": True, "gestures": gestures, "count": len(gestures),
|
||
"device": rec.device})
|
||
|
||
|
||
# ================== API:步骤默认值(「动作配置」页) ==================
|
||
#
|
||
# 存 app_meta.step_defaults 一个键(不建表),校验与合并都在 core/step_defaults.py。
|
||
|
||
@bp.route("/api/step_defaults")
|
||
@login_required
|
||
def api_step_defaults_get():
|
||
"""生效的默认值 + 出厂值 + 字段规格(前端画表单用)。"""
|
||
return jsonify({"ok": True, **step_defaults.as_payload()})
|
||
|
||
|
||
@bp.route("/api/step_defaults", methods=["POST"])
|
||
@perm_required(PERM_TASKS)
|
||
def api_step_defaults_save():
|
||
"""保存默认值(只落与出厂值不同的字段,便于以后出厂值升级能跟着走)。"""
|
||
data = request.json or {}
|
||
ok, msg = step_defaults.save(data.get("defaults") or data)
|
||
if not ok:
|
||
return jsonify({"ok": False, "error": msg}), 400
|
||
_log.info("更新步骤默认值 by %s: %s", getattr(current_user, "username", ""),
|
||
list((data.get("defaults") or data).keys()))
|
||
return jsonify({"ok": True, "msg": msg, **step_defaults.as_payload()})
|
||
|
||
|
||
# ================== API:去重记录(「任务 → 去重记录」页) ==================
|
||
#
|
||
# 账本在 done_mark 表(跨设备幂等,见 core/dedup.py);任务步骤怎么用它见
|
||
# tasks/generic/task.py 的 `_exec_mark_done` 与 if_el 的 `selector_type=dedup`。
|
||
|
||
@bp.route("/api/done_marks")
|
||
@login_required
|
||
def api_done_marks():
|
||
"""去重记录列表 + 统计(?job=<任务ID> 只看某个任务)。"""
|
||
job_id = (request.args.get("job") or "").strip()
|
||
try:
|
||
limit = int(request.args.get("limit") or 200)
|
||
except ValueError:
|
||
limit = 200
|
||
try:
|
||
rows, stats = dedup.list_marks(job_id, limit)
|
||
except Exception as e:
|
||
_log.warning(f"读取去重记录失败: {e}")
|
||
return jsonify({"ok": False, "error": str(e)[:120]}), 503
|
||
return jsonify({"ok": True, "marks": rows, "stats": stats, "job_id": job_id})
|
||
|
||
|
||
@bp.route("/api/done_marks/delete", methods=["POST"])
|
||
@perm_required(PERM_TASKS)
|
||
def api_done_marks_delete():
|
||
"""删一条记录 → 那个号/那台设备可以重跑。"""
|
||
data = request.json or {}
|
||
mark_id = data.get("id")
|
||
if mark_id in (None, ""):
|
||
return jsonify({"ok": False, "error": "缺少 id"}), 400
|
||
if not dedup.delete_mark(mark_id):
|
||
return jsonify({"ok": False, "error": "记录不存在或删除失败"}), 404
|
||
_log.info("删除去重记录 #%s by %s", mark_id, getattr(current_user, "username", ""))
|
||
return jsonify({"ok": True, "msg": "已删除(该设备/身份下次会重新执行)"})
|
||
|
||
|
||
@bp.route("/api/done_marks/clear", methods=["POST"])
|
||
@perm_required(PERM_TASKS)
|
||
def api_done_marks_clear():
|
||
"""清空某个任务的全部记录 → 整批重跑。"""
|
||
data = request.json or {}
|
||
job_id = (data.get("job") or "").strip()
|
||
if not job_id:
|
||
return jsonify({"ok": False, "error": "缺少任务ID"}), 400
|
||
n = dedup.clear_job(job_id)
|
||
_log.info("清空去重记录:任务 %s 共 %s 条 by %s", job_id, n,
|
||
getattr(current_user, "username", ""))
|
||
return jsonify({"ok": True, "msg": f"已清空 {n} 条记录(该任务下次会全部重跑)", "deleted": n})
|
||
|
||
|
||
# ================== API:用户管理 CRUD ==================
|
||
|
||
@bp.route("/api/custom_actions")
|
||
@login_required
|
||
def api_custom_actions_list():
|
||
rows = CustomAction.query.order_by(CustomAction.created_at.desc()).all()
|
||
return jsonify({"ok": True, "actions": [r.to_dict() for r in rows]})
|
||
|
||
@bp.route("/api/custom_actions", methods=["POST"])
|
||
@perm_required(PERM_TASKS)
|
||
def api_custom_actions_create():
|
||
data = request.json or {}
|
||
name = (data.get("name") or "").strip()
|
||
if not name:
|
||
return jsonify({"ok": False, "error": "动作名不能为空"}), 400
|
||
steps = data.get("steps", [])
|
||
if not steps:
|
||
return jsonify({"ok": False, "error": "至少需要1个步骤"}), 400
|
||
row = CustomAction(id=uuid.uuid4().hex[:8], name=name,
|
||
icon=data.get("icon") or "📦",
|
||
created_at=datetime.now().strftime("%Y-%m-%d %H:%M"))
|
||
row.set_steps(steps)
|
||
db.session.add(row)
|
||
db.session.commit()
|
||
return jsonify({"ok": True, "msg": "动作已保存", "action": row.to_dict()})
|
||
|
||
@bp.route("/api/custom_actions/<action_id>", methods=["PUT"])
|
||
@perm_required(PERM_TASKS)
|
||
def api_custom_actions_update(action_id):
|
||
row = CustomAction.query.get(action_id)
|
||
if not row:
|
||
return jsonify({"ok": False, "error": "动作不存在"}), 404
|
||
data = request.json or {}
|
||
if "name" in data:
|
||
name = (data["name"] or "").strip()
|
||
if not name:
|
||
return jsonify({"ok": False, "error": "动作名不能为空"}), 400
|
||
row.name = name
|
||
if "icon" in data:
|
||
row.icon = data["icon"] or "📦"
|
||
if "steps" in data:
|
||
if not data["steps"]:
|
||
return jsonify({"ok": False, "error": "至少需要1个步骤"}), 400
|
||
row.set_steps(data["steps"])
|
||
db.session.commit()
|
||
return jsonify({"ok": True, "msg": "已更新", "action": row.to_dict()})
|
||
|
||
@bp.route("/api/custom_actions/<action_id>", methods=["DELETE"])
|
||
@perm_required(PERM_TASKS)
|
||
def api_custom_actions_delete(action_id):
|
||
row = CustomAction.query.get(action_id)
|
||
if not row:
|
||
return jsonify({"ok": False, "error": "动作不存在"}), 404
|
||
db.session.delete(row)
|
||
db.session.commit()
|
||
return jsonify({"ok": True, "msg": "已删除"})
|
||
|
||
@bp.route("/api/uiauto/elements")
|
||
@perm_required(PERM_DEVICES)
|
||
def api_uiauto_elements():
|
||
"""获取设备当前 UI 元素树(供步骤编辑器"抓取元素"用)。
|
||
|
||
依赖本地运行的 uiautodev 服务(端口 20242)。
|
||
?serial=xxx 设备 serial
|
||
返回:
|
||
200 — {ok:true, elements:[...]}
|
||
503 — {ok:false, error:"..."}(uiauto2 未启动)
|
||
"""
|
||
serial = request.args.get("serial", "")
|
||
if not serial:
|
||
return jsonify({"ok": False, "error": "缺少 serial"}), 400
|
||
ok, data = uiauto_helper.get_elements(serial)
|
||
if ok:
|
||
return jsonify({"ok": True, "elements": data})
|
||
return jsonify({"ok": False, "error": data}), 503
|
||
|
||
@bp.route("/api/uiauto/status")
|
||
@login_required
|
||
def api_uiauto_status():
|
||
"""探测 uiauto2 本地服务是否运行(前端按钮禁启用)。"""
|
||
return jsonify({"ok": True, "running": uiauto_helper.is_running()})
|
||
|
||
@bp.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),与运行中任务互不干扰。
|
||
返回 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})
|
||
|
||
@bp.route("/api/uiauto/devices")
|
||
@perm_required(PERM_DEVICES)
|
||
def api_uiauto_devices():
|
||
"""获取可选设备列表(抓取元素用)。
|
||
|
||
uiautodev 只认识本地 adb 已连接的设备;设备池里在线但未本地连接的设备
|
||
在这里补全,并先做一次轻量 adb connect(单次尝试,绝不 kill-server),
|
||
让它们可被 uiautodev 抓取。
|
||
"""
|
||
ok, data = uiauto_helper.list_devices()
|
||
if not ok:
|
||
return jsonify({"ok": False, "error": data}), 503
|
||
seen = {d.get("serial") for d in data}
|
||
try:
|
||
online = set(device_pool.list_online())
|
||
pool = [s for s in device_pool.list_configured() if s in online]
|
||
except Exception:
|
||
pool = []
|
||
added = 0
|
||
for serial in pool:
|
||
if not serial or serial in seen:
|
||
continue
|
||
# 轻量连接(单次尝试,不重试不 kill-server);连不上也照样列出,
|
||
# 前端抓取时会报明确错误,不影响其他设备的连接
|
||
adb_connect_light(serial)
|
||
data.append({
|
||
"serial": serial,
|
||
"model": "",
|
||
"product": "",
|
||
"name": serial,
|
||
"status": "device",
|
||
"enabled": True,
|
||
})
|
||
seen.add(serial)
|
||
added += 1
|
||
# 设备名用**平台起的名字**(A08 / cs1):uiautodev 那份 name 是设备自己的
|
||
# codename(实测一柜子机器全叫 "earth"),同型号好几台时等于没有——
|
||
# 而抓取元素/试执行必须选对设备,所以池外设备宁可退回地址(地址至少唯一)。
|
||
try:
|
||
names = {d["serial"]: (d.get("name") or "") for d in device_pool.list_devices()}
|
||
except Exception:
|
||
names = {}
|
||
for d in data:
|
||
s = d.get("serial") or ""
|
||
d["name"] = names.get(s) or s
|
||
if added:
|
||
_log.info(f"抓取元素设备列表补全 {added} 台在线设备: {list(seen)}")
|
||
return jsonify({"ok": True, "devices": data})
|
||
|
||
@bp.route("/api/uiauto/snapshot")
|
||
@perm_required(PERM_DEVICES)
|
||
def api_uiauto_snapshot():
|
||
"""一次取齐:设备截图 + 元素树(**原生 u2,同一个连接背靠背取**)。
|
||
|
||
抓取元素时用它代替「/api/uiauto/screenshot + /api/uiauto/elements」两连击:
|
||
那两条是两次独立取数,中间隔着 dump 本身的 1.3~1.8 秒 —— 界面只要在动
|
||
(信息流/视频/加载动画),框就会落在旧位置上("抓取错位")。
|
||
同一个连接背靠背取把这个窗口压到最小,并额外用**双截图校验**标出
|
||
"抓取期间界面在变化"(`unstable`),前端据此明确提示而不是悄悄给错位的框。
|
||
|
||
?serial=xxx
|
||
→ {ok, image(data-url), width, height, elements:[...], unstable, cost_ms}
|
||
"""
|
||
serial = request.args.get("serial", "")
|
||
if not serial:
|
||
return jsonify({"ok": False, "error": "缺少 serial"}), 400
|
||
ok, data = uiauto_helper.snapshot(serial)
|
||
if not ok:
|
||
# 设备没连上/抓取失败:502(依赖不可用),与 /api/uiauto/elements 一致
|
||
return jsonify({"ok": False, "error": data}), 502
|
||
return jsonify({"ok": True, **data})
|
||
|
||
|
||
@bp.route("/api/uiauto/screenshot")
|
||
@perm_required(PERM_DEVICES)
|
||
def api_uiauto_screenshot():
|
||
"""通过 uiauto2 获取设备截图(供抓取元素时显示设备画面)。
|
||
|
||
?serial=xxx 设备 serial
|
||
直接返回 image/jpeg,前端用 <img> 加载。
|
||
"""
|
||
serial = request.args.get("serial", "")
|
||
if not serial:
|
||
return jsonify({"ok": False, "error": "缺少 serial"}), 400
|
||
ok, data = uiauto_helper.get_screenshot(serial)
|
||
if ok:
|
||
return Response(data, mimetype="image/jpeg",
|
||
headers={"Cache-Control": "no-store"})
|
||
return jsonify({"ok": False, "error": data}), 503
|
||
|
||
|
||
# ================== API:远程看屏(阶段 2) ==================
|
||
# MJPEG 实时画面流 + u2 触控注入。数据源 u2(atx-agent minicap 截图,单帧 ~200ms),
|
||
# 零新依赖(原计划 ws-scrcpy 不在 npm 分发,GitHub 下载在国内不可靠,改自建)。
|
||
# 坐标约定:前端以 <img> 显示尺寸归一化后映射到设备原生分辨率(naturalWidth/Height)。
|
||
_SCREEN_JPEG_QUALITY = 65
|
||
_SCREEN_FRAME_GAP = 0.05 # 帧间最小间隔(秒),防止空转烧 CPU
|
||
|
||
def _job_next_run(job):
|
||
"""任务下次执行时间,格式化为 "YYYY-MM-DD HH:MM"(Flask 序列化 aware datetime 会变 GMT 格式)。"""
|
||
nr = context.mgr.next_run_of(job)
|
||
return nr.strftime("%Y-%m-%d %H:%M") if nr else None
|
||
|