Files
auto_control/static/admin/editor.js
T

1097 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 步骤编辑器: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 '<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"'+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||'');
}
// 加载自定义动作库
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=不限) <input type="number" id="f-max_duration" value="'+(maxDuration||0)+'" min="0" style="width:70px;padding:2px 6px;border:1px solid var(--border);border-radius:4px;font-size:12px">';
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 '<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){
customHtml='<div class="lib-section"><div class="lib-title">自定义动作</div>';
customHtml+=_customActions.map(function(a){
return '<div class="lib-item custom" draggable="true" data-action-id="'+a.id+'">'+
'<span class="ico">'+(a.icon||'📦')+'</span><span>'+esc(a.name)+'</span>'+
'<button class="del-btn" title="删除" onclick="event.stopPropagation();_stepEditor.delCustomAction(\''+a.id+'\')">✕</button></div>';
}).join('');
customHtml+='</div>';
}
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='<div class="se-empty">拖拽左侧操作到这里<div class="hint">或点击操作库直接添加</div></div>';
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='<span class="sc-ico">'+lib.icon+'</span>'+
'<span class="sc-label">'+esc(step.label||lib.label)+'</span>'+
'<span class="sc-type">'+step.type+'</span>'+
'<span class="sc-actions">'+
'<button title="选中/取消选中(可多选后打包)" onclick="event.stopPropagation();_stepEditor.toggleSelect(\''+(step.id||'')+'\')">☑</button>'+
'<button title="展开/收起参数" onclick="_stepEditor.toggleExpand(this)">⚙</button>'+
'<button title="上移" onclick="_stepEditor.move('+idx+',-1,\''+this._arrId(parentArr)+'\')">↑</button>'+
'<button title="下移" onclick="_stepEditor.move('+idx+',1,\''+this._arrId(parentArr)+'\')">↓</button>'+
'<button title="复制" onclick="_stepEditor.duplicate('+idx+',\''+this._arrId(parentArr)+'\')">⧉</button>'+
'<button title="删除" onclick="_stepEditor.remove('+idx+',\''+this._arrId(parentArr)+'\')">✕</button>'+
'</span>';
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:<idx>'
return arr===this._steps?'root':'child';
},
_renderParams:function(step){
var p=step.params||{};
var h='';
if(step.type==='open_app'){
h+='<div class="form-group"><label>包名</label><input type="text" class="form-control" data-key="package" value="'+esc(p.package||'')+'" placeholder="如 com.ss.android.ugc.aweme"></div>';
h+='<div class="form-row"><div class="form-group form-check"><input type="checkbox" data-key="wait_home" '+(p.wait_home?'checked':'')+'><label>等待进入首页</label></div>';
h+='<div class="form-group"><label>首页特征词</label><input type="text" class="form-control" data-key="home_feature" value="'+esc(p.home_feature||'')+'" placeholder="如 拍摄"></div></div>';
}else if(step.type==='stop_app'){
h+='<div class="form-group"><label>包名</label><input type="text" class="form-control" data-key="package" value="'+esc(p.package||'')+'" placeholder="如 com.ss.android.ugc.aweme"></div>';
h+='<div class="help">强制结束该 App(am force-stop),清除后台进程,下次打开为冷启动。常配合"打开App"使用:结束→重新打开</div>';
}else if(step.type==='screen_on'){
h+='<div class="help">亮屏(息屏时唤醒并滑动解锁)。任务执行前用——息屏状态下 u2 无法点击操作</div>';
}else if(step.type==='screen_off'){
h+='<div class="help">息屏。任务结束后用,避免长时间亮屏导致烧屏/发热</div>';
}else if(step.type==='keep_screen'){
h+='<div class="form-group"><label>模式</label><select class="form-control" data-key="mode">'+
'<option value="on"'+(p.mode!=='off'?' selected':'')+'>保持亮屏(充电时屏幕常亮)</option>'+
'<option value="off"'+(p.mode==='off'?' selected':'')+'>恢复自动息屏</option></select></div>';
h+='<div class="help">长任务(如养号看视频几小时)放"保持亮屏"防屏幕超时熄灭;任务结束前放"恢复自动息屏"</div>';
}else if(step.type==='swipe'){
h+='<div class="form-row"><div class="form-group"><label>方向</label><select class="form-control" data-key="direction">'+
['up','down','left','right'].map(function(d){return '<option value="'+d+'"'+(p.direction===d?' selected':'')+'>'+d+'</option>';}).join('')+'</select></div>';
h+='<div class="form-group"><label>最短时长(秒)</label><input type="number" class="form-control" data-key="duration_min" value="'+(p.duration_min||0.25)+'" step="0.05" min="0.05"></div>';
h+='<div class="form-group"><label>最长时长(秒)</label><input type="number" class="form-control" data-key="duration_max" value="'+(p.duration_max||0.50)+'" step="0.05" min="0.05"></div></div>';
}else if(step.type==='click'){
h+=_selRowHtml(p,'<div class="form-group"><label>等待超时(秒)</label><input type="number" class="form-control" data-key="wait_timeout" value="'+(p.wait_timeout||2)+'" step="0.5" min="0.5"></div>');
}else if(step.type==='long_click'){
h+=_selRowHtml(p,'<div class="form-row"><div class="form-group"><label>长按时长(秒)</label><input type="number" class="form-control" data-key="duration" value="'+(p.duration||1.0)+'" step="0.5" min="0.3"></div>'+
'<div class="form-group"><label>等待超时(秒)</label><input type="number" class="form-control" data-key="wait_timeout" value="'+(p.wait_timeout||2)+'" step="0.5" min="0.5"></div></div>');
}else if(step.type==='swipe_until'){
h+=_selRowHtml(p,'<div class="form-row"><div class="form-group"><label>方向</label><select class="form-control" data-key="direction">'+
'<option value="up"'+(p.direction!=='down'?' selected':'')+'>上滑</option>'+
'<option value="down"'+(p.direction==='down'?' selected':'')+'>下滑</option></select></div>'+
'<div class="form-group"><label>最多滑动次数</label><input type="number" class="form-control" data-key="max_swipes" value="'+(p.max_swipes||8)+'" min="1" max="50"></div></div>'+
'<div class="form-group form-check"><input type="checkbox" data-key="click_when_found" '+(p.click_when_found!==false?'checked':'')+'><label>找到后点击</label></div>'+
'<div class="help">上滑/下滑直到目标元素出现(每次滑动后检查一次),找不到则跳过继续</div>');
}else if(step.type==='wait_el'){
h+=_selRowHtml(p,'<div class="form-group"><label>最长等待(秒)</label><input type="number" class="form-control" data-key="timeout" value="'+(p.timeout||10)+'" min="1" max="120"></div>'+
'<div class="help">等待目标元素出现(替代固定秒数等待),超时则继续</div>');
}else if(step.type==='click_xy'){
h+='<div class="form-row"><div class="form-group"><label>X(屏幕% )</label><input type="number" class="form-control" data-key="x" value="'+(p.x||50)+'" min="0" max="100"></div>'+
'<div class="form-group"><label>Y(屏幕% )</label><input type="number" class="form-control" data-key="y" value="'+(p.y||50)+'" min="0" max="100"></div></div>'+
'<div class="help">屏幕百分比坐标,50/50=屏幕中心。无选择器时的兜底点击</div>';
}else if(step.type==='key_event'){
h+='<div class="form-group"><label>按键</label><select class="form-control" data-key="key">'+
[['back','返回键'],['home','Home键'],['enter','回车键'],['menu','菜单键'],['recent','最近任务'],['delete','删除键']].map(function(k){
return '<option value="'+k[0]+'"'+(p.key===k[0]?' selected':'')+'>'+k[1]+'</option>';
}).join('')+'</select></div>';
h+='<div class="help">返回键用于退出评论/返回上一页,比结束App轻量</div>';
}else if(step.type==='wait'){
h+='<div class="form-row"><div class="form-group"><label>最短(秒)</label><input type="number" class="form-control" data-key="min" value="'+(p.min||1.0)+'" step="0.5" min="0.1"></div>';
h+='<div class="form-group"><label>最长(秒)</label><input type="number" class="form-control" data-key="max" value="'+(p.max||3.0)+'" step="0.5" min="0.1"></div></div>';
}else if(step.type==='input_text'){
const im=p.mode==='fixed'?'fixed':'random';
h+='<div class="form-group"><label>输入模式</label><select class="form-control" data-key="mode" onchange="_stepEditor._toggleInputMode(this)">'+
'<option value="random"'+(im==='random'?' selected':'')+'>随机候选</option>'+
'<option value="fixed"'+(im==='fixed'?' selected':'')+'>指定文字</option></select></div>';
h+='<div class="form-group" data-mode="random" style="display:'+(im==='random'?'block':'none')+'"><label>候选文字(每行一个,随机选一条)</label><textarea class="form-control" data-key="texts" rows="4" style="font-family:monospace;min-height:80px">'+esc(p.texts||'你好\n有趣\n支持')+'</textarea></div>';
h+='<div class="form-group" data-mode="fixed" style="display:'+(im==='fixed'?'block':'none')+'"><label>指定文字</label><input type="text" class="form-control" data-key="fixed_text" value="'+esc(p.fixed_text||'')+'"></div>';
h+='<div class="form-group form-check"><input type="checkbox" data-key="clear_first" '+(p.clear_first!==false?'checked':'')+'><label>输入前先清空</label></div>';
h+='<div class="help">需先用"点击元素"步骤定位到输入框,本步骤只负责输入文字</div>';
}else if(step.type==='loop'){
const lm=p.loop_mode==='time'?'time':'rounds';
h+='<div class="form-row"><div class="form-group"><label>循环方式</label><select class="form-control" data-key="loop_mode" onchange="_stepEditor._toggleLoopMode(this)">'+
'<option value="rounds"'+(lm==='rounds'?' selected':'')+'>按轮次</option>'+
'<option value="time"'+(lm==='time'?' selected':'')+'>按时间</option></select></div>'+
'<div class="form-group" data-lmode="rounds" style="display:'+(lm==='rounds'?'block':'none')+'"><label>最大循环次数</label><input type="number" class="form-control" data-key="max_iterations" value="'+(p.max_iterations||10)+'" min="1" max="100000"></div>'+
'<div class="form-group" data-lmode="time" style="display:'+(lm==='time'?'block':'none')+'"><label>循环时长(秒)</label><input type="number" class="form-control" data-key="loop_duration" value="'+(p.loop_duration||600)+'" min="1"></div></div>';
h+='<div class="help">将下方子步骤(拖拽到本卡片内)循环执行指定轮次或时长;按时间时配合"运行时长上限"看预计结束时间</div>';
}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,&lt;100 表示按概率执行(如 30=隔几次才触发一次,适合"偶尔点赞")</div></div></div>';
// 可编辑 label
h+='<div class="form-group"><label>步骤名称(可选)</label><input type="text" class="form-control" data-key="_label" value="'+esc(step.label||'')+'" placeholder="留空用默认"></div>';
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<idx)arr.splice(idx-1,0,moved);
else arr.splice(idx,0,moved);
rerender();
}else{
// 跨层级移入;禁止移进自己或自己的子孙(否则渲染会无限递归)
var moved2=srcArr[fromIdx];
if(moved2&&!this._containsArr(moved2,arr)){
this._syncFromDom();
srcArr.splice(fromIdx,1);
if(idx<0||idx>=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=
'<div class="el-device-grid"><div class="egd-title">选择在哪个设备上试执行</div><div id="test-device-list">加载中...</div></div>';
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='<div class="se-empty">'+esc((r&&r.error)||'获取设备失败')+'</div>';return;}
list.innerHTML=(r.devices||[]).map(function(d){
return '<div class="el-device-card" onclick="_stepEditor._runTestStep(\''+esc(d.serial)+'\')">'+
'<div class="edc-icon">📱</div>'+
'<div class="edc-info"><div class="edc-serial">'+esc(d.serial)+'</div>'+
'<div class="edc-model">'+esc(d.model||d.product||'')+'</div></div></div>';
}).join('')||'<div class="se-empty">无可用设备</div>';
});
},
_runTestStep:async function(serial){
var step=this._pendingTest;
var body=document.getElementById('test-modal-body');
if(!step||!body)return;
body.innerHTML='<div class="se-empty">正在 '+esc(serial)+' 上执行(adb + u2 连接约需几秒)...<div class="hint">请稍候,期间不要操作该设备</div></div>';
var r=await apiPost('/api/steps/test',{serial:serial,step:step});
if(!r)return;
var icon=r.result==='命中'?'✅':(r.result==='未找到'?'❌':'▶');
body.innerHTML='<div class="se-empty" style="padding:20px">'+icon+' <strong>'+esc(r.result||'已执行')+'</strong>'+
'<div class="hint" style="margin-top:8px">'+esc(r.msg||'')+'</div>'+
'<button class="btn btn-primary" style="margin-top:14px" onclick="closeTestModal()">完成</button></div>';
},
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<steps.length;i++){
var s=steps[i];
if(s.id===id)return s;
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/if_el 内部的步骤,只搜顶层会漏
var result=[];
var self=this;
steps.forEach(function(s){
if(ids.indexOf(s.id)>=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<arrs.length;i++){
for(var j=0;j<arrs[i].length;j++){
if(this._containsArr(arrs[i][j],arr))return true;
}
}
return false;
},
move:function(idx,dir,path){
this._syncFromDom(); // 先同步 DOM 编辑,避免重渲染丢内容
var arr=this._resolveArr(path);
if(!arr)return;
var ni=idx+dir;
if(ni<0||ni>=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='<div class="el-device-grid"><div class="egd-title">选择要抓取元素的设备</div><div id="el-device-list">加载中...</div></div>';
document.getElementById('el-picker-footer').innerHTML='<button class="btn" onclick="closeElPicker()">取消</button>';
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='<div class="se-empty">'+esc(res.error||'获取设备失败')+'<br><button class="btn btn-sm" style="margin-top:10px" onclick="_stepEditor._showDevicePicker()">重试</button></div>';return;}
var devices=res.devices||[];
if(!devices.length){list.innerHTML='<div class="se-empty">uiauto2 未发现已连接设备</div>';return;}
list.innerHTML=devices.map(function(d){
return '<div class="el-device-card" onclick="_stepEditor._showElPicker(\''+esc(d.serial)+'\')">'+
'<div class="edc-icon">📱</div>'+
'<div class="edc-info"><div class="edc-serial">'+esc(d.serial)+'</div>'+
'<div class="edc-model">'+esc(d.model||d.product||'')+'</div></div>'+
'<span class="edc-status">'+esc(d.status||'device')+'</span></div>';
}).join('');
}).catch(function(e){
document.getElementById('el-device-list').innerHTML='<div class="se-empty">请求失败: '+esc(e)+'<br><button class="btn btn-sm" style="margin-top:10px" onclick="_stepEditor._showDevicePicker()">重试</button></div>';
});
},
// 步骤2:选中设备后,显示截图 + 元素树
_showElPicker:function(serial){
var self=this;
self._elSerial=serial;
document.getElementById('el-picker-title').textContent='抓取元素 - '+serial;
document.getElementById('el-picker-body').innerHTML=
'<div class="el-picker-main">'+
'<div class="el-shot-wrap">'+
'<div class="el-shot-toolbar">'+
'<button class="btn" onclick="_stepEditor._refreshShot()" title="重新拉取截图和元素树(界面变化后刷新用)">刷新截图+元素</button>'+
'<span class="el-shot-info" id="el-shot-info">加载中...</span>'+
'</div>'+
'<div class="el-shot-container" id="el-shot-container">'+
'<div class="el-shot-loading">正在获取截图...</div>'+
'</div>'+
'</div>'+
'<div class="el-list-wrap">'+
'<input type="text" class="el-picker-search" id="el-search" placeholder="搜索 resource-id/text/description..." oninput="_stepEditor._filterEl(this.value)">'+
'<div class="el-picker-list" id="el-list"><div class="se-empty">加载元素树...</div></div>'+
'</div>'+
'</div>';
document.getElementById('el-picker-footer').innerHTML=
'<button class="btn" onclick="_stepEditor._showDevicePicker()">← 返回选设备</button>'+
'<button class="btn" onclick="closeElPicker()">取消</button>';
// 加载截图和元素树(_refreshShot 内部同时刷新两者)
self._refreshShot();
},
// 拉取元素树(进入页面和刷新时共用;带时间戳防缓存)
_loadElData:function(){
var serial=this._elSerial;
if(!serial)return;
var listEl=document.getElementById('el-list');
if(listEl)listEl.innerHTML='<div class="se-empty">加载元素树...</div>';
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='<div class="se-empty">'+esc(data.error||'抓取失败')+'</div>';
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='<div class="se-empty">请求失败: '+esc(e)+'</div>';
});
},
// 刷新截图
_refreshShot:function(){
var serial=this._elSerial;
if(!serial)return;
var container=document.getElementById('el-shot-container');
if(!container)return;
container.innerHTML='<div class="el-shot-loading">正在获取截图...</div>';
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='<div class="el-shot-loading">截图加载失败</div>';
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<bestArea){bestArea=area;best=i;}
}
});
if(best>=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('<span>id:'+esc(el.resource_id)+'</span>');
if(el.text)attrs.push('<span>text:'+esc(el.text)+'</span>');
if(el.description)attrs.push('<span>desc:'+esc(el.description)+'</span>');
var indent=' '.repeat(Math.min(el.depth||0,6));
var sg=el.suggested||{};
return '<div class="el-picker-item" data-idx="'+idx+'" title="点击填入: '+esc(sg.value||'')+'" onclick="_stepEditor._pickEl('+idx+')">'+
'<span class="ep-depth">'+(el.depth||0)+'</span>'+
'<div class="ep-info"><div class="ep-name">'+indent+esc(el.name||el.class||'')+'</div>'+
'<div class="ep-attrs">'+attrs.join('')+'</div></div>'+
'<span class="ep-sel">'+esc(sg.type)+'</span>'+
(sg.indexed?' <span class="label label-info" title="界面中同属性元素共 '+sg.total+' 个,按从上到下这是第 '+sg.occ+' 个,选择器已带序号精确定位">#'+sg.occ+'/'+sg.total+'</span>':'')+
(sg.broad?' <span class="label label-warning" title="无唯一属性,选择器可能误匹配其他元素,建议选带文字/id 的元素">⚠宽泛</span>':'')+
'</div>';
}).join('');
document.getElementById('el-list').innerHTML=html||'<div class="se-empty">无元素</div>';
},
_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());
}