// 步骤编辑器:STEP_LIB/拖拽/参数表单/条件判断/元素抓取/测试此步骤 // ================== 步骤编辑器 ================== var STEP_LIB=[ // 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; _childArrs(s).forEach(_stripIds); }); } function _findLib(type){return STEP_LIB.find(function(s){return s.type===type;});} // 选择器组件(类型下拉 + 值输入 + 抓取元素 + 测试按钮),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 '
'+ '
'+ ''+pick+test+ '
'+(extra||''); } // 加载自定义动作库 function _loadCustomActions(){ fetch('/api/custom_actions').then(function(r){return r.json();}).then(function(res){ if(res.ok)_customActions=res.actions||[]; if(typeof _stepEditor!=='undefined')_stepEditor._renderLib(); }).catch(function(){}); } var _stepEditor={ _steps:[], _selected:[], _dragSuppressClick:false, init:function(steps,maxDuration,draftKey,canvasId,libId){ this._draftKey=draftKey||'new'; this._selected=[]; this._updatePackBtns(); this._dragSuppressClick=false; // 可指定编辑容器(任务编辑器 vs 动作编辑器),默认任务编辑器 this._canvas=document.getElementById(canvasId||'se-canvas'); this._lib=document.getElementById(libId||'se-lib'); // 嵌套结构数组注册表:arr 唯一 id → 数组引用(修复多层级循环内移动错位) this._arrCounter=0; this._arrById={}; this._arrIdMap=new WeakMap(); // 恢复上次未保存的草稿(实时保存);没有则用传入的步骤 var draft=this._loadDraft(); if(draft&&Array.isArray(draft)){ this._steps=draft; }else{ this._steps=steps.length?JSON.parse(JSON.stringify(steps)):[]; } this._ensureIds(this._steps); this._renderLib(); this._renderCanvas(); _loadCustomActions(); // 实时同步:画布内任何输入(含下拉/勾选)都同步回 _steps 并自动保存 var canvas=this._canvas; if(canvas&&!canvas.dataset.seBound){ canvas.dataset.seBound='1'; var self=this; canvas.addEventListener('input',function(){self._syncFromDom();}); } if(draft&&Array.isArray(draft)){ showToast('已恢复上次未保存的步骤编辑','success'); } // max_duration 输入框(只在任务编辑器注入,动作编辑不需要) var head=document.querySelector('#step-editor .se-head'); if(head&&!document.getElementById('f-max_duration')){ var md=document.createElement('span'); md.style.cssText='font-size:11px;display:flex;align-items:center;gap:4px'; md.innerHTML='运行时长上限(秒,0=不限) '; head.appendChild(md); } }, _ensureIds:function(steps){ var self=this; steps.forEach(function(s){ if(!s.id)s.id=_newStepId(); _childArrs(s).forEach(function(a){self._ensureIds(a);}); }); }, _renderLib:function(){ var lib=this._lib; if(!lib)return; // 未初始化编辑器(如仅加载动作列表时)跳过 // 按分类分组展示(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=''; if(_customActions.length){ customHtml='
自定义动作
'; customHtml+=_customActions.map(function(a){ return '
'+ ''+(a.icon||'📦')+''+esc(a.name)+''+ '
'; }).join(''); customHtml+='
'; } lib.innerHTML=items+customHtml; var self=this; // 绑定拖拽 + 点击(系统步骤)—— 用 _dragSuppressClick 防止拖拽后重复触发 click lib.querySelectorAll('.lib-item[data-type]').forEach(function(item){ var type=item.dataset.type; item.addEventListener('dragstart',function(e){ self._dragSuppressClick=true; e.dataTransfer.setData('text/plain','type:'+type); e.dataTransfer.effectAllowed='copy'; }); item.addEventListener('click',function(e){ if(self._dragSuppressClick){self._dragSuppressClick=false;return;} self.addStep(type); }); }); // 绑定拖拽 + 点击(自定义动作) lib.querySelectorAll('.lib-item[data-action-id]').forEach(function(item){ var actionId=item.dataset.actionId; item.addEventListener('dragstart',function(e){ self._dragSuppressClick=true; e.dataTransfer.setData('text/plain','action:'+actionId); e.dataTransfer.effectAllowed='copy'; }); item.addEventListener('click',function(e){ if(e.target.classList.contains('del-btn'))return; if(self._dragSuppressClick){self._dragSuppressClick=false;return;} self.addCustomAction(actionId); }); }); }, _renderCanvas:function(){ if(!this._canvas)return; this._renderStepList(this._canvas,this._steps); }, _renderStepList:function(container,steps){ var self=this; if(!steps.length){ container.innerHTML='
拖拽左侧操作到这里
或点击操作库直接添加
'; this._bindDropZone(container,steps); // 空时也要能接收拖入 return; } container.innerHTML=''; steps.forEach(function(step,idx){ container.appendChild(self._renderCard(step,idx,steps)); }); this._bindDropZone(container,steps); }, _renderCard:function(step,idx,parentArr){ var self=this; var lib=_findLib(step.type)||{icon:'?',label:step.type}; var card=document.createElement('div'); 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||''; card.dataset.stepType=step.type; if(this._selected&&this._selected.indexOf(step.id)>=0)card.classList.add('selected'); // head var head=document.createElement('div'); head.className='sc-head'; head.innerHTML=''+lib.icon+''+ ''+esc(step.label||lib.label)+''+ ''+step.type+''+ ''+ ''+ ''+ ''+ ''+ ''+ ''+ ''; head.addEventListener('click',function(e){ if(e.target.tagName==='BUTTON')return; self.toggleExpand(head.querySelector('button')); }); card.appendChild(head); // body(参数表单) var body=document.createElement('div'); body.className='sc-body'; body.innerHTML=this._renderParams(step); card.appendChild(body); // 拖拽排序 card.addEventListener('dragstart',function(e){ e.dataTransfer.setData('text/plain','move:'+idx+':'+self._arrId(parentArr)); e.dataTransfer.effectAllowed='move'; card.classList.add('dragging'); }); card.addEventListener('dragend',function(){card.classList.remove('dragging');}); card.addEventListener('dragover',function(e){ e.preventDefault(); card.classList.add('drag-over'); }); card.addEventListener('dragleave',function(){card.classList.remove('drag-over');}); card.addEventListener('drop',function(e){ e.preventDefault(); e.stopPropagation(); // 关键:阻止冒泡到外层卡片/容器,避免重复添加 card.classList.remove('drag-over'); self._handleDrop(e,parentArr,idx); }); // loop/group 子步骤 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){ // 用数组长度作为标识(顶层是 _steps,子层是某 loop 的 children) // 这里简化:顶层 path='root',子层 path='loop:' return arr===this._steps?'root':'child'; }, _renderParams:function(step){ var p=step.params||{}; var h=''; if(step.type==='open_app'){ h+='
'; h+='
'; h+='
'; }else if(step.type==='stop_app'){ h+='
'; h+='
强制结束该 App(am force-stop),清除后台进程,下次打开为冷启动。常配合"打开App"使用:结束→重新打开
'; }else if(step.type==='screen_on'){ h+='
亮屏(息屏时唤醒并滑动解锁)。任务执行前用——息屏状态下 u2 无法点击操作
'; }else if(step.type==='screen_off'){ h+='
息屏。任务结束后用,避免长时间亮屏导致烧屏/发热
'; }else if(step.type==='keep_screen'){ h+='
'; h+='
长任务(如养号看视频几小时)放"保持亮屏"防屏幕超时熄灭;任务结束前放"恢复自动息屏"
'; }else if(step.type==='swipe'){ h+='
'; h+='
'; h+='
'; }else if(step.type==='click'){ h+=_selRowHtml(p,'
'); }else if(step.type==='long_click'){ h+=_selRowHtml(p,'
'+ '
'); }else if(step.type==='swipe_until'){ h+=_selRowHtml(p,'
'+ '
'+ '
'+ '
上滑/下滑直到目标元素出现(每次滑动后检查一次),找不到则跳过继续
'); }else if(step.type==='wait_el'){ h+=_selRowHtml(p,'
'+ '
等待目标元素出现(替代固定秒数等待),超时则继续
'); }else if(step.type==='click_xy'){ h+='
'+ '
'+ '
屏幕百分比坐标,50/50=屏幕中心。无选择器时的兜底点击
'; }else if(step.type==='key_event'){ h+='
'; h+='
返回键用于退出评论/返回上一页,比结束App轻量
'; }else if(step.type==='wait'){ h+='
'; h+='
'; }else if(step.type==='input_text'){ const im=p.mode==='fixed'?'fixed':'random'; h+='
'; h+='
'; h+='
'; h+='
'; h+='
需先用"点击元素"步骤定位到输入框,本步骤只负责输入文字
'; }else if(step.type==='loop'){ const lm=p.loop_mode==='time'?'time':'rounds'; h+='
'+ '
'+ '
'; h+='
将下方子步骤(拖拽到本卡片内)循环执行指定轮次或时长;按时间时配合"运行时长上限"看预计结束时间
'; }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=隔几次才触发一次,适合"偶尔点赞")
'; // 可编辑 label h+='
'; return h; }, _bindDropZone:function(container,arr){ // 常驻容器(#se-canvas)多次重渲染会累积 drop 监听 → 拖一次添加多个。 // 用 dataset 标记只绑定一次;数组引用用 WeakMap 每次更新。 if(!this._dropArrs)this._dropArrs=new WeakMap(); this._dropArrs.set(container,arr); if(container.dataset.dropBound)return; container.dataset.dropBound='1'; var self=this; // 画布空白处也能接收 drop,拖入时高亮提示 container.addEventListener('dragover',function(e){ e.preventDefault(); container.classList.add('drag-over'); }); container.addEventListener('dragleave',function(e){ if(!container.contains(e.relatedTarget))container.classList.remove('drag-over'); }); container.addEventListener('drop',function(e){ e.preventDefault(); e.stopPropagation(); // 阻止冒泡到外层卡片/容器,避免重复添加 container.classList.remove('drag-over'); // 子卡片都会 stopPropagation,能冒泡到这里的 drop 一定落在空白区 // (含 .se-empty 的提示文字),直接追加到数组末尾 self._handleDrop(e,self._dropArrs.get(container),-1); }); }, _handleDrop:function(e,arr,idx){ var self=this; var data=e.dataTransfer.getData('text/plain'); if(!data)return; // drop 处理中同步重建 DOM 会打断拖拽事件,可能被浏览器重发 drop 导致重复添加。 // 延迟到下一帧渲染,保证一次拖入只加一次。 function rerender(){ setTimeout(function(){self._renderCanvas();},0); } if(data.startsWith('move:')){ // 拖拽排序:同层级排序 / 跨层级移入(把步骤拖进循环块、循环套循环) var parts=data.split(':'); var fromIdx=parseInt(parts[1]); var fromPath=parts[2]; var srcArr=this._resolveArr(fromPath); if(srcArr){ if(srcArr===arr){ // 同层级排序 if(fromIdx===idx)return; this._syncFromDom(); var moved=arr.splice(fromIdx,1)[0]; if(idx<0)arr.push(moved); else if(fromIdx=arr.length)arr.push(moved2); else arr.splice(idx,0,moved2); rerender(); } } } }else if(data.startsWith('action:')){ // 从自定义动作库拖入 → 展开为 group 步骤 var actionId=data.substring(7); var action=_customActions.find(function(a){return a.id===actionId;}); if(action&&action.steps&&action.steps.length){ var ns=this._makeStep({type:'group',label:action.name,icon:action.icon||'📦',params:{children:[]}}); ns.label=action.name; ns.params.children=JSON.parse(JSON.stringify(action.steps)); _stripIds(ns.params.children); // 重新生成 id,避免同一动作拖入多次后 id 冲突 this._ensureIds(ns.params.children); if(idx<0||idx>=arr.length)arr.push(ns); else arr.splice(idx,0,ns); rerender(); } }else if(data.startsWith('type:')){ // 从系统操作库拖入 var type=data.substring(5); var lib=_findLib(type); if(lib){ var ns2=this._makeStep(lib); if(idx<0||idx>=arr.length)arr.push(ns2); else arr.splice(idx,0,ns2); rerender(); } } }, _makeStep:function(lib){ return {id:_newStepId(),type:lib.type,label:lib.label,params:JSON.parse(JSON.stringify(lib.params))}; }, // 测试此步骤:在选中设备上试执行当前步骤,验证选择器 _testStep:function(btn){ var card=btn.closest('.step-card'); if(!card)return; var type=card.dataset.stepType||''; var params={}; card.querySelectorAll('[data-key]').forEach(function(inp){ if(inp.type==='checkbox')params[inp.dataset.key]=inp.checked; else if(inp.type==='number')params[inp.dataset.key]=parseFloat(inp.value)||0; else params[inp.dataset.key]=inp.value; }); this._pendingTest={type:type,label:'测试',params:params}; this._showTestDevicePicker(); }, _showTestDevicePicker:function(){ document.getElementById('test-modal-title').textContent='测试步骤 - 选择设备'; document.getElementById('test-modal-body').innerHTML= '
选择在哪个设备上试执行
加载中...
'; document.getElementById('test-overlay').classList.add('show'); var self=this; apiGet('/api/uiauto/devices').then(function(r){ var list=document.getElementById('test-device-list'); if(!list)return; if(!r||!r.ok){list.innerHTML='
'+esc((r&&r.error)||'获取设备失败')+'
';return;} list.innerHTML=(r.devices||[]).map(function(d){ return '
'+ '
📱
'+ '
'+esc(d.serial)+'
'+ '
'+esc(d.model||d.product||'')+'
'; }).join('')||'
无可用设备
'; }); }, _runTestStep:async function(serial){ var step=this._pendingTest; var body=document.getElementById('test-modal-body'); if(!step||!body)return; body.innerHTML='
正在 '+esc(serial)+' 上执行(adb + u2 连接约需几秒)...
请稍候,期间不要操作该设备
'; var r=await apiPost('/api/steps/test',{serial:serial,step:step}); if(!r)return; var icon=r.result==='命中'?'✅':(r.result==='未找到'?'❌':'▶'); body.innerHTML='
'+icon+' '+esc(r.result||'已执行')+''+ '
'+esc(r.msg||'')+'
'+ '
'; }, addStep:function(type){ var lib=_findLib(type); if(!lib)return; this._syncFromDom(); this._steps.push(this._makeStep(lib)); this._renderCanvas(); }, addCustomAction:function(actionId){ var action=_customActions.find(function(a){return a.id===actionId;}); if(!action||!action.steps||!action.steps.length)return; this._syncFromDom(); var ns=this._makeStep({type:'group',label:action.name,icon:action.icon||'📦',params:{children:[]}}); ns.label=action.name; ns.params.children=JSON.parse(JSON.stringify(action.steps)); _stripIds(ns.params.children); // 重新生成 id,避免同一动作拖入多次后 id 冲突 this._ensureIds(ns.params.children); this._steps.push(ns); this._renderCanvas(); }, delCustomAction:function(actionId){ if(!confirm('确定删除这个自定义动作?'))return; apiDelete('/api/custom_actions/'+actionId).then(function(res){ if(!res)return; if(res.ok){_loadCustomActions();showToast('已删除','success');} else showToast(res.error||'删除失败','error'); }); }, toggleSelect:function(stepId){ this._syncFromDom(); if(!this._selected)this._selected=[]; var i=this._selected.indexOf(stepId); if(i>=0)this._selected.splice(i,1); else this._selected.push(stepId); this._renderCanvas(); this._updatePackBtns(); }, // 同步所有"打包选中步骤"按钮的可用状态(任务编辑器 + 动作编辑器) _updatePackBtns:function(){ var n=this._selected&&this._selected.length; ['se-pack-btn','se-pack-btn2'].forEach(function(id){ var bar=document.getElementById(id); if(bar)bar.disabled=n<1; }); }, saveAsCustomAction:function(stepId){ // 从 group 步骤的"存为自定义动作"按钮调用时传入该 group 的 id: // 优先收集该 group 的子步骤;否则收集 ☑ 选中的步骤 var stepsToSave=[]; if(stepId){ var group=this._findStepById(this._steps,stepId); if(group&&group.params&&group.params.children&&group.params.children.length){ stepsToSave=JSON.parse(JSON.stringify(group.params.children)); _stripIds(stepsToSave); } } if(!stepsToSave.length&&this._selected&&this._selected.length){ // 从选中步骤收集 stepsToSave=this._collectByIds(this._steps,this._selected); } if(!stepsToSave.length){ showToast('请先用 ☑ 选中要打包的步骤','error'); return; } var name=prompt('输入动作名称:','我的动作'); if(!name)return; var self=this; apiPost('/api/custom_actions',{name:name,steps:stepsToSave}) .then(function(res){ if(!res)return; if(res.ok){ showToast('已保存到自定义动作库','success'); self._selected=[]; _loadCustomActions(); self._renderCanvas(); self._updatePackBtns(); }else showToast(res.error||'保存失败','error'); }); }, // 在嵌套结构中按 id 查找步骤(group/loop 的子步骤、if_el 的分支步骤也找得到) _findStepById:function(steps,id){ var self=this; for(var i=0;i=0){ var copy=JSON.parse(JSON.stringify(s)); _stripIds([copy]); result.push(copy); // 容器选中时子步骤已包含在拷贝里,不再单独收集 }else{ _childArrs(s).forEach(function(a){ result=result.concat(self._collectByIds(a,ids)); }); } }); return result; }, toggleExpand:function(btn){ var card=btn.closest('.step-card'); card.classList.toggle('expanded'); }, _toggleInputMode:function(sel){ var body=sel.closest('.sc-body'); var mode=sel.value; body.querySelectorAll('[data-mode]').forEach(function(g){ g.style.display=g.dataset.mode===mode?'block':'none'; }); }, _toggleLoopMode:function(sel){ var body=sel.closest('.sc-body'); var mode=sel.value; body.querySelectorAll('[data-lmode]').forEach(function(g){ 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'; if(!this._arrIdMap)this._arrIdMap=new WeakMap(); if(!this._arrById)this._arrById={}; if(!this._arrIdMap.has(arr)){ var id='arr_'+(++this._arrCounter); this._arrIdMap.set(arr,id); this._arrById[id]=arr; } return this._arrIdMap.get(arr); }, _resolveArr:function(path){ if(path==='root')return this._steps; return this._arrById&&this._arrById[path]; }, // 判断 step 的子孙数组中是否包含 arr(防循环块套进自己导致渲染无限递归) _containsArr:function(step,arr){ var arrs=_childArrs(step); if(!arrs.length)return false; if(arrs.indexOf(arr)>=0)return true; for(var i=0;i=arr.length)return; var tmp=arr[ni];arr[ni]=arr[idx];arr[idx]=tmp; this._renderCanvas(); }, duplicate:function(idx,path){ this._syncFromDom(); var arr=this._resolveArr(path); if(!arr)return; var copy=JSON.parse(JSON.stringify(arr[idx])); copy.id=_newStepId(); this._ensureIds([copy]); arr.splice(idx+1,0,copy); this._renderCanvas(); }, remove:function(idx,path){ this._syncFromDom(); var arr=this._resolveArr(path); if(!arr)return; arr.splice(idx,1); this._renderCanvas(); }, collect:function(){ // 先把当前 DOM 上的参数值同步回 _steps var self=this; this._syncParams(this._canvas,this._steps); return JSON.parse(JSON.stringify(this._steps)); }, validate:function(steps){ // 校验步骤配置,返回警告列表 var warnings=[]; function check(arr,prefix){ arr.forEach(function(s,i){ var name=prefix?(prefix+'.'+(i+1)):('步骤'+(i+1)); var label=s.label||s.type; if(s.type==='click'){ if(!s.params.selector_value){ warnings.push(name+' "'+label+'": 选择器值为空,点击步骤会跳过。请用"抓取元素"按钮选择目标。'); } }else if(s.type==='long_click'||s.type==='swipe_until'||s.type==='wait_el'){ if(!s.params.selector_value){ warnings.push(name+' "'+label+'": 选择器值为空,该步骤会跳过。请用"抓取元素"或"测试"按钮选择目标。'); } }else if(s.type==='click_xy'){ const x=parseFloat(s.params.x), y=parseFloat(s.params.y); if(isNaN(x)||isNaN(y)||x<0||x>100||y<0||y>100){ warnings.push(name+' "'+label+'": 坐标需在 0-100 之间(屏幕百分比)。'); } }else if(s.type==='open_app'||s.type==='stop_app'){ if(!s.params.package){ warnings.push(name+' "'+label+'": 包名为空,无法'+(s.type==='open_app'?'启动':'结束')+'App'); } }else if(s.type==='input_text'){ if(s.params.mode==='fixed'&&!s.params.fixed_text){ warnings.push(name+' "'+label+'": 指定文字为空'); }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+'": 条件判断的选择器为空,会直接走"未找到"分支。'); } } _childArrs(s).forEach(function(a){ if(a.length)check(a,name+' 子步骤'); }); }); } check(steps,''); return warnings; }, _syncParams:function(container,steps){ var self=this; var cards=container.querySelectorAll(':scope > .step-card'); cards.forEach(function(card,i){ if(!steps[i])return; // 只读本卡片直接子级的 .sc-body,避免把嵌套子步骤的输入误写进本步骤 params var inputs=card.querySelectorAll(':scope > .sc-body [data-key]'); inputs.forEach(function(inp){ var key=inp.dataset.key; if(inp.type==='checkbox'){ steps[i].params[key]=inp.checked; }else if(inp.type==='number'){ steps[i].params[key]=parseFloat(inp.value)||0; }else{ if(key==='_label'){ steps[i].label=inp.value||steps[i].label; }else{ steps[i].params[key]=inp.value; } } }); // 递归子步骤: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,并自动保存草稿(操作前调用防丢失) _syncFromDom:function(){ this._syncParams(this._canvas,this._steps); this._autosave(); }, _autosave:function(){ try{localStorage.setItem('se_draft_'+this._draftKey,JSON.stringify(this._steps));}catch(e){} }, _loadDraft:function(){ try{ var d=localStorage.getItem('se_draft_'+this._draftKey); if(d)return JSON.parse(d); }catch(e){} return null; }, clearDraft:function(){ try{localStorage.removeItem('se_draft_'+this._draftKey);}catch(e){} }, // ================== 元素抓取 ================== openPicker:function(btn){ var input=btn.previousElementSibling; this._elTargetInput=input; // 直接弹设备选择页(独立模态框,不影响任务编辑窗口) this._showDevicePicker(); }, // 步骤1:设备选择页 _showDevicePicker:function(){ document.getElementById('el-picker-title').textContent='抓取元素 - 选择设备'; document.getElementById('el-picker-body').innerHTML='
选择要抓取元素的设备
加载中...
'; document.getElementById('el-picker-footer').innerHTML=''; document.getElementById('el-picker-overlay').classList.add('show'); fetch('/api/uiauto/devices').then(function(r){return r.json();}).then(function(res){ var list=document.getElementById('el-device-list'); if(!res.ok){list.innerHTML='
'+esc(res.error||'获取设备失败')+'
';return;} var devices=res.devices||[]; if(!devices.length){list.innerHTML='
uiauto2 未发现已连接设备
';return;} list.innerHTML=devices.map(function(d){ return '
'+ '
📱
'+ '
'+esc(d.serial)+'
'+ '
'+esc(d.model||d.product||'')+'
'+ ''+esc(d.status||'device')+'
'; }).join(''); }).catch(function(e){ document.getElementById('el-device-list').innerHTML='
请求失败: '+esc(e)+'
'; }); }, // 步骤2:选中设备后,显示截图 + 元素树 _showElPicker:function(serial){ var self=this; self._elSerial=serial; document.getElementById('el-picker-title').textContent='抓取元素 - '+serial; document.getElementById('el-picker-body').innerHTML= '
'+ '
'+ '
'+ ''+ '加载中...'+ '
'+ '
'+ '
正在获取截图...
'+ '
'+ '
'+ '
'+ ''+ '
加载元素树...
'+ '
'+ '
'; document.getElementById('el-picker-footer').innerHTML= ''+ ''; // 加载截图和元素树(_refreshShot 内部同时刷新两者) self._refreshShot(); }, // 拉取元素树(进入页面和刷新时共用;带时间戳防缓存) _loadElData:function(){ var serial=this._elSerial; if(!serial)return; var listEl=document.getElementById('el-list'); if(listEl)listEl.innerHTML='
加载元素树...
'; var self=this; fetch('/api/uiauto/elements?serial='+encodeURIComponent(serial)+'&_='+Date.now()) .then(function(r){return r.json();}) .then(function(data){ var le=document.getElementById('el-list'); if(!le)return; if(!data.ok){ le.innerHTML='
'+esc(data.error||'抓取失败')+'
'; return; } self._elData=data.elements||[]; self._renderEl(self._elData); // 刷新后保持搜索过滤状态 var search=document.getElementById('el-search'); if(search&&search.value)self._filterEl(search.value); self._renderOverlays(); }) .catch(function(e){ var le=document.getElementById('el-list'); if(le)le.innerHTML='
请求失败: '+esc(e)+'
'; }); }, // 刷新截图 _refreshShot:function(){ var serial=this._elSerial; if(!serial)return; var container=document.getElementById('el-shot-container'); if(!container)return; container.innerHTML='
正在获取截图...
'; var info=document.getElementById('el-shot-info'); if(info)info.textContent='加载中...'; var self=this; var img=new Image(); img.onload=function(){ container.innerHTML=''; container.appendChild(img); var infoEl=document.getElementById('el-shot-info'); if(infoEl)infoEl.textContent=img.naturalWidth+'x'+img.naturalHeight; // 截图加载完后重新渲染元素边界框 self._renderOverlays(); }; img.onerror=function(){ container.innerHTML='
截图加载失败
'; var infoEl=document.getElementById('el-shot-info'); if(infoEl)infoEl.textContent='失败'; }; img.src='/api/uiauto/screenshot?serial='+encodeURIComponent(serial)+'&_='+Date.now(); // 同时重新拉取元素树——只刷截图会导致"截图是新的、元素框是旧的"错位 this._loadElData(); }, // 在截图上渲染元素边界框(可点击) _renderOverlays:function(){ var container=document.getElementById('el-shot-container'); var img=container.querySelector('img'); if(!img||!this._elData)return; var self=this; function draw(){ // 清除旧 overlay 和旧点击处理 container.querySelectorAll('.el-shot-overlay').forEach(function(o){o.remove();}); container.onclick=null; var scaleX=img.clientWidth/img.naturalWidth; var scaleY=img.clientHeight/img.naturalHeight; // 图片在 container 里的偏移 var imgOffsetX=img.offsetLeft; var imgOffsetY=img.offsetTop; var imgW=img.naturalWidth, imgH=img.naturalHeight; self._elData.forEach(function(el,i){ if(!el.bounds)return; // bounds 格式 "[x1,y1][x2,y2]" var m=el.bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/); if(!m)return; var x1=parseInt(m[1]),y1=parseInt(m[2]),x2=parseInt(m[3]),y2=parseInt(m[4]); var w=(x2-x1)*scaleX; var h=(y2-y1)*scaleY; if(w<4||h<4)return; // 太小不画 // 跳过占满屏幕的容器:面积>90%屏幕,没有选择价值,还会盖住小元素 if((x2-x1)*(y2-y1) > imgW*imgH*0.9)return; var ov=document.createElement('div'); ov.className='el-shot-overlay'; ov.setAttribute('data-idx',i); ov.style.left=(imgOffsetX+x1*scaleX)+'px'; ov.style.top=(imgOffsetY+y1*scaleY)+'px'; ov.style.width=w+'px'; ov.style.height=h+'px'; ov.title=el.name||el.class||''; container.appendChild(ov); }); // 点击截图:按 bounds 命中测试,选中最小的(最深的)元素, // 而不是被 DOM 层叠中的大容器/大兄弟挡住 container.onclick=function(e){ var im=container.querySelector('img'); if(!im)return; var r=im.getBoundingClientRect(); var px=(e.clientX-r.left)/scaleX; // 换算回原始分辨率坐标 var py=(e.clientY-r.top)/scaleY; var best=-1,bestArea=Infinity; self._elData.forEach(function(el,i){ var mb=el.bounds&&el.bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/); if(!mb)return; var a=parseInt(mb[1]),b=parseInt(mb[2]),c=parseInt(mb[3]),dd=parseInt(mb[4]); if(px>=a&&px<=c&&py>=b&&py<=dd){ var area=(c-a)*(dd-b); if(area=0)self._highlightEl(best); }; } if(img.complete&&img.naturalWidth>0)draw(); else img.onload=draw; }, // 高亮元素(截图框 + 列表项联动) _highlightEl:function(i){ // 清除旧高亮(只在 el-picker-overlay 范围内查找) var picker=document.getElementById('el-picker-overlay'); picker.querySelectorAll('.el-shot-overlay.active').forEach(function(o){o.classList.remove('active');}); picker.querySelectorAll('.el-picker-item.active').forEach(function(o){o.classList.remove('active');}); // 高亮当前(overlay 用 data-idx 查找,渲染时跳过了全屏容器,索引不再一一对应) var overlay=picker.querySelector('.el-shot-overlay[data-idx="'+i+'"]'); if(overlay)overlay.classList.add('active'); // 列表项用 data-idx 查找(过滤后顺序可能不同) var item=picker.querySelector('.el-picker-item[data-idx="'+i+'"]'); if(item){ item.classList.add('active'); item.scrollIntoView({block:'nearest',behavior:'smooth'}); } // 信息栏显示将填入的选择器,确认是否精确命中目标 var infoEl=document.getElementById('el-shot-info'); if(infoEl&&this._elData&&this._elData[i]){ var sg=this._elData[i].suggested||{}; infoEl.textContent='已选中: '+(sg.type||'')+' '+(sg.value||''); } }, _renderEl:function(list){ var self=this; var html=list.map(function(el){ var idx=self._elData.indexOf(el); var attrs=[]; if(el.resource_id)attrs.push('id:'+esc(el.resource_id)+''); if(el.text)attrs.push('text:'+esc(el.text)+''); if(el.description)attrs.push('desc:'+esc(el.description)+''); var indent=' '.repeat(Math.min(el.depth||0,6)); var sg=el.suggested||{}; return '
'+ ''+(el.depth||0)+''+ '
'+indent+esc(el.name||el.class||'')+'
'+ '
'+attrs.join('')+'
'+ ''+esc(sg.type)+''+ (sg.indexed?' #'+sg.occ+'/'+sg.total+'':'')+ (sg.broad?' ⚠宽泛':'')+ '
'; }).join(''); document.getElementById('el-list').innerHTML=html||'
无元素
'; }, _filterEl:function(kw){ if(!kw){this._renderEl(this._elData);this._renderOverlays();return;} kw=kw.toLowerCase(); var f=this._elData.filter(function(el){ return (el.resource_id&&el.resource_id.toLowerCase().includes(kw))|| (el.text&&el.text.toLowerCase().includes(kw))|| (el.description&&el.description.toLowerCase().includes(kw)); }); this._renderEl(f); }, _pickEl:function(i){ var el=this._elData[i]; if(!el||!this._elTargetInput)return; // 填入推荐选择器值,并切换选择器类型 this._elTargetInput.value=el.suggested.value; var sel=this._elTargetInput.closest('.sc-body').querySelector('[data-key="selector_type"]'); if(sel)sel.value=el.suggested.type; // 程序赋值不会触发 input 事件,手动同步回 _steps 防丢失 this._syncFromDom(); // 只关闭抓取元素弹窗,不影响任务编辑窗口 closeElPicker(); showToast('已填入: '+el.suggested.type+'='+el.suggested.value,'success'); } }; async function saveTask(jobId){ const name=document.getElementById('f-name').value.trim(); if(!name){showToast('任务名不能为空','error');return;} let params={}; const tt=document.getElementById('f-task_type').value; if(tt==='generic_steps'){ // 通用步骤任务:从步骤编辑器收集 const steps=_stepEditor.collect(); if(!steps.length){showToast('请至少添加一个步骤','error');return;} // 校验:click 步骤的 selector_value 不能为空 var warnings=_stepEditor.validate(steps); if(warnings.length){ if(!confirm('步骤配置有问题:\n\n'+warnings.join('\n')+'\n\n确定要保存吗?'))return; } const mdEl=document.getElementById('f-max_duration'); params={max_duration:mdEl?parseInt(mdEl.value)||0:0,steps:steps}; }else{ try{params=JSON.parse(document.getElementById('f-params').value||'{}');} catch(e){showToast('参数 JSON 格式错误','error');return;} } const targetMode=document.getElementById('f-target_mode').value; const target={mode:targetMode}; if(targetMode==='group')target.group_name=document.getElementById('f-target_group').value; if(targetMode==='serial')target.serial=document.getElementById('f-target_serial').value; const schedMode=document.getElementById('f-schedule_mode').value; let schedule={mode:'once'}; if(schedMode==='cron'){ schedule={mode:'cron',cron:buildCronFromFreq()}; }else if(schedMode==='cron_stop'){ const st=(document.getElementById('f-schedule_stop_time').value||'18:00').split(':'); schedule={mode:'cron_stop', cron:buildCronFromFreq(), stop_cron:(parseInt(st[1])||0)+' '+(parseInt(st[0])||0)+' * * *'}; } // 运行窗口:窗口外(定时触发 + 手动执行)不启动 if(document.getElementById('f-window_enable').checked){ schedule.window={ start:document.getElementById('f-window_start').value||'09:00', end:document.getElementById('f-window_end').value||'18:00', }; } const data={ name:name, task_type:document.getElementById('f-task_type').value, enabled:document.getElementById('f-enabled').checked, target:target, params:params, schedule:schedule, retry:{max_attempts:Math.max(1,parseInt(document.getElementById('f-retry_attempts').value)||1), delay:Math.max(10,parseInt(document.getElementById('f-retry_delay').value)||60)} }; if(jobId){ const r=await apiPut('/api/jobs/'+jobId,data); handleResult(r,()=>{_stepEditor.clearDraft();closeModal();loadTasks();}); }else{ const r=await apiPost('/api/jobs',data); handleResult(r,()=>{_stepEditor.clearDraft();closeModal();loadTasks();}); } } async function deleteTask(jobId){ if(!confirm('确认删除此任务吗?'))return; const r=await apiDelete('/api/jobs/'+jobId); handleResult(r,()=>loadTasks()); }