';
// 可编辑 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){
// 参数 = STEP_LIB 出厂值 ← 用户在「动作配置」里配的默认值(有才覆盖)
var params=JSON.parse(JSON.stringify(lib.params));
var d=_stepDefaults&&_stepDefaults[lib.type];
if(d)Object.keys(d).forEach(function(k){ if(d[k]!==undefined)params[k]=d[k]; });
return {id:_newStepId(),type:lib.type,label:lib.label,params:params};
},
// 递归找步骤对象(按 id)——写入录音必须改 _steps,不能只改 DOM
_findStep:function(id,arr){
var self=this, found=null;
(arr||this._steps).forEach(function(st){
if(found)return;
if(st.id===id){found=st;return;}
_childArrs(st).forEach(function(a){ if(!found)found=self._findStep(id,a); });
});
return found;
},
// 给某个「录制手势」步骤录制轨迹(src: 'phone' 手机上录 / 'web' 网页上录)
recordInto:function(btn,src){
var card=btn.closest('.step-card');
var step=card?this._findStep(card.dataset.stepId):null;
if(!step){showToast('找不到这个步骤','error');return;}
if(typeof openRecorder!=='function'){showToast('录制器未加载','error');return;}
var self=this;
openRecorder({mode:'gesture', autoStartPhone: src==='phone', onDone:function(p,from){
step.params={points:p.points,speed:p.speed||1.0};
var dur=p.points.length?p.points[p.points.length-1][2]:0;
step.label='录制手势 '+(from==='phone'?'手机':'网页')+' '+p.points.length+'点';
self._renderCanvas();
showToast('已录到 '+(p.points.length)+' 个点 / '+(dur/1000).toFixed(2)+' 秒','success');
}});
},
clearGesture:function(btn){
var card=btn.closest('.step-card');
var step=card?this._findStep(card.dataset.stepId):null;
if(!step){return;}
step.params={points:[],speed:1.0};
this._renderCanvas();
showToast('已清空录制的轨迹','success');
},
// 测试此步骤:在选中设备上试执行当前步骤,验证选择器
_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='
';
});
},
_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 时显示"命中后点击"开关
_condTypeChanged:function(sel){
// 条件类型一变,"值"控件也要跟着换(元素值输入框 / 屏幕亮熄下拉 / 包名输入),
// 整卡重渲染最省心。先把 DOM 的值同步回 _steps,否则重渲染会丢掉刚改的东西。
this._syncFromDom();
var self=this;
setTimeout(function(){self._renderCanvas();},0);
},
// 给数组分配唯一 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==='clipboard'){
if(!s.params.text){
warnings.push(name+' "'+label+'": 注入内容为空,步骤会跳过。');
}
}else if(s.type==='gesture'){
if(!(s.params.points||[]).length){
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='
';
}).join('');
},
// 颜色页:鼠标位置的像素色 + 选中元素的主色
_pixelAt:function(x,y){
try{
var c=this._colorCanvas, img=document.getElementById('el-shot-container').querySelector('img');
if(!c||!img)return null;
if(c.width!==img.naturalWidth){c.width=img.naturalWidth;c.height=img.naturalHeight;}
var ctx=c.getContext('2d');
ctx.drawImage(img,0,0);
var d=ctx.getImageData(Math.max(0,Math.min(c.width-1,x)),Math.max(0,Math.min(c.height-1,y)),1,1).data;
return '#'+[d[0],d[1],d[2]].map(function(v){return ('0'+v.toString(16)).slice(-2);}).join('');
}catch(e){return null;}
},
_updatePixel:function(x,y){
var box=document.getElementById('el-color-list');
if(!box)return;
var col=this._pixelAt(Math.round(x),Math.round(y));
if(!col)return;
box.innerHTML='
'+
'光标处 '+esc(col)+'
'+
(box.dataset.dom||'');
},
_renderColors:function(i){
var box=document.getElementById('el-color-list');
if(!box)return;
var el=(this._elData||[])[i];
var dom='';
if(el&&el.bounds){
var m=el.bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
if(m){
var x=Math.round((+m[1]+ +m[3])/2), y=Math.round((+m[2]+ +m[4])/2);
var c=this._pixelAt(x,y);
if(c) dom='
'+
'选中元素中心 '+esc(c)+'
';
}
}
box.dataset.dom=dom;
box.innerHTML=dom||'
把鼠标移到左侧图上取色
';
},
// 一次取齐:截图 + 元素树(后端用原生 u2 背靠背取)
// 为什么不再分两个接口:截图与元素树分两次拿,中间隔着 dump 本身的 1.3~1.8 秒,
// 界面只要在动(信息流/视频/加载),框就会落在旧位置上("抓取错位")。
_loadSnapshot:function(){
var serial=this._elSerial;
if(!serial)return;
var container=document.getElementById('el-shot-container');
var info=document.getElementById('el-shot-info');
var old=document.querySelector('.el-shot-warn');
if(old)old.remove();
if(container)container.innerHTML='
正在抓取(截图 + 元素树一次取齐)…
';
if(info)info.textContent='抓取中…';
var self=this;
fetch('/api/uiauto/snapshot?serial='+encodeURIComponent(serial)+'&_='+Date.now())
.then(function(r){return r.json();})
.then(function(d){
if(!d.ok){
if(container)container.innerHTML='