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 '
'+ + return '
'+ '
'+ ''+pick+test+ '
'+(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 '
'+ - ''+s.icon+''+s.label+'
'; + // 按分类分组展示(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 '
'+ + ''+s.icon+''+s.label+'
'; + }).join(''); + return '
'+c[1]+'
'+list+'
'; }).join(''); // 自定义动作区 var customHtml=''; @@ -1064,7 +1087,7 @@ var _stepEditor={ }).join(''); customHtml+='
'; } - 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+='
0~100,<100 表示按概率执行(如 30=隔几次才触发一次,适合"偶尔点赞")
'; @@ -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}