feat: 条件判断步骤(if_el 双分支:找到/未找到各执行独立步骤,可嵌套循环/条件;OCR识别选择器匹配图片文字,RapidOCR 跨平台实现含命中点击)+ 动作编辑器布局优化(操作库分组/加宽、任务弹窗加宽)

This commit is contained in:
2026-08-11 10:38:47 +08:00
parent 159649b6a0
commit 2f2d8cc184
5 changed files with 280 additions and 59 deletions
+79
View File
@@ -0,0 +1,79 @@
"""屏幕 OCR 识别(RapidOCR,ONNX 推理,跨平台:Windows/Linux/macOS)。
用于"条件判断"步骤的 ocr 选择器:截屏 → 识别文字 → 按关键词匹配 → 可点击。
UI 树里找不到的文字(图片、画布、WebView 渲染的文字)也能识别。
依赖 rapidocr_onnxruntime(中英文模型随包内置,无需下载/联网):
pip install rapidocr_onnxruntime
服务器(无显示器)环境建议把 opencv-python 换成 opencv-python-headless。
坐标约定:返回 box 为像素坐标(原点左上),与 u2 的 d.click 一致。
"""
import threading
import numpy as np
from core.logger import get_logger
_log = get_logger("core.ocr")
_engine = None
_engine_lock = threading.Lock()
def _get_engine():
global _engine
with _engine_lock:
if _engine is None:
from rapidocr_onnxruntime import RapidOCR
_log.info("初始化 RapidOCR 引擎(首次约 1-2 秒加载模型)")
_engine = RapidOCR()
return _engine
def available():
"""OCR 是否可用。返回 (ok, hint)。"""
try:
_get_engine()
return True, ""
except Exception as e:
return False, f"OCR 不可用(需 pip install rapidocr_onnxruntime): {e}"
def recognize(image):
"""识别 PIL Image 上的文字。
返回 [{text, score, box}],box=[x1,y1,x2,y2] 像素坐标(原点左上,与 u2 一致)。
"""
engine = _get_engine()
# PIL(RGB) → ndarray(BGR),与 OpenCV 约定一致
arr = np.array(image.convert("RGB"))[:, :, ::-1]
results = []
try:
# 引擎实例并发调用加锁保护(识别本身约 0.2-0.5s,锁开销可忽略)
with _engine_lock:
result, _ = engine(arr)
except Exception as e:
_log.warning(f"OCR 识别异常: {e}")
return results
for item in result or []:
box4, text, score = item
xs = [p[0] for p in box4]
ys = [p[1] for p in box4]
results.append({"text": text, "score": float(score),
"box": [round(min(xs)), round(min(ys)),
round(max(xs)), round(max(ys))]})
return results
def find_on_screen(image, keyword):
"""在截图上查找包含 keyword 的文字。
返回 (found, center_xy, matched_text);center_xy 为文字中心像素坐标(可点击),
未命中返回 (False, None, "")。
"""
for r in recognize(image):
if keyword in r["text"]:
b = r["box"]
return True, ((b[0] + b[2]) // 2, (b[1] + b[3]) // 2), r["text"]
return False, None, ""
+4
View File
@@ -24,5 +24,9 @@ uiautomator2>=3.0
# UI 元素抓取(步骤编辑器"抓取元素"功能,本地服务端口 20242)
uiautodev>=0.14
# 屏幕 OCR(条件判断的 OCR识别 选择器,ONNX 推理,跨平台:Windows/Linux/macOS,
# 中英文模型随包内置;服务器无显示器环境建议把 opencv-python 换成 opencv-python-headless)
rapidocr_onnxruntime>=1.4
# APK 元信息解析(应用管理功能:自动读取包名/版本/应用名)
pyaxmlparser>=0.3.27
+124 -53
View File
@@ -769,6 +769,8 @@ function openTaskModal(jobId){
const job=jobId?(_jobsData||[]).find(j=>j.id===jobId):null;
const isEdit=!!job;
document.getElementById('modal-title').textContent=isEdit?'编辑任务':'新建任务';
// 任务编辑器含步骤编辑,加宽弹窗;关闭时在 closeModal 移除
document.getElementById('modal-box').classList.add('wide-modal');
const j=job||{name:'',task_type:'douyin_nurture',enabled:true,target:{mode:'all'},params:{},schedule:{mode:'once'},retry:{max_attempts:1,delay:60}};
const tt=_taskTypes.map(t=>'<option value="'+t.task_type+'"'+(t.task_type===j.task_type?' selected':'')+'>'+esc(t.name)+'</option>').join('');
@@ -943,41 +945,60 @@ function onTaskTypeChange(){
// ================== 步骤编辑器 ==================
var STEP_LIB=[
{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_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:'input_text',label:'输入文字',icon:'⌨',params:{mode:'random',texts:'你好\n有趣\n支持',fixed_text:''}},
{type:'wait',label:'等待',icon:'⏱',params:{min:1.0,max:3.0}},
{type:'loop',label:'循环块',icon:'↻',params:{loop_mode:'rounds',max_iterations:10,loop_duration:600,children:[]}},
{type:'group',label:'动作组',icon:'📦',params:{children:[]}}
// cat:interact=交互操作 / screen=屏幕与App / flow=流程控制(操作库按此分组展示)
{type:'click',label:'点击元素',icon:'✦',cat:'interact',params:{selector_type:'xpath',selector_value:'',wait_timeout:2}},
{type:'click_xy',label:'点击坐标',icon:'🎯',cat:'interact',params:{x:50,y:50}},
{type:'long_click',label:'长按元素',icon:'👆',cat:'interact',params:{selector_type:'xpath',selector_value:'',duration:1.0,wait_timeout:2}},
{type:'swipe',label:'滑动',icon:'↕',cat:'interact',params:{direction:'up',duration_min:0.25,duration_max:0.50}},
{type:'swipe_until',label:'滑动直到元素',icon:'🔍',cat:'interact',params:{selector_type:'xpath',selector_value:'',direction:'up',max_swipes:8,click_when_found:true}},
{type:'input_text',label:'输入文字',icon:'⌨',cat:'interact',params:{mode:'random',texts:'你好\n有趣\n支持',fixed_text:'',clear_first:true}},
{type:'key_event',label:'按键',icon:'⌨️',cat:'interact',params:{key:'back'}},
{type:'open_app',label:'打开App',icon:'▶',cat:'screen',params:{package:'',wait_home:false,home_feature:''}},
{type:'stop_app',label:'结束App',icon:'⏹',cat:'screen',params:{package:''}},
{type:'screen_on',label:'亮屏',icon:'💡',cat:'screen',params:{}},
{type:'screen_off',label:'息屏',icon:'🌙',cat:'screen',params:{}},
{type:'keep_screen',label:'保持亮屏',icon:'🔆',cat:'screen',params:{mode:'on'}},
{type:'wait',label:'等待',icon:'⏱',cat:'flow',params:{min:1.0,max:3.0}},
{type:'wait_el',label:'等待元素',icon:'⏳',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:10}},
{type:'loop',label:'循环块',icon:'↻',cat:'flow',params:{loop_mode:'rounds',max_iterations:10,loop_duration:600,children:[]}},
{type:'group',label:'动作组',icon:'📦',cat:'flow',params:{children:[]}},
{type:'if_el',label:'条件判断',icon:'❓',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:3,ocr_click:false,then:[],else:[]}}
];
var STEP_LIB_CATS=[['interact','交互操作'],['screen','屏幕与App'],['flow','流程控制']];
var _customActions=[]; // 后端加载的自定义动作
var _stepIdCounter=0;
function _newStepId(){_stepIdCounter++;return 'step_'+Date.now().toString(36)+'_'+_stepIdCounter;}
// 步骤的所有子步骤数组(loop/group 的 children、if_el 的 then/else 分支)
function _childArrs(s){
var arrs=[];
if(s&&s.params){
if(Array.isArray(s.params.children))arrs.push(s.params.children);
if(Array.isArray(s.params.then))arrs.push(s.params.then);
if(Array.isArray(s.params.else))arrs.push(s.params.else);
}
return arrs;
}
// 递归去掉步骤 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);
_childArrs(s).forEach(_stripIds);
});
}
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('');
// 选择器组件(类型下拉 + 值输入 + 抓取元素 + 测试按钮),click/long_click/swipe_until/wait_el/if_el 复用
// withOcr=true 时额外提供 OCR识别 选项(图片/画布里的文字,仅条件判断支持)
function _selRowHtml(p, extra, withOcr){
var types=withOcr?['xpath','description','text','resourceId','descriptionContains','className','ocr']:
['xpath','description','text','resourceId','descriptionContains','className'];
var labels={xpath:'xpath',description:'description',text:'text',resourceId:'resourceId',
descriptionContains:'descriptionContains',className:'className',ocr:'OCR识别'};
var opts=types.map(function(d){
return '<option value="'+d+'"'+(p.selector_type===d?' selected':'')+'>'+labels[d]+'</option>';}).join('');
var toggle=withOcr?' onchange="_stepEditor._toggleOcrMode(this)"':'';
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>'+
return '<div class="form-row"><div class="form-group"><label>选择器类型</label><select class="form-control" data-key="selector_type"'+toggle+'>'+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||'');
@@ -1041,17 +1062,19 @@ var _stepEditor={
var self=this;
steps.forEach(function(s){
if(!s.id)s.id=_newStepId();
if(s.params&&s.params.children&&Array.isArray(s.params.children)){
self._ensureIds(s.params.children);
}
_childArrs(s).forEach(function(a){self._ensureIds(a);});
});
},
_renderLib:function(){
var lib=this._lib;
if(!lib)return; // 未初始化编辑器(如仅加载动作列表时)跳过
var items=STEP_LIB.map(function(s){
return '<div class="lib-item" draggable="true" data-type="'+s.type+'">'+
'<span class="ico">'+s.icon+'</span><span>'+s.label+'</span></div>';
// 按分类分组展示(STEP_LIB_CATS 定义分类顺序)
var items=STEP_LIB_CATS.map(function(c){
var list=STEP_LIB.filter(function(s){return s.cat===c[0];}).map(function(s){
return '<div class="lib-item" draggable="true" data-type="'+s.type+'" title="'+esc(s.label)+'">'+
'<span class="ico">'+s.icon+'</span><span>'+s.label+'</span></div>';
}).join('');
return '<div class="lib-section"><div class="lib-title">'+c[1]+'</div>'+list+'</div>';
}).join('');
// 自定义动作区
var customHtml='';
@@ -1064,7 +1087,7 @@ var _stepEditor={
}).join('');
customHtml+='</div>';
}
lib.innerHTML='<div class="lib-title">操作库</div>'+items+customHtml;
lib.innerHTML=items+customHtml;
var self=this;
// 绑定拖拽 + 点击(系统步骤)—— 用 _dragSuppressClick 防止拖拽后重复触发 click
lib.querySelectorAll('.lib-item[data-type]').forEach(function(item){
@@ -1114,9 +1137,8 @@ var _stepEditor={
_renderCard:function(step,idx,parentArr){
var self=this;
var lib=_findLib(step.type)||{icon:'?',label:step.type};
var isContainer=(step.type==='loop'||step.type==='group');
var card=document.createElement('div');
card.className='step-card'+(step.type==='loop'?' loop-block':'')+(step.type==='group'?' group-block':'');
card.className='step-card'+(step.type==='loop'?' loop-block':'')+(step.type==='group'?' group-block':'')+(step.type==='if_el'?' if-block':'');
card.draggable=true;
card.dataset.idx=idx;
card.dataset.stepId=step.id||'';
@@ -1165,11 +1187,29 @@ var _stepEditor={
self._handleDrop(e,parentArr,idx);
});
// loop/group 子步骤
if(isContainer&&step.params&&step.params.children){
var childWrap=document.createElement('div');
childWrap.className='step-children';
this._renderStepList(childWrap,step.params.children);
card.appendChild(childWrap);
if(step.type==='loop'||step.type==='group'){
if(step.params&&step.params.children){
var childWrap=document.createElement('div');
childWrap.className='step-children';
this._renderStepList(childWrap,step.params.children);
card.appendChild(childWrap);
}
}
// if_el 双分支:找到时(then) / 未找到时(else),各自独立容器接收拖入
if(step.type==='if_el'&&step.params){
[['then','✅ 找到时执行','if-then'],['else','❌ 未找到时执行','if-else']].forEach(function(b){
var branch=document.createElement('div');
branch.className='if-branch';
var title=document.createElement('div');
title.className='branch-title '+b[0];
title.textContent=b[1];
var wrap=document.createElement('div');
wrap.className='step-children '+b[2];
self._renderStepList(wrap,step.params[b[0]]||[]);
branch.appendChild(title);
branch.appendChild(wrap);
card.appendChild(branch);
});
}
return card;
},
@@ -1250,6 +1290,13 @@ var _stepEditor={
}else if(step.type==='group'){
h+='<div class="help">动作组:按顺序执行下方子步骤一次。把多个步骤打包成一个可复用的整体。</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>';
}else if(step.type==='if_el'){
h+=_selRowHtml(p,
'<div class="form-group"><label>查找超时(秒)</label><input type="number" class="form-control" data-key="timeout" value="'+(p.timeout||3)+'" min="0.5" max="30" step="0.5"></div>'+
'<div class="form-group form-check" data-ocr-field style="display:'+(p.selector_type==='ocr'?'block':'none')+'">'+
'<input type="checkbox" data-key="ocr_click" '+(p.ocr_click?'checked':'')+'><label>OCR 命中后自动点击该文字</label></div>'+
'<div class="help">超时内找到元素 → 执行下方"✅ 找到时"分支;未找到 → 执行"❌ 未找到时"分支。两分支可放任意步骤,也可再嵌套条件判断/循环;'+
'<b>OCR识别</b>可匹配图片/画布里的文字(截屏识别,跨平台)</div>', true);
}
// 共享:触发概率(所有步骤通用,0~100)
h+='<div class="form-row" style="margin-top:8px"><div class="form-group"><label>触发概率(%)</label><input type="number" class="form-control" data-key="probability" value="'+(p.probability!=null?p.probability:100)+'" min="0" max="100"><div class="help">0~100,&lt;100 表示按概率执行(如 30=隔几次才触发一次,适合"偶尔点赞")</div></div></div>';
@@ -1468,21 +1515,21 @@ var _stepEditor={
}else showToast(res.error||'保存失败','error');
});
},
// 在嵌套结构中按 id 查找步骤(group/loop 的子步骤也找得到)
// 在嵌套结构中按 id 查找步骤(group/loop 的子步骤、if_el 的分支步骤也找得到)
_findStepById:function(steps,id){
var self=this;
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;
}
var f=null;
_childArrs(s).forEach(function(a){ if(!f)f=self._findStepById(a,id); });
if(f)return f;
}
return null;
},
_collectByIds:function(steps,ids){
// 收集选中的步骤(深拷贝,去掉 id)。
// 递归搜索嵌套子步骤:☑ 也能选中 group/loop 内部的步骤,只搜顶层会漏
// 递归搜索嵌套子步骤:☑ 也能选中 group/loop/if_el 内部的步骤,只搜顶层会漏
var result=[];
var self=this;
steps.forEach(function(s){
@@ -1490,8 +1537,10 @@ var _stepEditor={
var copy=JSON.parse(JSON.stringify(s));
_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));
}else{
_childArrs(s).forEach(function(a){
result=result.concat(self._collectByIds(a,ids));
});
}
});
return result;
@@ -1514,6 +1563,12 @@ var _stepEditor={
g.style.display=g.dataset.lmode===mode?'block':'none';
});
},
// 条件判断:选择器类型切到 OCR 时显示"命中后点击"开关
_toggleOcrMode:function(sel){
var card=sel.closest('.step-card');
var f=card&&card.querySelector('[data-ocr-field]');
if(f)f.style.display=sel.value==='ocr'?'block':'none';
},
// 给数组分配唯一 id,用于 move/duplicate/remove/拖拽定位到正确的父数组
_arrId:function(arr){
if(arr===this._steps)return 'root';
@@ -1532,10 +1587,13 @@ var _stepEditor={
},
// 判断 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;
var arrs=_childArrs(step);
if(!arrs.length)return false;
if(arrs.indexOf(arr)>=0)return true;
for(var i=0;i<arrs.length;i++){
for(var j=0;j<arrs[i].length;j++){
if(this._containsArr(arrs[i][j],arr))return true;
}
}
return false;
},
@@ -1601,10 +1659,14 @@ var _stepEditor={
}else if(s.params.mode==='random'&&!s.params.texts){
warnings.push(name+' "'+label+'": 候选文字为空');
}
}else if(s.type==='if_el'){
if(!s.params.selector_value){
warnings.push(name+' "'+label+'": 条件判断的选择器为空,会直接走"未找到"分支。');
}
}
if(s.params&&s.params.children&&s.params.children.length){
check(s.params.children,name+' 子步骤');
}
_childArrs(s).forEach(function(a){
if(a.length)check(a,name+' 子步骤');
});
});
}
check(steps,'');
@@ -1631,11 +1693,20 @@ var _stepEditor={
}
}
});
// 递归子步骤
// 递归子步骤:loop/group 的 children
var childWrap=card.querySelector(':scope > .step-children');
if(childWrap&&steps[i].params&&steps[i].params.children){
self._syncParams(childWrap,steps[i].params.children);
}
// if_el 双分支(then/else 在 .if-branch 内,不会与上面的直接子级选择器冲突)
var thenWrap=card.querySelector(':scope > .if-branch .step-children.if-then');
if(thenWrap&&steps[i].params&&Array.isArray(steps[i].params.then)){
self._syncParams(thenWrap,steps[i].params.then);
}
var elseWrap=card.querySelector(':scope > .if-branch .step-children.if-else');
if(elseWrap&&steps[i].params&&Array.isArray(steps[i].params.else)){
self._syncParams(elseWrap,steps[i].params.else);
}
});
},
// 把画布 DOM 上的参数同步回 _steps,并自动保存草稿(操作前调用防丢失)
@@ -2742,7 +2813,7 @@ function closeModal(){
stopShotTimer();
_shotSerial='';
const box=document.getElementById('modal-box');
if(box)box.classList.remove('shot-modal');
if(box){box.classList.remove('shot-modal');box.classList.remove('wide-modal');}
}catch(e){console.warn('closeModal cleanup error:',e);}
}
+60 -2
View File
@@ -75,6 +75,9 @@ STEP_TYPES = [
"params": {"loop_mode": "rounds", "max_iterations": 10, "loop_duration": 600, "children": []}},
{"type": "group", "label": "动作组", "icon": "📦",
"params": {"children": []}},
{"type": "if_el", "label": "条件判断", "icon": "❓",
"params": {"selector_type": "xpath", "selector_value": "", "timeout": 3,
"then": [], "else": []}},
]
@@ -177,8 +180,8 @@ class GenericStepsWorker(BaseWorker):
_log.error(f"[{self.serial}] 步骤 {label}({stype}) 异常: {e}")
else:
_log.warning(f"[{self.serial}] 未知步骤类型: {stype}")
# 容器步骤(loop/group)不计入操作计数,避免监控显示噪音
if stype not in ("loop", "group"):
# 容器步骤(loop/group/if_el)不计入操作计数,避免监控显示噪音
if stype not in ("loop", "group", "if_el"):
self._action_counts[label] = self._action_counts.get(label, 0) + 1
elapsed = self.elapsed()
# done=累计执行的操作次数(无固定总数),前端显示"已执行 N 次操作"
@@ -365,6 +368,61 @@ class GenericStepsWorker(BaseWorker):
_log.warning(f"[{self.serial}] 等待元素异常: {e}")
return False
def _exec_if_el(self, d, params, depth=0):
"""条件判断:找到元素 → 执行 then 分支;超时未找到 → 执行 else 分支。
selector_type=ocr 时用屏幕 OCR 匹配文字(图片/画布/WebView 里的文字也能找到),
命中且 ocr_click=True 时自动点击该文字中心。
分支里可放任意步骤(含循环/嵌套条件判断),实现"有弹窗就关掉、没弹窗就继续"这类逻辑。
返回 True=命中 / False=未命中 / None=缺选择器或 OCR 不可用。
"""
sel_type = params.get("selector_type", "xpath")
sel_val = params.get("selector_value", "")
timeout = float(params.get("timeout", 3))
if not sel_val:
_log.warning(f"[{self.serial}] if_el 缺少选择器,跳过")
return None
found = False
if sel_type == "ocr":
# OCR 模式:截图 → 识别文字 → 关键词匹配(UI 树里没有的文字也能找到)
try:
from core.ocr import available as _ocr_available, find_on_screen
ok, hint = _ocr_available()
if not ok:
_log.error(f"[{self.serial}] OCR 条件判断不可用: {hint}")
return None
img = d.screenshot()
if img is None:
_log.warning(f"[{self.serial}] OCR 截图失败")
else:
found, center, matched = find_on_screen(img, sel_val)
if found:
if params.get("ocr_click") and center:
d.click(*center)
_log.info(f"[{self.serial}] OCR 命中 '{matched}',已点击 ({center[0]},{center[1]})")
else:
_log.info(f"[{self.serial}] OCR 命中 '{matched}'")
else:
_log.info(f"[{self.serial}] OCR 未命中: '{sel_val}'")
except Exception as e:
_log.warning(f"[{self.serial}] OCR 条件判断异常: {e}")
found = False
else:
try:
if sel_type == "xpath":
found = d.xpath(sel_val).wait(timeout=timeout)
else:
found = d(**{sel_type: sel_val}).exists(timeout=timeout)
except Exception as e:
_log.warning(f"[{self.serial}] 条件判断异常: {e}")
found = False
self._track_selector_health(sel_val, found)
branch = params.get("then" if found else "else") or []
self.set_action(f"条件{'命中' if found else '未命中'} → 执行{'找到' if found else '未找到'}分支({len(branch)}步)")
_log.info(f"[{self.serial}] 条件判断 {'命中' if found else '未命中'}: {sel_val},执行{'then' if found else 'else'}分支 {len(branch)} 步")
self._exec_steps(d, branch, depth + 1)
return found
def _exec_swipe(self, d, params, depth=0):
direction = params.get("direction", "up")
dur_min = float(params.get("duration_min", 0.25))
+13 -4
View File
@@ -161,6 +161,8 @@ select.form-control{cursor:pointer}
/* 截图弹窗 */
.shot-modal{max-width:none;width:min(440px,95vw)}
/* 宽弹窗(任务编辑器:步骤编辑需要横向空间) */
.wide-modal{max-width:1000px}
.shot-modal .modal-body{text-align:center;padding:14px}
.shot-img{max-width:100%;max-height:calc(100vh - 240px);width:auto;height:auto;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,.15);background:#1e293b;min-height:200px;display:block;margin:0 auto}
.shot-loading{color:var(--text-light);padding:60px 0;font-size:13px}
@@ -184,14 +186,15 @@ select.form-control{cursor:pointer}
.step-editor{display:none;border:1px solid var(--border);border-radius:10px;overflow:hidden;margin-top:8px}
.step-editor.show{display:block}
.step-editor .se-head{background:#f1f5f9;padding:10px 14px;font-weight:600;font-size:13px;display:flex;justify-content:space-between;align-items:center}
.step-editor .se-body{display:grid;grid-template-columns:140px 1fr;min-height:280px}
.step-editor .se-lib{border-right:1px solid var(--border);padding:8px;background:#f8fafc}
.step-editor .se-body{display:grid;grid-template-columns:200px 1fr;min-height:280px}
.step-editor .se-lib{border-right:1px solid var(--border);padding:8px;background:#f8fafc;overflow-y:auto;max-height:520px}
.step-editor .se-lib .lib-title{font-size:11px;color:var(--text-light);font-weight:600;margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px}
.step-editor .se-lib .lib-item{padding:6px 8px;border-radius:6px;cursor:grab;font-size:12px;color:var(--text);display:flex;align-items:center;gap:6px;margin-bottom:2px;transition:background .1s}
.step-editor .se-lib .lib-item{padding:6px 8px;border-radius:6px;cursor:grab;font-size:12px;color:var(--text);display:flex;align-items:center;gap:6px;margin-bottom:2px;transition:background .1s;white-space:nowrap;overflow:hidden}
.step-editor .se-lib .lib-item span:last-child{overflow:hidden;text-overflow:ellipsis}
.step-editor .se-lib .lib-item:hover{background:#e2e8f0}
.step-editor .se-lib .lib-item:active{cursor:grabbing}
.step-editor .se-lib .lib-item .ico{width:18px;height:18px;display:inline-flex;align-items:center;justify-content:center;background:var(--primary);color:#fff;border-radius:4px;font-size:11px;flex-shrink:0}
.step-editor .se-canvas{padding:8px;overflow-y:auto;max-height:400px}
.step-editor .se-canvas{padding:8px;overflow-y:auto;max-height:480px}
.step-editor .se-empty{text-align:center;color:var(--text-light);padding:40px 20px;font-size:13px}
.step-editor .se-empty .hint{font-size:11px;margin-top:4px}
.step-card{background:#fff;border:1px solid var(--border);border-radius:8px;margin-bottom:6px;transition:box-shadow .15s}
@@ -215,8 +218,14 @@ select.form-control{cursor:pointer}
.step-card.loop-block .sc-ico{background:var(--success)}
.step-card.group-block{border-left:3px solid #8b5cf6}
.step-card.group-block .sc-ico{background:#8b5cf6}
.step-card.if-block{border-left:3px solid #f59e0b}
.step-card.if-block .sc-ico{background:#f59e0b}
.step-card.selected{border-color:var(--primary);box-shadow:0 0 0 2px rgba(59,130,246,.2)}
.step-children{margin-left:20px;margin-top:6px;border-left:2px dashed var(--border);padding-left:8px}
.if-branch{margin-top:6px}
.if-branch .branch-title{font-size:11px;font-weight:600;padding:3px 10px;border-radius:4px;margin-bottom:4px;display:inline-block}
.if-branch .branch-title.then{color:#166534;background:#dcfce7}
.if-branch .branch-title.else{color:#991b1b;background:#fee2e2}
.step-children.drag-over{border-left-color:var(--primary);background:rgba(59,130,246,.06);border-radius:6px}
#se-canvas.drag-over{outline:2px dashed var(--primary);outline-offset:2px;border-radius:8px}
.sel-picker{display:flex;gap:4px;align-items:center}