feat: 前端整体增强(应用查看UI/元素选择/步骤编辑器/拖拽/概率/分页/动作管理)

- 应用Tab:设备已装应用区块(应用名+版本+versionCode+路径)、安装弹窗设备显示 IP·型号
- 元素抓取:截图按 bounds 命中测试选最小元素,跳过全屏容器 overlay
- 步骤编辑器:输入实时同步+localStorage草稿自动保存(防丢内容)
- 拖拽修复:画布 drop 只绑定一次+stopPropagation+延迟渲染,可拖入循环块且不重复
- 步骤概率触发字段、循环按轮次/时间切换、监控页预计结束时间
- 全部列表统一搜索+分页(设备/任务/应用/分组/用户/动作)
- 自定义动作管理模块(列表/新建/编辑/删除)、步骤编辑器容器化、嵌套循环移动修复
This commit is contained in:
2026-08-08 16:31:06 +08:00
parent fcbdb0a8a1
commit 473dc1909d
+479 -67
View File
@@ -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()">&times;</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,&lt;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>';