feat: 前端整体增强(应用查看UI/元素选择/步骤编辑器/拖拽/概率/分页/动作管理)
- 应用Tab:设备已装应用区块(应用名+版本+versionCode+路径)、安装弹窗设备显示 IP·型号 - 元素抓取:截图按 bounds 命中测试选最小元素,跳过全屏容器 overlay - 步骤编辑器:输入实时同步+localStorage草稿自动保存(防丢内容) - 拖拽修复:画布 drop 只绑定一次+stopPropagation+延迟渲染,可拖入循环块且不重复 - 步骤概率触发字段、循环按轮次/时间切换、监控页预计结束时间 - 全部列表统一搜索+分页(设备/任务/应用/分组/用户/动作) - 自定义动作管理模块(列表/新建/编辑/删除)、步骤编辑器容器化、嵌套循环移动修复
This commit is contained in:
+479
-67
@@ -54,6 +54,11 @@ body{background:var(--bg);font-family:-apple-system,"Segoe UI",Roboto,"PingFang
|
||||
.btn-sm{padding:4px 12px;font-size:12px}
|
||||
.btn-xs{padding:3px 10px;font-size:12px}
|
||||
|
||||
/* 通用列表分页栏 */
|
||||
.list-pager{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin:8px 0 20px}
|
||||
.list-pager .lp-total{font-size:12px;color:var(--text-light);margin-right:auto}
|
||||
.list-search{width:240px}
|
||||
|
||||
/* 区块标题 */
|
||||
.section-title{font-size:15px;font-weight:600;color:var(--text);margin:0 0 12px;padding-bottom:8px;border-bottom:2px solid var(--primary);display:inline-block}
|
||||
|
||||
@@ -202,6 +207,8 @@ select.form-control{cursor:pointer}
|
||||
.step-card.group-block .sc-ico{background:#8b5cf6}
|
||||
.step-card.selected{border-color:var(--primary);box-shadow:0 0 0 2px rgba(59,130,246,.2)}
|
||||
.step-children{margin-left:20px;margin-top:6px;border-left:2px dashed var(--border);padding-left:8px}
|
||||
.step-children.drag-over{border-left-color:var(--primary);background:rgba(59,130,246,.06);border-radius:6px}
|
||||
#se-canvas.drag-over{outline:2px dashed var(--primary);outline-offset:2px;border-radius:8px}
|
||||
.sel-picker{display:flex;gap:4px;align-items:center}
|
||||
.sel-picker input{flex:1}
|
||||
.sel-picker .btn-pick{padding:5px 8px;font-size:11px;white-space:nowrap}
|
||||
@@ -287,6 +294,7 @@ select.form-control{cursor:pointer}
|
||||
<button class="btn" onclick="apiAction('/api/release')">强制释放占用</button>
|
||||
<button class="btn btn-primary" id="btn-scan-fg" onclick="scanForeground()">扫描前台App</button>
|
||||
<button class="btn" onclick="loadMonitor()">刷新</button>
|
||||
<input class="form-control list-search" id="search-devices" placeholder="搜索设备/型号/App/任务...">
|
||||
<span class="hint"><span id="fg-status"></span> · 每 5 秒自动刷新 · <span id="srvTime"></span> · <span id="sel-count">未选中</span></span>
|
||||
</div>
|
||||
<table class="table">
|
||||
@@ -305,6 +313,7 @@ select.form-control{cursor:pointer}
|
||||
</tr></thead>
|
||||
<tbody id="tb-devices"></tbody>
|
||||
</table>
|
||||
<div class="list-pager" id="pager-devices"></div>
|
||||
|
||||
<div class="section-title" style="margin-top:8px">任务运行概况</div>
|
||||
<div id="jobs-grid" class="jobs-grid"></div>
|
||||
@@ -317,6 +326,7 @@ select.form-control{cursor:pointer}
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-primary" onclick="openTaskModal()">新建任务</button>
|
||||
<button class="btn" onclick="loadTasks()">刷新</button>
|
||||
<input class="form-control list-search" id="search-tasks" placeholder="搜索任务名/类型/ID...">
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead><tr>
|
||||
@@ -324,6 +334,23 @@ select.form-control{cursor:pointer}
|
||||
</tr></thead>
|
||||
<tbody id="tb-tasks"></tbody>
|
||||
</table>
|
||||
<div class="list-pager" id="pager-tasks"></div>
|
||||
|
||||
<!-- 自定义动作管理 -->
|
||||
<div style="margin-top:28px">
|
||||
<div class="page-title" style="font-size:16px">自定义动作管理</div>
|
||||
<div class="page-sub">把常用步骤打包成可复用的动作,拖入任务步骤编辑器使用;也可在任务编辑器里"☑选中步骤→打包"生成</div>
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-primary" onclick="openActionModal()">新建动作</button>
|
||||
<button class="btn" onclick="loadCustomActions()">刷新</button>
|
||||
<input class="form-control list-search" id="search-actions" placeholder="搜索动作名...">
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead><tr><th>图标</th><th>动作名</th><th>步骤数</th><th>创建时间</th><th>操作</th></tr></thead>
|
||||
<tbody id="tb-actions"></tbody>
|
||||
</table>
|
||||
<div class="list-pager" id="pager-actions"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========== Tab: 应用 ========== -->
|
||||
@@ -336,6 +363,7 @@ select.form-control{cursor:pointer}
|
||||
<input type="file" accept=".apk" style="display:none" onchange="uploadApk(this)">
|
||||
</label>
|
||||
<button class="btn" onclick="loadApks()">刷新</button>
|
||||
<input class="form-control list-search" id="search-apks" placeholder="搜索应用名/包名...">
|
||||
<span class="hint" id="apk-hint"></span>
|
||||
</div>
|
||||
<table class="table">
|
||||
@@ -344,6 +372,32 @@ select.form-control{cursor:pointer}
|
||||
</tr></thead>
|
||||
<tbody id="tb-apks"></tbody>
|
||||
</table>
|
||||
<div class="list-pager" id="pager-apks"></div>
|
||||
|
||||
<!-- 设备已装应用 -->
|
||||
<div style="margin-top:28px">
|
||||
<div class="page-title" style="font-size:16px">设备已装应用</div>
|
||||
<div class="page-sub">查看某台设备上安装的所有应用(含版本号)</div>
|
||||
<div class="toolbar">
|
||||
<select id="apps-device" class="form-control" style="width:280px;display:inline-block">
|
||||
<option value="">选择设备...</option>
|
||||
</select>
|
||||
<button class="btn btn-primary" onclick="loadDeviceApps()">查看应用</button>
|
||||
<input class="form-control list-search" id="search-devapps" placeholder="搜索包名/路径...">
|
||||
<span class="hint" id="apps-hint"></span>
|
||||
</div>
|
||||
<div style="max-height:420px;overflow:auto;border:1px solid var(--border);border-radius:8px">
|
||||
<table class="table" style="margin:0">
|
||||
<thead><tr>
|
||||
<th>应用</th><th>版本号</th><th>versionCode</th><th>APK 路径</th>
|
||||
</tr></thead>
|
||||
<tbody id="tb-device-apps">
|
||||
<tr><td colspan="4" class="empty">选择设备后点击"查看应用"</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="list-pager" id="pager-devapps"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========== Tab 3: 分组 ========== -->
|
||||
@@ -353,6 +407,7 @@ select.form-control{cursor:pointer}
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-primary" onclick="openGroupModal()">新建分组</button>
|
||||
<button class="btn" onclick="loadGroups()">刷新</button>
|
||||
<input class="form-control list-search" id="search-groups" placeholder="搜索分组名/描述...">
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead><tr>
|
||||
@@ -360,6 +415,7 @@ select.form-control{cursor:pointer}
|
||||
</tr></thead>
|
||||
<tbody id="tb-groups"></tbody>
|
||||
</table>
|
||||
<div class="list-pager" id="pager-groups"></div>
|
||||
</div>
|
||||
|
||||
<!-- ========== Tab 4: 日志 ========== -->
|
||||
@@ -393,6 +449,7 @@ select.form-control{cursor:pointer}
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-primary" onclick="openUserModal()">新建用户</button>
|
||||
<button class="btn" onclick="loadUsers()">刷新</button>
|
||||
<input class="form-control list-search" id="search-users" placeholder="搜索用户名...">
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead><tr>
|
||||
@@ -400,6 +457,7 @@ select.form-control{cursor:pointer}
|
||||
</tr></thead>
|
||||
<tbody id="tb-users"></tbody>
|
||||
</table>
|
||||
<div class="list-pager" id="pager-users"></div>
|
||||
</div>
|
||||
|
||||
</div><!-- /.content -->
|
||||
@@ -416,6 +474,18 @@ select.form-control{cursor:pointer}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========== 自定义动作编辑模态框 ========== -->
|
||||
<div id="action-modal-overlay" class="modal-overlay" onclick="if(event.target===this)closeActionModal()">
|
||||
<div class="modal" id="action-modal-box" style="max-width:1000px">
|
||||
<div class="modal-header">
|
||||
<h3 id="action-modal-title">新建动作</h3>
|
||||
<button class="close" onclick="closeActionModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="action-modal-body" style="max-height:78vh;overflow:auto"></div>
|
||||
<div class="modal-footer" id="action-modal-footer"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========== 元素抓取模态框(独立第二层,不覆盖任务编辑窗口)========== -->
|
||||
<div id="el-picker-overlay" class="modal-overlay" style="z-index:1100" onclick="if(event.target===this)closeElPicker()">
|
||||
<div class="modal" id="el-picker-box" style="max-width:900px">
|
||||
@@ -671,13 +741,68 @@ function showTab(name){
|
||||
if(_logTimer){clearInterval(_logTimer);_logTimer=null;}
|
||||
// 按需加载数据
|
||||
if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);}
|
||||
if(name==='tasks')loadTasks();
|
||||
if(name==='tasks'){loadTasks();loadCustomActions();}
|
||||
if(name==='apps')loadApks();
|
||||
if(name==='groups')loadGroups();
|
||||
if(name==='logs'){loadLogs();if(document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);}
|
||||
if(name==='users')loadUsers();
|
||||
}
|
||||
|
||||
// ================== 通用列表:搜索 + 分页 ==================
|
||||
// 用法:
|
||||
// setListPager(key,{data:()=>array, filter:(item,kw)=>bool, render:(item)=>html,
|
||||
// tbody:'tb-id', pager:'pager-id', pageSize:N});
|
||||
// applyListPager(key); // 数据变化后重渲染(含搜索/翻页状态)
|
||||
// initListSearch(key,'search-id');// 搜索框绑定
|
||||
var _LIST_PAGERS={};
|
||||
|
||||
function _lp(key){ return _LIST_PAGERS[key] || (_LIST_PAGERS[key]={page:1,kw:'',cfg:null}); }
|
||||
|
||||
function setListPager(key,cfg){
|
||||
// 只更新配置,不重置 page/kw —— 监控列表 5 秒自动刷新时保持用户当前页
|
||||
var st=_lp(key); st.cfg=cfg;
|
||||
}
|
||||
function initListSearch(key,inputId){
|
||||
var input=document.getElementById(inputId);
|
||||
if(!input||input.dataset.bound)return;
|
||||
input.dataset.bound='1';
|
||||
input.addEventListener('input',function(){
|
||||
var st=_lp(key); st.kw=this.value.trim(); st.page=1; applyListPager(key);
|
||||
});
|
||||
}
|
||||
function applyListPager(key){
|
||||
var st=_lp(key);
|
||||
if(!st.cfg)return;
|
||||
var cfg=st.cfg;
|
||||
var all=(cfg.data&&cfg.data())||[];
|
||||
var kw=st.kw.toLowerCase();
|
||||
var filtered=kw?all.filter(function(x){return cfg.filter(x,kw);}):all;
|
||||
var pageSize=cfg.pageSize||10;
|
||||
var pages=Math.max(1,Math.ceil(filtered.length/pageSize));
|
||||
if(st.page>pages)st.page=pages;
|
||||
if(st.page<1)st.page=1;
|
||||
var start=(st.page-1)*pageSize;
|
||||
var slice=filtered.slice(start,start+pageSize);
|
||||
var tb=document.getElementById(cfg.tbody);
|
||||
if(tb)tb.innerHTML=slice.map(cfg.render).join('')||(cfg.empty||'<tr><td colspan="20" class="empty">无数据</td></tr>');
|
||||
var pg=document.getElementById(cfg.pager);
|
||||
if(pg)pg.innerHTML=_lpPagerHtml(key,pages,st.page,filtered.length,pageSize);
|
||||
}
|
||||
function _lpPagerHtml(key,pages,cur,total,pageSize){
|
||||
if(pages<=1)return total>0?'<div class="list-pager"><span class="lp-total">共 '+total+' 条</span></div>':'';
|
||||
var h='<div class="list-pager"><span class="lp-total">共 '+total+' 条</span>';
|
||||
h+='<button class="btn btn-xs" onclick="_lpGo(\''+key+'\','+(cur-1)+')"'+(cur<=1?' disabled':'')+'>‹ 上一页</button>';
|
||||
for(var p=Math.max(1,cur-2);p<=Math.min(pages,cur+2);p++){
|
||||
h+='<button class="btn btn-xs'+(p===cur?' btn-primary':'')+'" onclick="_lpGo(\''+key+'\','+p+')">'+p+'</button>';
|
||||
}
|
||||
h+='<button class="btn btn-xs" onclick="_lpGo(\''+key+'\','+(cur+1)+')"'+(cur>=pages?' disabled':'')+'>下一页 ›</button>';
|
||||
h+='</div>';
|
||||
return h;
|
||||
}
|
||||
function _lpGo(key,p){
|
||||
var st=_lp(key); st.page=p; applyListPager(key);
|
||||
}
|
||||
|
||||
// ================== Tab 1: 监控 ==================
|
||||
let _statusData=null;
|
||||
let _jobsData=null;
|
||||
@@ -732,7 +857,8 @@ async function loadMonitor(){
|
||||
}catch(e){console.error(e);}
|
||||
}
|
||||
|
||||
// 渲染设备表格(带排序,复选状态保持)
|
||||
// 渲染设备表格(带排序+搜索+分页,复选状态保持)
|
||||
let _sortedDevices=[];
|
||||
function renderDevices(){
|
||||
if(!_statusData||!_statusData.devices){return;}
|
||||
let devices=_statusData.devices.slice();
|
||||
@@ -748,7 +874,18 @@ function renderDevices(){
|
||||
return (va-vb)*dir;
|
||||
});
|
||||
}
|
||||
const rows=devices.map(dev=>{
|
||||
_sortedDevices=devices;
|
||||
setListPager('devices',{
|
||||
data:()=>_sortedDevices,
|
||||
filter:(dev,kw)=>((dev.serial||'').toLowerCase().includes(kw)||(dev.model||'').toLowerCase().includes(kw)||(dev.foreground_app||'').toLowerCase().includes(kw)||(dev.task_job||'').toLowerCase().includes(kw)||(dev.current_action||'').toLowerCase().includes(kw)),
|
||||
render:renderDeviceRow,
|
||||
tbody:'tb-devices',pager:'pager-devices',pageSize:10,
|
||||
empty:'<tr><td colspan="11" class="empty">暂无设备</td></tr>'
|
||||
});
|
||||
applyListPager('devices');
|
||||
initListSearch('devices','search-devices');
|
||||
}
|
||||
function renderDeviceRow(dev){
|
||||
const stf=dev.stf_occupied?'<span class="label label-success">占用</span>':'<span class="label label-default">空闲</span>';
|
||||
const offline=dev.present?'':' <span class="label label-danger">离线</span>';
|
||||
const st=STATUS_MAP[dev.worker_status]||{t:dev.worker_status,c:'default'};
|
||||
@@ -762,6 +899,8 @@ function renderDevices(){
|
||||
const prog=fmtProgress(dev.progress, dev.worker_status);
|
||||
const tname=dev.task_job?'<span class="label label-info">'+esc(dev.task_job)+'</span>':'<span class="text-muted">-</span>';
|
||||
const retry=dev.attempt>0?' <span class="text-muted">'+dev.attempt+'次</span>':'';
|
||||
// 预计结束时间(设置了运行时长上限时显示)
|
||||
const endInfo=(dev.worker_status==='running'&&dev.end_time)?'<div style="font-size:11px;color:#94a3b8">预计结束 '+new Date(dev.end_time*1000).toLocaleTimeString()+'</div>':'';
|
||||
const stopBtn=(dev.worker_status==='running'||dev.worker_status==='connecting')?'<button class="btn btn-danger btn-xs" onclick="apiAction(\'/api/stop_device\',{serial:\''+esc(dev.serial)+'\'})">停止</button>':'';
|
||||
// 截图按钮(任何状态都可看,离线时会报错)
|
||||
const shotBtn='<button class="btn btn-xs" onclick="openScreenshot(\''+esc(dev.serial)+'\')" title="查看设备画面(不影响任务)">截图</button>';
|
||||
@@ -772,9 +911,7 @@ function renderDevices(){
|
||||
const checked=_selectedSerials.has(dev.serial)?'checked':'';
|
||||
const rowCls=_selectedSerials.has(dev.serial)?'row-checked':'';
|
||||
const cb='<input type="checkbox" '+checked+' onclick="toggleDevice(\''+esc(dev.serial)+'\',this.checked)">';
|
||||
return '<tr class="'+rowCls+'"><td class="col-cb">'+cb+'</td><td>'+devLabel+'</td><td>'+esc(dev.model)+'</td><td>'+stf+offline+'</td><td>'+wb+'</td><td>'+fg+'</td><td>'+prog+'</td><td>'+tname+retry+'</td><td class="text-muted">'+esc(dev.current_action||'-')+'</td><td class="err-text" title="'+esc(dev.last_error||'')+'">'+esc(dev.last_error||'-')+'</td><td>'+btns+'</td></tr>';
|
||||
});
|
||||
document.getElementById('tb-devices').innerHTML=rows.length?rows.join(''):'<tr><td colspan="11" class="empty">暂无设备</td></tr>';
|
||||
return '<tr class="'+rowCls+'"><td class="col-cb">'+cb+'</td><td>'+devLabel+'</td><td>'+esc(dev.model)+'</td><td>'+stf+offline+'</td><td>'+wb+'</td><td>'+fg+'</td><td>'+prog+'</td><td>'+tname+retry+endInfo+'</td><td class="text-muted">'+esc(dev.current_action||'-')+'</td><td class="err-text" title="'+esc(dev.last_error||'')+'">'+esc(dev.last_error||'-')+'</td><td>'+btns+'</td></tr>';
|
||||
}
|
||||
|
||||
async function loadJobsForMonitor(){
|
||||
@@ -899,7 +1036,17 @@ async function loadTasks(){
|
||||
|
||||
function renderTaskTable(){
|
||||
const jobs=_jobsData||[];
|
||||
document.getElementById('tb-tasks').innerHTML=jobs.length?jobs.map(j=>{
|
||||
setListPager('tasks',{
|
||||
data:()=>jobs,
|
||||
filter:(j,kw)=>((j.name||'').toLowerCase().includes(kw)||(j.task_type||'').toLowerCase().includes(kw)||(j.id||'').toLowerCase().includes(kw)),
|
||||
render:renderTaskRow,
|
||||
tbody:'tb-tasks',pager:'pager-tasks',pageSize:10,
|
||||
empty:'<tr><td colspan="8" class="empty">暂无任务,点击"新建任务"创建</td></tr>'
|
||||
});
|
||||
applyListPager('tasks');
|
||||
initListSearch('tasks','search-tasks');
|
||||
}
|
||||
function renderTaskRow(j){
|
||||
const tgt=TARGET_MAP[j.target.mode]||j.target.mode;
|
||||
const tgtDetail=j.target.mode==='group'?(j.target.group_name||''):(j.target.mode==='serial'?(j.target.serial||''):'');
|
||||
const sched=j.schedule.mode==='cron_stop'?('启停 '+esc(j.schedule.cron||'')+'→'+esc(j.schedule.stop_cron||'')):
|
||||
@@ -921,7 +1068,6 @@ function renderTaskTable(){
|
||||
'<button class="btn btn-danger btn-xs" onclick="deleteTask(\''+j.id+'\')">删除</button>'+
|
||||
'</td>'+
|
||||
'</tr>';
|
||||
}).join(''):'<tr><td colspan="8" class="empty">暂无任务,点击"新建任务"创建</td></tr>';
|
||||
}
|
||||
|
||||
function openTaskModal(jobId){
|
||||
@@ -982,8 +1128,8 @@ function openTaskModal(jobId){
|
||||
'</div>'+
|
||||
'</div>';
|
||||
|
||||
// 初始化步骤编辑器(generic_steps 时显示,其他隐藏)
|
||||
_stepEditor.init(j.params&&j.params.steps?j.params.steps:[],j.params&&j.params.max_duration?j.params.max_duration:0);
|
||||
// 初始化步骤编辑器(generic_steps 时显示,其他隐藏);jobId 作为草稿 key
|
||||
_stepEditor.init(j.params&&j.params.steps?j.params.steps:[],j.params&&j.params.max_duration?j.params.max_duration:0,jobId||'');
|
||||
|
||||
document.getElementById('modal-footer').innerHTML=
|
||||
'<button class="btn" onclick="closeModal()">取消</button>'+
|
||||
@@ -1021,7 +1167,7 @@ var STEP_LIB=[
|
||||
{type:'click',label:'点击元素',icon:'✦',params:{selector_type:'xpath',selector_value:'',wait_timeout:2}},
|
||||
{type:'input_text',label:'输入文字',icon:'⌨',params:{mode:'random',texts:'你好\n有趣\n支持',fixed_text:''}},
|
||||
{type:'wait',label:'等待',icon:'⏱',params:{min:1.0,max:3.0}},
|
||||
{type:'loop',label:'循环块',icon:'↻',params:{max_iterations:10,children:[]}},
|
||||
{type:'loop',label:'循环块',icon:'↻',params:{loop_mode:'rounds',max_iterations:10,loop_duration:600,children:[]}},
|
||||
{type:'group',label:'动作组',icon:'📦',params:{children:[]}}
|
||||
];
|
||||
var _customActions=[]; // 后端加载的自定义动作
|
||||
@@ -1041,15 +1187,39 @@ var _stepEditor={
|
||||
_steps:[],
|
||||
_selected:[],
|
||||
_dragSuppressClick:false,
|
||||
init:function(steps,maxDuration){
|
||||
this._steps=steps.length?JSON.parse(JSON.stringify(steps)):[];
|
||||
init:function(steps,maxDuration,draftKey,canvasId,libId){
|
||||
this._draftKey=draftKey||'new';
|
||||
this._selected=[];
|
||||
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();
|
||||
// max_duration 输入框(放在 se-head 旁边)
|
||||
// 实时同步:画布内任何输入(含下拉/勾选)都同步回 _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');
|
||||
@@ -1068,7 +1238,8 @@ var _stepEditor={
|
||||
});
|
||||
},
|
||||
_renderLib:function(){
|
||||
var lib=document.getElementById('se-lib');
|
||||
var lib=this._lib;
|
||||
if(!lib)return; // 未初始化编辑器(如仅加载动作列表时)跳过
|
||||
var items=STEP_LIB.map(function(s){
|
||||
return '<div class="lib-item" draggable="true" data-type="'+s.type+'">'+
|
||||
'<span class="ico">'+s.icon+'</span><span>'+s.label+'</span></div>';
|
||||
@@ -1115,12 +1286,14 @@ var _stepEditor={
|
||||
});
|
||||
},
|
||||
_renderCanvas:function(){
|
||||
this._renderStepList(document.getElementById('se-canvas'),this._steps);
|
||||
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='';
|
||||
@@ -1148,10 +1321,10 @@ var _stepEditor={
|
||||
'<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._path(parentArr)+'\')">↑</button>'+
|
||||
'<button title="下移" onclick="_stepEditor.move('+idx+',1,\''+this._path(parentArr)+'\')">↓</button>'+
|
||||
'<button title="复制" onclick="_stepEditor.duplicate('+idx+',\''+this._path(parentArr)+'\')">⧉</button>'+
|
||||
'<button title="删除" onclick="_stepEditor.remove('+idx+',\''+this._path(parentArr)+'\')">✕</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;
|
||||
@@ -1165,7 +1338,7 @@ var _stepEditor={
|
||||
card.appendChild(body);
|
||||
// 拖拽排序
|
||||
card.addEventListener('dragstart',function(e){
|
||||
e.dataTransfer.setData('text/plain','move:'+idx+':'+self._path(parentArr));
|
||||
e.dataTransfer.setData('text/plain','move:'+idx+':'+self._arrId(parentArr));
|
||||
e.dataTransfer.effectAllowed='move';
|
||||
card.classList.add('dragging');
|
||||
});
|
||||
@@ -1177,6 +1350,7 @@ var _stepEditor={
|
||||
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);
|
||||
});
|
||||
@@ -1222,41 +1396,66 @@ var _stepEditor={
|
||||
h+='<div class="form-group" data-mode="fixed" style="display:none"><label>指定文字</label><input type="text" class="form-control" data-key="fixed_text" value="'+esc(p.fixed_text||'')+'"></div>';
|
||||
h+='<div class="help">需先用"点击元素"步骤定位到输入框,本步骤只负责输入文字</div>';
|
||||
}else if(step.type==='loop'){
|
||||
h+='<div class="form-group"><label>最大循环次数</label><input type="number" class="form-control" data-key="max_iterations" value="'+(p.max_iterations||10)+'" min="1" max="1000"></div>';
|
||||
h+='<div class="help">将下方子步骤(拖拽到本卡片内)循环执行指定次数</div>';
|
||||
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"'+(p.loop_mode!=='time'?' selected':'')+'>按轮次</option>'+
|
||||
'<option value="time"'+(p.loop_mode==='time'?' selected':'')+'>按时间</option></select></div>'+
|
||||
'<div class="form-group" data-lmode="rounds"><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: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()">存为自定义动作</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>';
|
||||
// 可编辑 label
|
||||
h+='<div class="form-group" style="margin-top:8px"><label>步骤名称(可选)</label><input type="text" class="form-control" data-key="_label" value="'+esc(step.label||'')+'" placeholder="留空用默认"></div>';
|
||||
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();});
|
||||
// 画布空白处也能接收 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');
|
||||
if(e.target===container||e.target.classList.contains('se-empty')){
|
||||
self._handleDrop(e,arr,-1);
|
||||
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];
|
||||
if(fromPath===this._path(arr)&&fromIdx!==idx){
|
||||
if(fromPath===this._arrId(arr)&&fromIdx!==idx){
|
||||
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);
|
||||
this._renderCanvas();
|
||||
rerender();
|
||||
}
|
||||
}else if(data.startsWith('action:')){
|
||||
// 从自定义动作库拖入 → 展开为 group 步骤
|
||||
@@ -1269,7 +1468,7 @@ var _stepEditor={
|
||||
this._ensureIds(ns.params.children);
|
||||
if(idx<0||idx>=arr.length)arr.push(ns);
|
||||
else arr.splice(idx,0,ns);
|
||||
this._renderCanvas();
|
||||
rerender();
|
||||
}
|
||||
}else if(data.startsWith('type:')){
|
||||
// 从系统操作库拖入
|
||||
@@ -1279,7 +1478,7 @@ var _stepEditor={
|
||||
var ns2=this._makeStep(lib);
|
||||
if(idx<0||idx>=arr.length)arr.push(ns2);
|
||||
else arr.splice(idx,0,ns2);
|
||||
this._renderCanvas();
|
||||
rerender();
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1289,12 +1488,14 @@ var _stepEditor={
|
||||
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));
|
||||
@@ -1310,6 +1511,7 @@ var _stepEditor={
|
||||
});
|
||||
},
|
||||
toggleSelect:function(stepId){
|
||||
this._syncFromDom();
|
||||
if(!this._selected)this._selected=[];
|
||||
var i=this._selected.indexOf(stepId);
|
||||
if(i>=0)this._selected.splice(i,1);
|
||||
@@ -1368,8 +1570,32 @@ var _stepEditor={
|
||||
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';
|
||||
});
|
||||
},
|
||||
// 给数组分配唯一 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];
|
||||
},
|
||||
move:function(idx,dir,path){
|
||||
var arr=path==='root'?this._steps:this._findLoopChildren();
|
||||
this._syncFromDom(); // 先同步 DOM 编辑,避免重渲染丢内容
|
||||
var arr=this._resolveArr(path);
|
||||
if(!arr)return;
|
||||
var ni=idx+dir;
|
||||
if(ni<0||ni>=arr.length)return;
|
||||
@@ -1377,7 +1603,8 @@ var _stepEditor={
|
||||
this._renderCanvas();
|
||||
},
|
||||
duplicate:function(idx,path){
|
||||
var arr=path==='root'?this._steps:this._findLoopChildren();
|
||||
this._syncFromDom();
|
||||
var arr=this._resolveArr(path);
|
||||
if(!arr)return;
|
||||
var copy=JSON.parse(JSON.stringify(arr[idx]));
|
||||
copy.id=_newStepId();
|
||||
@@ -1386,24 +1613,16 @@ var _stepEditor={
|
||||
this._renderCanvas();
|
||||
},
|
||||
remove:function(idx,path){
|
||||
var arr=path==='root'?this._steps:this._findLoopChildren();
|
||||
this._syncFromDom();
|
||||
var arr=this._resolveArr(path);
|
||||
if(!arr)return;
|
||||
arr.splice(idx,1);
|
||||
this._renderCanvas();
|
||||
},
|
||||
_findLoopChildren:function(){
|
||||
// 简化:找到第一个 loop 的 children(当前只支持单层 loop 编辑)
|
||||
for(var i=0;i<this._steps.length;i++){
|
||||
if(this._steps[i].type==='loop'&&this._steps[i].params&&this._steps[i].params.children){
|
||||
return this._steps[i].params.children;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
collect:function(){
|
||||
// 先把当前 DOM 上的参数值同步回 _steps
|
||||
var self=this;
|
||||
this._syncParams(document.getElementById('se-canvas'),this._steps);
|
||||
this._syncParams(this._canvas,this._steps);
|
||||
return JSON.parse(JSON.stringify(this._steps));
|
||||
},
|
||||
validate:function(steps){
|
||||
@@ -1463,6 +1682,24 @@ var _stepEditor={
|
||||
}
|
||||
});
|
||||
},
|
||||
// 把画布 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;
|
||||
@@ -1564,16 +1801,17 @@ var _stepEditor={
|
||||
var container=document.getElementById('el-shot-container');
|
||||
var img=container.querySelector('img');
|
||||
if(!img||!this._elData)return;
|
||||
// 等图片加载完
|
||||
var self=this;
|
||||
function draw(){
|
||||
// 清除旧 overlay
|
||||
// 清除旧 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]"
|
||||
@@ -1583,19 +1821,38 @@ var _stepEditor={
|
||||
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||'';
|
||||
ov.onclick=function(e){
|
||||
e.stopPropagation();
|
||||
self._highlightEl(i);
|
||||
};
|
||||
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;
|
||||
@@ -1606,9 +1863,9 @@ var _stepEditor={
|
||||
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');});
|
||||
// 高亮当前(overlays 基于 _elData 原始索引)
|
||||
var overlays=picker.querySelectorAll('.el-shot-overlay');
|
||||
if(overlays[i])overlays[i].classList.add('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){
|
||||
@@ -1651,6 +1908,8 @@ var _stepEditor={
|
||||
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');
|
||||
@@ -1706,10 +1965,10 @@ async function saveTask(jobId){
|
||||
|
||||
if(jobId){
|
||||
const r=await apiPut('/api/jobs/'+jobId,data);
|
||||
handleResult(r,()=>{closeModal();loadTasks();});
|
||||
handleResult(r,()=>{_stepEditor.clearDraft();closeModal();loadTasks();});
|
||||
}else{
|
||||
const r=await apiPost('/api/jobs',data);
|
||||
handleResult(r,()=>{closeModal();loadTasks();});
|
||||
handleResult(r,()=>{_stepEditor.clearDraft();closeModal();loadTasks();});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1719,6 +1978,82 @@ async function deleteTask(jobId){
|
||||
handleResult(r,()=>loadTasks());
|
||||
}
|
||||
|
||||
// ================== 自定义动作管理 ==================
|
||||
async function loadCustomActions(){
|
||||
const r=await apiGet('/api/custom_actions'); if(!r||!r.ok)return;
|
||||
_customActions=r.actions||[];
|
||||
setListPager('actions',{
|
||||
data:()=>_customActions,
|
||||
filter:(a,kw)=>((a.name||'').toLowerCase().includes(kw)),
|
||||
render:renderActionRow,
|
||||
tbody:'tb-actions',pager:'pager-actions',pageSize:10,
|
||||
empty:'<tr><td colspan="5" class="empty">暂无自定义动作,可点击"新建动作"或用任务编辑器☑选中步骤打包</td></tr>'
|
||||
});
|
||||
applyListPager('actions');
|
||||
initListSearch('actions','search-actions');
|
||||
_stepEditor._renderLib(); // 刷新步骤编辑器左侧动作库
|
||||
}
|
||||
function renderActionRow(a){
|
||||
const n=(a.steps||[]).length;
|
||||
return '<tr>'+
|
||||
'<td style="font-size:20px">'+esc(a.icon||'📦')+'</td>'+
|
||||
'<td><strong>'+esc(a.name)+'</strong></td>'+
|
||||
'<td>'+n+'</td>'+
|
||||
'<td style="font-size:12px;color:#94a3b8">'+esc(a.created_at||'-')+'</td>'+
|
||||
'<td>'+
|
||||
'<button class="btn btn-xs" onclick="openActionModal(\''+a.id+'\')">编辑</button> '+
|
||||
'<button class="btn btn-danger btn-xs" onclick="deleteAction(\''+a.id+'\')">删除</button>'+
|
||||
'</td>'+
|
||||
'</tr>';
|
||||
}
|
||||
function openActionModal(actionId){
|
||||
const action=actionId?_customActions.find(a=>a.id===actionId):null;
|
||||
const isEdit=!!action;
|
||||
document.getElementById('action-modal-title').textContent=isEdit?'编辑动作':'新建动作';
|
||||
document.getElementById('action-modal-body').innerHTML=
|
||||
'<div class="form-row">'+
|
||||
'<div class="form-group"><label>动作名称</label><input type="text" class="form-control" id="a-name" value="'+esc(action?action.name:'')+'" placeholder="如:点赞动作"></div>'+
|
||||
'<div class="form-group"><label>图标</label><input type="text" class="form-control" id="a-icon" value="'+esc(action?(action.icon&&action.icon!=='📦'?action.icon:''):'')+'" placeholder="📦" style="width:80px"></div>'+
|
||||
'</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-body">'+
|
||||
'<div class="se-lib" id="action-lib"><div class="lib-title">操作库</div></div>'+
|
||||
'<div class="se-canvas" id="action-canvas"></div>'+
|
||||
'</div>'+
|
||||
'</div>';
|
||||
document.getElementById('action-modal-footer').innerHTML=
|
||||
'<button class="btn" onclick="closeActionModal()">取消</button>'+
|
||||
'<button class="btn btn-primary" onclick="saveAction('+(isEdit?'\''+actionId+'\'':'null')+')">保存动作</button>';
|
||||
// 用独立画布初始化步骤编辑器(不与任务编辑器冲突)
|
||||
_stepEditor.init(action&&action.steps?action.steps:[], 0, 'action:'+(actionId||'new'), 'action-canvas', 'action-lib');
|
||||
document.getElementById('action-modal-overlay').classList.add('show');
|
||||
}
|
||||
async function saveAction(actionId){
|
||||
const name=document.getElementById('a-name').value.trim();
|
||||
if(!name){showToast('动作名不能为空','error');return;}
|
||||
const icon=document.getElementById('a-icon').value.trim()||'📦';
|
||||
const steps=_stepEditor.collect();
|
||||
if(!steps.length){showToast('至少需要1个步骤','error');return;}
|
||||
const data={name:name,icon:icon,steps:steps};
|
||||
if(actionId){
|
||||
const r=await apiPut('/api/custom_actions/'+actionId,data);
|
||||
handleResult(r,()=>{_stepEditor.clearDraft();closeActionModal();loadCustomActions();});
|
||||
}else{
|
||||
const r=await apiPost('/api/custom_actions',data);
|
||||
handleResult(r,()=>{_stepEditor.clearDraft();closeActionModal();loadCustomActions();});
|
||||
}
|
||||
}
|
||||
async function deleteAction(id){
|
||||
if(!confirm('确认删除此动作?'))return;
|
||||
const r=await apiDelete('/api/custom_actions/'+id);
|
||||
handleResult(r,()=>loadCustomActions());
|
||||
}
|
||||
function closeActionModal(){
|
||||
document.getElementById('action-modal-overlay').classList.remove('show');
|
||||
}
|
||||
|
||||
// ================== Tab 3: 分组 ==================
|
||||
async function loadGroups(){
|
||||
const r=await apiGet('/api/groups');if(!r)return;
|
||||
@@ -1730,7 +2065,17 @@ async function loadGroups(){
|
||||
|
||||
function renderGroupTable(){
|
||||
const groups=_groupsList||[];
|
||||
document.getElementById('tb-groups').innerHTML=groups.length?groups.map(g=>{
|
||||
setListPager('groups',{
|
||||
data:()=>groups,
|
||||
filter:(g,kw)=>((g.name||'').toLowerCase().includes(kw)||(g.description||'').toLowerCase().includes(kw)||(g.serials||[]).join(' ').toLowerCase().includes(kw)),
|
||||
render:renderGroupRow,
|
||||
tbody:'tb-groups',pager:'pager-groups',pageSize:10,
|
||||
empty:'<tr><td colspan="5" class="empty">暂无分组,点击"新建分组"创建</td></tr>'
|
||||
});
|
||||
applyListPager('groups');
|
||||
initListSearch('groups','search-groups');
|
||||
}
|
||||
function renderGroupRow(g){
|
||||
const serials=g.serials||[];
|
||||
return '<tr>'+
|
||||
'<td><strong>'+esc(g.name)+'</strong></td>'+
|
||||
@@ -1742,7 +2087,6 @@ function renderGroupTable(){
|
||||
'<button class="btn btn-danger btn-xs" onclick="deleteGroup(\''+esc(g.name)+'\')">删除</button>'+
|
||||
'</td>'+
|
||||
'</tr>';
|
||||
}).join(''):'<tr><td colspan="5" class="empty">暂无分组,点击"新建分组"创建</td></tr>';
|
||||
}
|
||||
|
||||
function openGroupModal(name){
|
||||
@@ -1823,7 +2167,17 @@ function toggleLogAuto(){
|
||||
async function loadUsers(){
|
||||
const r=await apiGet('/api/users');if(!r)return;
|
||||
const users=r.users||[];
|
||||
document.getElementById('tb-users').innerHTML=users.length?users.map(u=>{
|
||||
setListPager('users',{
|
||||
data:()=>users,
|
||||
filter:(u,kw)=>(u.username||'').toLowerCase().includes(kw),
|
||||
render:renderUserRow,
|
||||
tbody:'tb-users',pager:'pager-users',pageSize:10,
|
||||
empty:'<tr><td colspan="4" class="empty">暂无用户</td></tr>'
|
||||
});
|
||||
applyListPager('users');
|
||||
initListSearch('users','search-users');
|
||||
}
|
||||
function renderUserRow(u){
|
||||
const admin=u.is_admin?'<span class="label label-primary">管理员</span>':'<span class="label label-default">普通</span>';
|
||||
return '<tr>'+
|
||||
'<td>'+u.id+'</td>'+
|
||||
@@ -1834,7 +2188,6 @@ async function loadUsers(){
|
||||
(u.username==='admin'?'':'<button class="btn btn-danger btn-xs" onclick="deleteUser('+u.id+')">删除</button>')+
|
||||
'</td>'+
|
||||
'</tr>';
|
||||
}).join(''):'<tr><td colspan="4" class="empty">暂无用户</td></tr>';
|
||||
}
|
||||
|
||||
function openUserModal(uid){
|
||||
@@ -1888,6 +2241,7 @@ async function deleteUser(uid){
|
||||
// ================== Tab: 应用 ==================
|
||||
let _apksCache=[];
|
||||
let _installTimer=null;
|
||||
let _devAppsData=[];
|
||||
|
||||
function fmtSize(b){
|
||||
if(!b)return '0 B';
|
||||
@@ -1901,12 +2255,19 @@ async function loadApks(){
|
||||
const apks=r.apks||[];
|
||||
_apksCache=apks;
|
||||
document.getElementById('apk-hint').textContent='共 '+apks.length+' 个应用';
|
||||
if(!apks.length){
|
||||
document.getElementById('tb-apks').innerHTML='<tr><td colspan="6" class="empty">暂无APK,点击"上传APK"上传</td></tr>';
|
||||
return;
|
||||
}
|
||||
document.getElementById('tb-apks').innerHTML=apks.map(a=>
|
||||
'<tr>'+
|
||||
setListPager('apks',{
|
||||
data:()=>apks,
|
||||
filter:(a,kw)=>((a.display_name||'').toLowerCase().includes(kw)||(a.package_name||'').toLowerCase().includes(kw)),
|
||||
render:renderApkRow,
|
||||
tbody:'tb-apks',pager:'pager-apks',pageSize:10,
|
||||
empty:'<tr><td colspan="6" class="empty">暂无APK,点击"上传APK"上传</td></tr>'
|
||||
});
|
||||
applyListPager('apks');
|
||||
initListSearch('apks','search-apks');
|
||||
await fillAppsDeviceList();
|
||||
}
|
||||
function renderApkRow(a){
|
||||
return '<tr>'+
|
||||
'<td><strong>'+esc(a.display_name||'(未命名)')+'</strong></td>'+
|
||||
'<td style="font-family:monospace;font-size:12px;color:#64748b">'+esc(a.package_name||'-')+'</td>'+
|
||||
'<td>'+(a.version_name?esc(a.version_name):'-')+'</td>'+
|
||||
@@ -1916,8 +2277,58 @@ async function loadApks(){
|
||||
'<button class="btn btn-primary btn-xs" onclick="openInstallModal(\''+a.id+'\')">安装</button> '+
|
||||
'<button class="btn btn-danger btn-xs" onclick="deleteApk(\''+a.id+'\')">删除</button>'+
|
||||
'</td>'+
|
||||
'</tr>'
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
// 填充"设备已装应用"的设备下拉(保留当前选择)
|
||||
async function fillAppsDeviceList(){
|
||||
const sr=await apiGet('/api/status');if(!sr||!sr.ok)return;
|
||||
const sel=document.getElementById('apps-device');
|
||||
if(!sel)return;
|
||||
const cur=sel.value;
|
||||
const devices=(sr.devices||[]).filter(d=>d.present);
|
||||
sel.innerHTML='<option value="">选择设备...</option>'+devices.map(d=>
|
||||
'<option value="'+esc(d.serial)+'">'+esc((d.serial||'').replace(/:5555$/,'')+' ('+(d.device_name||d.model||'未知设备')+')')+'</option>'
|
||||
).join('');
|
||||
if(cur)sel.value=cur;
|
||||
}
|
||||
|
||||
// 查看指定设备已安装应用
|
||||
async function loadDeviceApps(){
|
||||
const sel=document.getElementById('apps-device');
|
||||
const serial=sel.value;
|
||||
if(!serial){showToast('请先选择设备','error');return;}
|
||||
const hint=document.getElementById('apps-hint');
|
||||
const tb=document.getElementById('tb-device-apps');
|
||||
hint.textContent='加载中...';
|
||||
const r=await apiGet('/api/devices/'+encodeURIComponent(serial)+'/apps');
|
||||
hint.textContent='';
|
||||
if(!r||!r.ok){
|
||||
tb.innerHTML='<tr><td colspan="4" class="empty">'+(r&&r.error?esc(r.error):'获取失败')+'</td></tr>';
|
||||
showToast('获取失败','error');return;
|
||||
}
|
||||
const apps=r.apps||[];
|
||||
if(!apps.length){tb.innerHTML='<tr><td colspan="4" class="empty">无应用</td></tr>';return;}
|
||||
const userApps=apps.filter(a=>/^\/data\//.test(a.path||'')).length;
|
||||
hint.textContent='共 '+apps.length+' 个应用(用户安装 '+userApps+')';
|
||||
_devAppsData=apps;
|
||||
setListPager('devapps',{
|
||||
data:()=>_devAppsData||[],
|
||||
filter:(a,kw)=>((a.label||a.package||'').toLowerCase().includes(kw)||(a.package||'').toLowerCase().includes(kw)||(a.path||'').toLowerCase().includes(kw)),
|
||||
render:renderDevAppRow,
|
||||
tbody:'tb-device-apps',pager:'pager-devapps',pageSize:20,
|
||||
empty:'<tr><td colspan="4" class="empty">无应用</td></tr>'
|
||||
});
|
||||
applyListPager('devapps');
|
||||
initListSearch('devapps','search-devapps');
|
||||
}
|
||||
function renderDevAppRow(a){
|
||||
return '<tr>'+
|
||||
'<td><strong>'+esc(a.label||a.package)+'</strong>'+(a.label?' <span style="font-size:11px;color:#94a3b8;font-family:monospace">'+esc(a.package)+'</span>':'')+'</td>'+
|
||||
'<td>'+(a.version_name?esc(a.version_name):'-')+'</td>'+
|
||||
'<td style="font-size:12px;color:#94a3b8">'+(a.version_code!=null?esc(a.version_code):'-')+'</td>'+
|
||||
'<td style="font-size:11px;color:#94a3b8;word-break:break-all">'+esc(a.path||'-')+'</td>'+
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
async function uploadApk(input){
|
||||
@@ -1994,7 +2405,8 @@ async function openInstallModal(apkId){
|
||||
online.map(d=>{
|
||||
const running=d.worker_status==='running'||d.worker_status==='connecting';
|
||||
const st='<span class="label label-'+(running?'success':'default')+'" style="margin-left:6px">'+(running?'运行中':'空闲')+'</span>';
|
||||
const label=d.device_name||d.model||d.serial;
|
||||
// serial 唯一,显示 IP + 型号便于区分同型号设备
|
||||
const label=(d.serial||'').replace(/:5555$/,'')+' · '+(d.device_name||d.model||'设备');
|
||||
return '<label><input type="checkbox" value="'+esc(d.serial)+'"'+(running?'':' checked')+'>'+esc(label)+st+'</label>';
|
||||
}).join('')+
|
||||
'</div>';
|
||||
|
||||
Reference in New Issue
Block a user