fix: 动作编辑器修复(toast 未定义/CSRF 缺失/嵌套选中打包/跨层级拖拽/步骤 id 冲突)
This commit is contained in:
+110
-32
@@ -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="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="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>'+
|
'<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>'+
|
||||||
'<div class="se-body">'+
|
'<div class="se-body">'+
|
||||||
'<div class="se-lib" id="se-lib"><div class="lib-title">操作库</div></div>'+
|
'<div class="se-lib" id="se-lib"><div class="lib-title">操作库</div></div>'+
|
||||||
@@ -753,7 +753,25 @@ var STEP_LIB=[
|
|||||||
var _customActions=[]; // 后端加载的自定义动作
|
var _customActions=[]; // 后端加载的自定义动作
|
||||||
var _stepIdCounter=0;
|
var _stepIdCounter=0;
|
||||||
function _newStepId(){_stepIdCounter++;return 'step_'+Date.now().toString(36)+'_'+_stepIdCounter;}
|
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;});}
|
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(){
|
function _loadCustomActions(){
|
||||||
@@ -770,6 +788,7 @@ var _stepEditor={
|
|||||||
init:function(steps,maxDuration,draftKey,canvasId,libId){
|
init:function(steps,maxDuration,draftKey,canvasId,libId){
|
||||||
this._draftKey=draftKey||'new';
|
this._draftKey=draftKey||'new';
|
||||||
this._selected=[];
|
this._selected=[];
|
||||||
|
this._updatePackBtns();
|
||||||
this._dragSuppressClick=false;
|
this._dragSuppressClick=false;
|
||||||
// 可指定编辑容器(任务编辑器 vs 动作编辑器),默认任务编辑器
|
// 可指定编辑容器(任务编辑器 vs 动作编辑器),默认任务编辑器
|
||||||
this._canvas=document.getElementById(canvasId||'se-canvas');
|
this._canvas=document.getElementById(canvasId||'se-canvas');
|
||||||
@@ -986,7 +1005,7 @@ var _stepEditor={
|
|||||||
h+='<div class="help">将下方子步骤(拖拽到本卡片内)循环执行指定轮次或时长;按时间时配合"运行时长上限"看预计结束时间</div>';
|
h+='<div class="help">将下方子步骤(拖拽到本卡片内)循环执行指定轮次或时长;按时间时配合"运行时长上限"看预计结束时间</div>';
|
||||||
}else if(step.type==='group'){
|
}else if(step.type==='group'){
|
||||||
h+='<div class="help">动作组:按顺序执行下方子步骤一次。把多个步骤打包成一个可复用的整体。</div>';
|
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)
|
// 共享:触发概率(所有步骤通用,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>';
|
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.preventDefault();
|
||||||
e.stopPropagation(); // 阻止冒泡到外层卡片/容器,避免重复添加
|
e.stopPropagation(); // 阻止冒泡到外层卡片/容器,避免重复添加
|
||||||
container.classList.remove('drag-over');
|
container.classList.remove('drag-over');
|
||||||
if(e.target===container||e.target.classList.contains('se-empty')){
|
// 子卡片都会 stopPropagation,能冒泡到这里的 drop 一定落在空白区
|
||||||
self._handleDrop(e,self._dropArrs.get(container),-1);
|
// (含 .se-empty 的提示文字),直接追加到数组末尾
|
||||||
}
|
self._handleDrop(e,self._dropArrs.get(container),-1);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
_handleDrop:function(e,arr,idx){
|
_handleDrop:function(e,arr,idx){
|
||||||
@@ -1027,17 +1046,32 @@ var _stepEditor={
|
|||||||
// 延迟到下一帧渲染,保证一次拖入只加一次。
|
// 延迟到下一帧渲染,保证一次拖入只加一次。
|
||||||
function rerender(){ setTimeout(function(){self._renderCanvas();},0); }
|
function rerender(){ setTimeout(function(){self._renderCanvas();},0); }
|
||||||
if(data.startsWith('move:')){
|
if(data.startsWith('move:')){
|
||||||
// 内部排序
|
// 拖拽排序:同层级排序 / 跨层级移入(把步骤拖进循环块、循环套循环)
|
||||||
var parts=data.split(':');
|
var parts=data.split(':');
|
||||||
var fromIdx=parseInt(parts[1]);
|
var fromIdx=parseInt(parts[1]);
|
||||||
var fromPath=parts[2];
|
var fromPath=parts[2];
|
||||||
if(fromPath===this._arrId(arr)&&fromIdx!==idx){
|
var srcArr=this._resolveArr(fromPath);
|
||||||
this._syncFromDom();
|
if(srcArr){
|
||||||
var moved=arr.splice(fromIdx,1)[0];
|
if(srcArr===arr){
|
||||||
if(idx<0)arr.push(moved);
|
// 同层级排序
|
||||||
else if(fromIdx<idx)arr.splice(idx-1,0,moved);
|
if(fromIdx===idx)return;
|
||||||
else arr.splice(idx,0,moved);
|
this._syncFromDom();
|
||||||
rerender();
|
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:')){
|
}else if(data.startsWith('action:')){
|
||||||
// 从自定义动作库拖入 → 展开为 group 步骤
|
// 从自定义动作库拖入 → 展开为 group 步骤
|
||||||
@@ -1047,6 +1081,7 @@ var _stepEditor={
|
|||||||
var ns=this._makeStep({type:'group',label:action.name,icon:action.icon||'📦',params:{children:[]}});
|
var ns=this._makeStep({type:'group',label:action.name,icon:action.icon||'📦',params:{children:[]}});
|
||||||
ns.label=action.name;
|
ns.label=action.name;
|
||||||
ns.params.children=JSON.parse(JSON.stringify(action.steps));
|
ns.params.children=JSON.parse(JSON.stringify(action.steps));
|
||||||
|
_stripIds(ns.params.children); // 重新生成 id,避免同一动作拖入多次后 id 冲突
|
||||||
this._ensureIds(ns.params.children);
|
this._ensureIds(ns.params.children);
|
||||||
if(idx<0||idx>=arr.length)arr.push(ns);
|
if(idx<0||idx>=arr.length)arr.push(ns);
|
||||||
else arr.splice(idx,0,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:[]}});
|
var ns=this._makeStep({type:'group',label:action.name,icon:action.icon||'📦',params:{children:[]}});
|
||||||
ns.label=action.name;
|
ns.label=action.name;
|
||||||
ns.params.children=JSON.parse(JSON.stringify(action.steps));
|
ns.params.children=JSON.parse(JSON.stringify(action.steps));
|
||||||
|
_stripIds(ns.params.children); // 重新生成 id,避免同一动作拖入多次后 id 冲突
|
||||||
this._ensureIds(ns.params.children);
|
this._ensureIds(ns.params.children);
|
||||||
this._steps.push(ns);
|
this._steps.push(ns);
|
||||||
this._renderCanvas();
|
this._renderCanvas();
|
||||||
},
|
},
|
||||||
delCustomAction:function(actionId){
|
delCustomAction:function(actionId){
|
||||||
if(!confirm('确定删除这个自定义动作?'))return;
|
if(!confirm('确定删除这个自定义动作?'))return;
|
||||||
fetch('/api/custom_actions/'+actionId,{method:'DELETE'}).then(function(r){return r.json();}).then(function(res){
|
apiDelete('/api/custom_actions/'+actionId).then(function(res){
|
||||||
if(res.ok){_loadCustomActions();toast('已删除','success');}
|
if(!res)return;
|
||||||
else toast(res.error||'删除失败','error');
|
if(res.ok){_loadCustomActions();showToast('已删除','success');}
|
||||||
|
else showToast(res.error||'删除失败','error');
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
toggleSelect:function(stepId){
|
toggleSelect:function(stepId){
|
||||||
@@ -1099,44 +1136,74 @@ var _stepEditor={
|
|||||||
if(i>=0)this._selected.splice(i,1);
|
if(i>=0)this._selected.splice(i,1);
|
||||||
else this._selected.push(stepId);
|
else this._selected.push(stepId);
|
||||||
this._renderCanvas();
|
this._renderCanvas();
|
||||||
// 更新工具栏按钮状态
|
this._updatePackBtns();
|
||||||
var bar=document.getElementById('se-pack-btn');
|
|
||||||
if(bar)bar.disabled=this._selected.length<1;
|
|
||||||
},
|
},
|
||||||
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=[];
|
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);
|
stepsToSave=this._collectByIds(this._steps,this._selected);
|
||||||
}
|
}
|
||||||
if(!stepsToSave.length){
|
if(!stepsToSave.length){
|
||||||
toast('请先用 ☑ 选中要打包的步骤','error');
|
showToast('请先用 ☑ 选中要打包的步骤','error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var name=prompt('输入动作名称:','我的动作');
|
var name=prompt('输入动作名称:','我的动作');
|
||||||
if(!name)return;
|
if(!name)return;
|
||||||
var self=this;
|
var self=this;
|
||||||
fetch('/api/custom_actions',{method:'POST',headers:{'Content-Type':'application/json'},
|
apiPost('/api/custom_actions',{name:name,steps:stepsToSave})
|
||||||
body:JSON.stringify({name:name,steps:stepsToSave})})
|
|
||||||
.then(function(r){return r.json();})
|
|
||||||
.then(function(res){
|
.then(function(res){
|
||||||
|
if(!res)return;
|
||||||
if(res.ok){
|
if(res.ok){
|
||||||
toast('已保存到自定义动作库','success');
|
showToast('已保存到自定义动作库','success');
|
||||||
self._selected=[];
|
self._selected=[];
|
||||||
_loadCustomActions();
|
_loadCustomActions();
|
||||||
self._renderCanvas();
|
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){
|
_collectByIds:function(steps,ids){
|
||||||
// 收集选中的步骤(深拷贝,去掉 id)
|
// 收集选中的步骤(深拷贝,去掉 id)。
|
||||||
|
// 递归搜索嵌套子步骤:☑ 也能选中 group/loop 内部的步骤,只搜顶层会漏
|
||||||
var result=[];
|
var result=[];
|
||||||
|
var self=this;
|
||||||
steps.forEach(function(s){
|
steps.forEach(function(s){
|
||||||
if(ids.indexOf(s.id)>=0){
|
if(ids.indexOf(s.id)>=0){
|
||||||
var copy=JSON.parse(JSON.stringify(s));
|
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;
|
return result;
|
||||||
@@ -1175,6 +1242,15 @@ var _stepEditor={
|
|||||||
if(path==='root')return this._steps;
|
if(path==='root')return this._steps;
|
||||||
return this._arrById&&this._arrById[path];
|
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){
|
move:function(idx,dir,path){
|
||||||
this._syncFromDom(); // 先同步 DOM 编辑,避免重渲染丢内容
|
this._syncFromDom(); // 先同步 DOM 编辑,避免重渲染丢内容
|
||||||
var arr=this._resolveArr(path);
|
var arr=this._resolveArr(path);
|
||||||
@@ -1601,7 +1677,8 @@ function openActionModal(actionId){
|
|||||||
'</div>'+
|
'</div>'+
|
||||||
'<div class="help" style="margin-bottom:8px">用下方步骤编辑器编排动作内容,保存后可拖入任务步骤编辑器复用</div>'+
|
'<div class="help" style="margin-bottom:8px">用下方步骤编辑器编排动作内容,保存后可拖入任务步骤编辑器复用</div>'+
|
||||||
'<div class="step-editor" style="display:block">'+
|
'<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-body">'+
|
||||||
'<div class="se-lib" id="action-lib"><div class="lib-title">操作库</div></div>'+
|
'<div class="se-lib" id="action-lib"><div class="lib-title">操作库</div></div>'+
|
||||||
'<div class="se-canvas" id="action-canvas"></div>'+
|
'<div class="se-canvas" id="action-canvas"></div>'+
|
||||||
@@ -1619,6 +1696,7 @@ async function saveAction(actionId){
|
|||||||
if(!name){showToast('动作名不能为空','error');return;}
|
if(!name){showToast('动作名不能为空','error');return;}
|
||||||
const icon=document.getElementById('a-icon').value.trim()||'📦';
|
const icon=document.getElementById('a-icon').value.trim()||'📦';
|
||||||
const steps=_stepEditor.collect();
|
const steps=_stepEditor.collect();
|
||||||
|
_stripIds(steps); // 入库前去掉 id,避免重复使用后 id 冲突
|
||||||
if(!steps.length){showToast('至少需要1个步骤','error');return;}
|
if(!steps.length){showToast('至少需要1个步骤','error');return;}
|
||||||
const data={name:name,icon:icon,steps:steps};
|
const data={name:name,icon:icon,steps:steps};
|
||||||
if(actionId){
|
if(actionId){
|
||||||
|
|||||||
Reference in New Issue
Block a user