From 2f2d8cc18458fde085ffcd99fcf7fff1f9f8dce1 Mon Sep 17 00:00:00 2001
From: butubb <1422726308@qq.com>
Date: Tue, 11 Aug 2026 10:38:47 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9D=A1=E4=BB=B6=E5=88=A4=E6=96=AD?=
=?UTF-8?q?=E6=AD=A5=E9=AA=A4=EF=BC=88if=5Fel=20=E5=8F=8C=E5=88=86?=
=?UTF-8?q?=E6=94=AF=EF=BC=9A=E6=89=BE=E5=88=B0/=E6=9C=AA=E6=89=BE?=
=?UTF-8?q?=E5=88=B0=E5=90=84=E6=89=A7=E8=A1=8C=E7=8B=AC=E7=AB=8B=E6=AD=A5?=
=?UTF-8?q?=E9=AA=A4=EF=BC=8C=E5=8F=AF=E5=B5=8C=E5=A5=97=E5=BE=AA=E7=8E=AF?=
=?UTF-8?q?/=E6=9D=A1=E4=BB=B6=EF=BC=9BOCR=E8=AF=86=E5=88=AB=E9=80=89?=
=?UTF-8?q?=E6=8B=A9=E5=99=A8=E5=8C=B9=E9=85=8D=E5=9B=BE=E7=89=87=E6=96=87?=
=?UTF-8?q?=E5=AD=97=EF=BC=8CRapidOCR=20=E8=B7=A8=E5=B9=B3=E5=8F=B0?=
=?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=90=AB=E5=91=BD=E4=B8=AD=E7=82=B9=E5=87=BB?=
=?UTF-8?q?=EF=BC=89+=20=E5=8A=A8=E4=BD=9C=E7=BC=96=E8=BE=91=E5=99=A8?=
=?UTF-8?q?=E5=B8=83=E5=B1=80=E4=BC=98=E5=8C=96=EF=BC=88=E6=93=8D=E4=BD=9C?=
=?UTF-8?q?=E5=BA=93=E5=88=86=E7=BB=84/=E5=8A=A0=E5=AE=BD=E3=80=81?=
=?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=BC=B9=E7=AA=97=E5=8A=A0=E5=AE=BD=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
core/ocr.py | 79 ++++++++++++++++
requirements.txt | 4 +
static/admin/monitor.js | 177 ++++++++++++++++++++++++-----------
tasks/generic/task.py | 62 +++++++++++-
templates/admin/monitor.html | 17 +++-
5 files changed, 280 insertions(+), 59 deletions(-)
create mode 100644 core/ocr.py
diff --git a/core/ocr.py b/core/ocr.py
new file mode 100644
index 0000000..6e43ec8
--- /dev/null
+++ b/core/ocr.py
@@ -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, ""
diff --git a/requirements.txt b/requirements.txt
index 1a2242c..35c8ef1 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -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
diff --git a/static/admin/monitor.js b/static/admin/monitor.js
index 0763f42..e866ce0 100644
--- a/static/admin/monitor.js
+++ b/static/admin/monitor.js
@@ -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=>'').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 '';}).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 '';}).join('');
+ var toggle=withOcr?' onchange="_stepEditor._toggleOcrMode(this)"':'';
var pick=_can('devices')?'':'';
var test=_can('devices')?'':'';
- return '
';
}
- lib.innerHTML='操作库
'+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+='动作组:按顺序执行下方子步骤一次。把多个步骤打包成一个可复用的整体。
';
h+=' 把本组步骤保存到左侧自定义动作库,方便复用
';
+ }else if(step.type==='if_el'){
+ h+=_selRowHtml(p,
+ ''+
+ ''+
+ '
'+
+ '超时内找到元素 → 执行下方"✅ 找到时"分支;未找到 → 执行"❌ 未找到时"分支。两分支可放任意步骤,也可再嵌套条件判断/循环;'+
+ 'OCR识别可匹配图片/画布里的文字(截屏识别,跨平台)
', true);
}
// 共享:触发概率(所有步骤通用,0~100)
h+='';
@@ -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=0)return true;
+ for(var i=0;i .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);}
}
diff --git a/tasks/generic/task.py b/tasks/generic/task.py
index a1bd12a..7fe6527 100644
--- a/tasks/generic/task.py
+++ b/tasks/generic/task.py
@@ -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))
diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html
index 6a41715..35719ea 100644
--- a/templates/admin/monitor.html
+++ b/templates/admin/monitor.html
@@ -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}