feat: 条件判断步骤(if_el 双分支:找到/未找到各执行独立步骤,可嵌套循环/条件;OCR识别选择器匹配图片文字,RapidOCR 跨平台实现含命中点击)+ 动作编辑器布局优化(操作库分组/加宽、任务弹窗加宽)
This commit is contained in:
+79
@@ -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, ""
|
||||
@@ -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
|
||||
|
||||
+117
-46
@@ -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,18 +1062,20 @@ 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+'">'+
|
||||
// 按分类分组展示(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='';
|
||||
if(_customActions.length){
|
||||
@@ -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,12 +1187,30 @@ var _stepEditor={
|
||||
self._handleDrop(e,parentArr,idx);
|
||||
});
|
||||
// loop/group 子步骤
|
||||
if(isContainer&&step.params&&step.params.children){
|
||||
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;
|
||||
},
|
||||
_path:function(arr){
|
||||
@@ -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,<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);
|
||||
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
@@ -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))
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user