diff --git a/core/task_manager.py b/core/task_manager.py
index 76cc08e..b79a5b4 100644
--- a/core/task_manager.py
+++ b/core/task_manager.py
@@ -744,6 +744,7 @@ class TaskManager:
"progress": w.get("progress", {}),
"current_action": w.get("current_action", ""),
"last_error": w.get("last_error", ""),
+ "last_warning": w.get("last_warning", ""),
"running_job": r.get("job_id", ""),
"task_job": w.get("task_job", ""),
"attempt": r.get("attempt", 0),
diff --git a/static/admin/monitor.js b/static/admin/monitor.js
index 699a6c9..ca483c1 100644
--- a/static/admin/monitor.js
+++ b/static/admin/monitor.js
@@ -454,6 +454,8 @@ function renderDeviceRow(dev){
const retry=dev.attempt>0?' '+dev.attempt+'次':'';
// 预计结束时间(设置了运行时长上限时显示)
const endInfo=(dev.worker_status==='running'&&dev.end_time)?'
预计结束 '+new Date(dev.end_time*1000).toLocaleTimeString()+'
':'';
+ // 选择器健康警告(某步骤连续未找到元素,可能 App 改版选择器失效)
+ const warnInfo=dev.last_warning?'⚠ '+esc(dev.last_warning)+'
':'';
const stopBtn=(dev.worker_status==='running'||dev.worker_status==='connecting')?'':'';
// 截图按钮(任何状态都可看,离线时会报错)
const shotBtn='';
@@ -464,7 +466,7 @@ function renderDeviceRow(dev){
const checked=_selectedSerials.has(dev.serial)?'checked':'';
const rowCls=_selectedSerials.has(dev.serial)?'row-checked':'';
const cb='';
- return '| '+cb+' | '+devLabel+' | '+esc(dev.model)+' | '+stf+offline+' | '+wb+' | '+fg+' | '+prog+' | '+tname+retry+endInfo+' | '+esc(dev.current_action||'-')+' | '+esc(dev.last_error||'-')+' | '+btns+' |
';
+ return '| '+cb+' | '+devLabel+' | '+esc(dev.model)+' | '+stf+offline+' | '+wb+' | '+fg+' | '+prog+' | '+tname+retry+endInfo+' | '+esc(dev.current_action||'-')+warnInfo+' | '+esc(dev.last_error||'-')+' | '+btns+' |
';
}
async function loadJobsForMonitor(){
diff --git a/tasks/generic/task.py b/tasks/generic/task.py
index ff6cebe..468af01 100644
--- a/tasks/generic/task.py
+++ b/tasks/generic/task.py
@@ -83,6 +83,25 @@ class GenericStepsWorker(BaseWorker):
self.max_duration = int(p.get("max_duration", 0))
self.steps = p.get("steps", [])
self._action_counts = {} # 步骤执行计数 {step_label: count}
+ self._miss_counts = {} # 选择器健康:selector -> 连续未命中次数
+
+ # 某 click 选择器连续未找到元素的次数达到该值,判定可能失效(App 改版)
+ _MAX_CONSECUTIVE_MISS = 10
+
+ def _track_selector_health(self, sel_val, found):
+ """记录某选择器的连续未命中次数,达到阈值上报 last_warning 并重置计数。
+
+ 用于发现"选择器失效但任务仍显示成功"的静默空转问题(如 App 改版后元素找不到了)。
+ """
+ if found:
+ self._miss_counts.pop(sel_val, None)
+ return
+ n = self._miss_counts.get(sel_val, 0) + 1
+ self._miss_counts[sel_val] = n
+ if n >= self._MAX_CONSECUTIVE_MISS:
+ _log.error(f"[{self.serial}] 选择器连续 {n} 次未找到元素,可能已失效(App 改版?):{sel_val}")
+ _update_status(self.serial, last_warning=f"选择器连续{n}次未命中")
+ self._miss_counts.pop(sel_val, None) # 已上报,重置避免刷屏
def run_task(self, d):
"""按 steps 顺序执行。loop 步骤递归执行其 children。"""
@@ -179,6 +198,7 @@ class GenericStepsWorker(BaseWorker):
# 兼容旧配置:selector_type 不是 xpath 但值是 XPath(以 // 开头)
if sel_type != "xpath" and sel_val.strip().startswith("//"):
sel_type = "xpath"
+ found = False
try:
if sel_type == "xpath":
# u2 的 d.xpath() 返回 XPathSelector,exists 是属性需用 wait()
@@ -186,6 +206,7 @@ class GenericStepsWorker(BaseWorker):
if el.wait(timeout=timeout):
el.click()
_log.info(f"[{self.serial}] click 命中: xpath={sel_val}")
+ found = True
else:
_log.warning(f"[{self.serial}] click 未找到元素: xpath={sel_val} (等待{timeout}s)")
else:
@@ -194,8 +215,10 @@ class GenericStepsWorker(BaseWorker):
if el.exists(timeout=timeout):
el.click()
_log.info(f"[{self.serial}] click 命中: {sel_type}={sel_val}")
+ found = True
else:
_log.warning(f"[{self.serial}] click 未找到元素: {sel_type}={sel_val} (等待{timeout}s)")
+ self._track_selector_health(sel_val, found)
except Exception as e:
_log.warning(f"[{self.serial}] click 异常: {e}")