fix: 动作编辑器修复(toast 未定义/CSRF 缺失/嵌套选中打包/跨层级拖拽/步骤 id 冲突)
This commit is contained in:
+103
-25
@@ -700,7 +700,7 @@ function openTaskModal(jobId){
|
||||
'<div class="form-group" id="f-params_wrap"><label>任务参数 (JSON)</label><textarea class="form-control" id="f-params" rows="6">'+esc(JSON.stringify(j.params||{},null,2))+'</textarea><div class="help">可留空使用默认参数。抖音任务可设 max_duration(秒,如 1800=30分钟)限制单设备运行时长</div></div>'+
|
||||
'<div class="step-editor" id="step-editor">'+
|
||||
'<div class="se-head"><span>步骤编辑器</span><span style="font-size:11px;color:var(--text-light);flex:1;margin-left:12px">用步骤卡片上的 ☑ 选中多个步骤,可打包成自定义动作</span>'+
|
||||
'<button class="btn btn-xs btn-primary" id="se-pack-btn" disabled onclick="_stepEditor.saveAsCustomAction()">打包选中步骤</button>'+
|
||||
(_can('tasks')?'<button class="btn btn-xs btn-primary" id="se-pack-btn" disabled onclick="_stepEditor.saveAsCustomAction()">打包选中步骤</button>':'')+
|
||||
'</div>'+
|
||||
'<div class="se-body">'+
|
||||
'<div class="se-lib" id="se-lib"><div class="lib-title">操作库</div></div>'+
|
||||
@@ -753,7 +753,25 @@ var STEP_LIB=[
|
||||
var _customActions=[]; // 后端加载的自定义动作
|
||||
var _stepIdCounter=0;
|
||||
function _newStepId(){_stepIdCounter++;return 'step_'+Date.now().toString(36)+'_'+_stepIdCounter;}
|
||||
// 递归去掉步骤 id(保存到自定义动作库/拖入画布时重新生成,避免重复拖入后 id 冲突导致多选错乱)
|
||||
function _stripIds(steps){
|
||||
steps.forEach(function(s){
|
||||
delete s.id;
|
||||
if(s.params&&s.params.children&&Array.isArray(s.params.children))_stripIds(s.params.children);
|
||||
});
|
||||
}
|
||||
function _findLib(type){return STEP_LIB.find(function(s){return s.type===type;});}
|
||||
// 选择器组件(类型下拉 + 值输入 + 抓取元素 + 测试按钮),click/long_click/swipe_until/wait_el 复用
|
||||
function _selRowHtml(p, extra){
|
||||
var opts=['xpath','description','text','resourceId','descriptionContains','className'].map(function(d){
|
||||
return '<option value="'+d+'"'+(p.selector_type===d?' selected':'')+'>'+d+'</option>';}).join('');
|
||||
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">'+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(){
|
||||
@@ -770,6 +788,7 @@ var _stepEditor={
|
||||
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');
|
||||
@@ -986,7 +1005,7 @@ var _stepEditor={
|
||||
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()">存为自定义动作</button> <span class="help" style="display:inline">把本组步骤保存到左侧自定义动作库,方便复用</span></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>';
|
||||
}
|
||||
// 共享:触发概率(所有步骤通用,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,<100 表示按概率执行(如 30=隔几次才触发一次,适合"偶尔点赞")</div></div></div>';
|
||||
@@ -1014,9 +1033,9 @@ var _stepEditor={
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // 阻止冒泡到外层卡片/容器,避免重复添加
|
||||
container.classList.remove('drag-over');
|
||||
if(e.target===container||e.target.classList.contains('se-empty')){
|
||||
// 子卡片都会 stopPropagation,能冒泡到这里的 drop 一定落在空白区
|
||||
// (含 .se-empty 的提示文字),直接追加到数组末尾
|
||||
self._handleDrop(e,self._dropArrs.get(container),-1);
|
||||
}
|
||||
});
|
||||
},
|
||||
_handleDrop:function(e,arr,idx){
|
||||
@@ -1027,17 +1046,32 @@ var _stepEditor={
|
||||
// 延迟到下一帧渲染,保证一次拖入只加一次。
|
||||
function rerender(){ setTimeout(function(){self._renderCanvas();},0); }
|
||||
if(data.startsWith('move:')){
|
||||
// 内部排序
|
||||
// 拖拽排序:同层级排序 / 跨层级移入(把步骤拖进循环块、循环套循环)
|
||||
var parts=data.split(':');
|
||||
var fromIdx=parseInt(parts[1]);
|
||||
var fromPath=parts[2];
|
||||
if(fromPath===this._arrId(arr)&&fromIdx!==idx){
|
||||
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 步骤
|
||||
@@ -1047,6 +1081,7 @@ var _stepEditor={
|
||||
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);
|
||||
@@ -1081,15 +1116,17 @@ var _stepEditor={
|
||||
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;
|
||||
fetch('/api/custom_actions/'+actionId,{method:'DELETE'}).then(function(r){return r.json();}).then(function(res){
|
||||
if(res.ok){_loadCustomActions();toast('已删除','success');}
|
||||
else toast(res.error||'删除失败','error');
|
||||
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){
|
||||
@@ -1099,44 +1136,74 @@ var _stepEditor={
|
||||
if(i>=0)this._selected.splice(i,1);
|
||||
else this._selected.push(stepId);
|
||||
this._renderCanvas();
|
||||
// 更新工具栏按钮状态
|
||||
var bar=document.getElementById('se-pack-btn');
|
||||
if(bar)bar.disabled=this._selected.length<1;
|
||||
this._updatePackBtns();
|
||||
},
|
||||
saveAsCustomAction:function(){
|
||||
// 从 group 步骤的"存为自定义动作"按钮调用:收集当前展开的 group 步骤的 children
|
||||
// 或从选中步骤收集
|
||||
// 同步所有"打包选中步骤"按钮的可用状态(任务编辑器 + 动作编辑器)
|
||||
_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(this._selected&&this._selected.length){
|
||||
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){
|
||||
toast('请先用 ☑ 选中要打包的步骤','error');
|
||||
showToast('请先用 ☑ 选中要打包的步骤','error');
|
||||
return;
|
||||
}
|
||||
var name=prompt('输入动作名称:','我的动作');
|
||||
if(!name)return;
|
||||
var self=this;
|
||||
fetch('/api/custom_actions',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({name:name,steps:stepsToSave})})
|
||||
.then(function(r){return r.json();})
|
||||
apiPost('/api/custom_actions',{name:name,steps:stepsToSave})
|
||||
.then(function(res){
|
||||
if(!res)return;
|
||||
if(res.ok){
|
||||
toast('已保存到自定义动作库','success');
|
||||
showToast('已保存到自定义动作库','success');
|
||||
self._selected=[];
|
||||
_loadCustomActions();
|
||||
self._renderCanvas();
|
||||
}else toast(res.error||'保存失败','error');
|
||||
self._updatePackBtns();
|
||||
}else showToast(res.error||'保存失败','error');
|
||||
});
|
||||
},
|
||||
// 在嵌套结构中按 id 查找步骤(group/loop 的子步骤也找得到)
|
||||
_findStepById:function(steps,id){
|
||||
for(var i=0;i<steps.length;i++){
|
||||
var s=steps[i];
|
||||
if(s.id===id)return s;
|
||||
if(s.params&&s.params.children&&Array.isArray(s.params.children)){
|
||||
var f=this._findStepById(s.params.children,id);
|
||||
if(f)return f;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
_collectByIds:function(steps,ids){
|
||||
// 收集选中的步骤(深拷贝,去掉 id)
|
||||
// 收集选中的步骤(深拷贝,去掉 id)。
|
||||
// 递归搜索嵌套子步骤:☑ 也能选中 group/loop 内部的步骤,只搜顶层会漏
|
||||
var result=[];
|
||||
var self=this;
|
||||
steps.forEach(function(s){
|
||||
if(ids.indexOf(s.id)>=0){
|
||||
var copy=JSON.parse(JSON.stringify(s));
|
||||
result.push(copy);
|
||||
_stripIds([copy]);
|
||||
result.push(copy); // 容器选中时子步骤已包含在拷贝里,不再单独收集
|
||||
}else if(s.params&&s.params.children&&Array.isArray(s.params.children)){
|
||||
result=result.concat(self._collectByIds(s.params.children,ids));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
@@ -1175,6 +1242,15 @@ var _stepEditor={
|
||||
if(path==='root')return this._steps;
|
||||
return this._arrById&&this._arrById[path];
|
||||
},
|
||||
// 判断 step 的子孙数组中是否包含 arr(防循环块套进自己导致渲染无限递归)
|
||||
_containsArr:function(step,arr){
|
||||
if(!step||!step.params||!Array.isArray(step.params.children))return false;
|
||||
if(step.params.children===arr)return true;
|
||||
for(var i=0;i<step.params.children.length;i++){
|
||||
if(this._containsArr(step.params.children[i],arr))return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
move:function(idx,dir,path){
|
||||
this._syncFromDom(); // 先同步 DOM 编辑,避免重渲染丢内容
|
||||
var arr=this._resolveArr(path);
|
||||
@@ -1601,7 +1677,8 @@ function openActionModal(actionId){
|
||||
'</div>'+
|
||||
'<div class="help" style="margin-bottom:8px">用下方步骤编辑器编排动作内容,保存后可拖入任务步骤编辑器复用</div>'+
|
||||
'<div class="step-editor" style="display:block">'+
|
||||
'<div class="se-head"><span>动作步骤</span><span style="font-size:11px;color:var(--text-light);margin-left:12px">支持循环/分组/概率触发/拖拽排序</span></div>'+
|
||||
'<div class="se-head"><span>动作步骤</span><span style="font-size:11px;color:var(--text-light);margin-left:12px">支持循环/分组/概率触发/拖拽排序</span>'+
|
||||
(_can('tasks')?'<button class="btn btn-xs btn-primary" id="se-pack-btn2" disabled onclick="_stepEditor.saveAsCustomAction()">打包选中步骤</button>':'')+'</div>'+
|
||||
'<div class="se-body">'+
|
||||
'<div class="se-lib" id="action-lib"><div class="lib-title">操作库</div></div>'+
|
||||
'<div class="se-canvas" id="action-canvas"></div>'+
|
||||
@@ -1619,6 +1696,7 @@ async function saveAction(actionId){
|
||||
if(!name){showToast('动作名不能为空','error');return;}
|
||||
const icon=document.getElementById('a-icon').value.trim()||'📦';
|
||||
const steps=_stepEditor.collect();
|
||||
_stripIds(steps); // 入库前去掉 id,避免重复使用后 id 冲突
|
||||
if(!steps.length){showToast('至少需要1个步骤','error');return;}
|
||||
const data={name:name,icon:icon,steps:steps};
|
||||
if(actionId){
|
||||
|
||||
Reference in New Issue
Block a user