fix: 动作编辑器修复(toast 未定义/CSRF 缺失/嵌套选中打包/跨层级拖拽/步骤 id 冲突)

This commit is contained in:
2026-08-11 08:27:32 +08:00
parent 8a3cd67a97
commit e3acb5739c
+110 -32
View File
@@ -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,&lt;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,&lt;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){