refactor: 列表统一为 setListPager 搜索+分页+排序(_lpSort 升序/降序/取消,中文数字感知,空值排后);监控设备表并入统一排序;STF设备管理/应用版本管理接入统一列表

This commit is contained in:
2026-08-11 14:12:52 +08:00
parent 3bd56b4f59
commit 136613bc12
2 changed files with 148 additions and 95 deletions
+120 -77
View File
@@ -60,31 +60,9 @@ async function apiPut(url,body){
}
// ================== 设备列表排序与多选 ==================
let _sortKey=''; // 当前排序字段
let _sortDir=''; // '' | 'asc' | 'desc'
let _selectedSerials=new Set(); // 已勾选的 serial 集合
// 点击表头排序
function sortBy(key){
if(_sortKey===key){
// 同一列:切换方向;已是降序则取消
if(_sortDir==='asc'){_sortDir='desc';}
else if(_sortDir==='desc'){_sortKey='';_sortDir='';}
else{_sortDir='asc';}
}else{
_sortKey=key;_sortDir='asc';
}
// 更新表头样式
document.querySelectorAll('.table thead th.sortable').forEach(th=>{
th.classList.remove('sort-asc','sort-desc');
if(th.dataset.key===_sortKey){
th.classList.add(_sortDir==='asc'?'sort-asc':(_sortDir==='desc'?'sort-desc':''));
}
});
renderDevices();
}
// 排序比较:从设备对象取排序值
// 排序比较:从设备对象取排序值(统一排序用,见 setListPager/sortValue)
function _sortValue(dev,key){
let v;
if(key==='progress'){
@@ -351,6 +329,8 @@ function toggleClipDevice(cb){
}
// ================== 工具:应用版本管理 ==================
let _appVerRows = []; // [{serial, installed, version_name, version_code, error}]
async function queryAppVers(){
const pkg = document.getElementById('appver-pkg').value.trim();
if(!pkg){showToast('请输入包名','error');return;}
@@ -359,24 +339,41 @@ async function queryAppVers(){
try{
const r = await apiPost('/api/tools/appver', {pkg});
if(!r || !r.ok){showToast('查询失败: '+(r&&r.error||''),'error');return;}
const rows = Object.entries(r.results||{}).map(([serial,v])=>{
if(v.error)return '<tr><td style="font-family:monospace">'+esc(serial)+'</td>'+
'<td><span class="label label-warning">查询失败</span></td><td colspan="2">'+esc(v.error)+'</td></tr>';
if(v.installed)return '<tr><td style="font-family:monospace">'+esc(serial)+'</td>'+
'<td><span class="label label-success">已安装</span></td>'+
'<td>'+esc(v.version_name||'')+'</td><td style="font-family:monospace">'+esc(v.version_code||'')+'</td></tr>';
return '<tr><td style="font-family:monospace">'+esc(serial)+'</td>'+
'<td><span class="label label-default">未安装</span></td><td>-</td><td>-</td></tr>';
}).join('');
document.getElementById('tb-appver').innerHTML = rows || '<tr><td colspan="4" class="empty">无可用设备</td></tr>';
_appVerRows = Object.entries(r.results||{}).map(([serial,v])=>({serial, ...v}));
setListPager('appver',{
data:()=>_appVerRows,
filter:(x,kw)=>((x.serial||'').toLowerCase().includes(kw)||(x.version_name||'').toLowerCase().includes(kw)||(x.version_code||'').toLowerCase().includes(kw)),
sortValue:(x,k)=>{
if(k==='state'){ if(x.error)return 2; return x.installed?0:1; }
if(k==='code')return parseInt(x.version_code)||0;
if(k==='version')return x.version_name||'';
return x.serial||'';
},
render:renderAppVerRow,
tbody:'tb-appver',pager:'pager-appver',pageSize:10,
empty:'<tr><td colspan="4" class="empty">无可用设备</td></tr>'
});
applyListPager('appver');
initListSearch('appver','search-appver');
document.getElementById('appver-status').textContent = '共 '+(r.total||0)+' 台设备'+(r.fail?','+r.fail+' 台查询失败':'');
if(r.fail)showToast(r.fail+' 台设备查询失败,详见表格','error');
} finally {
btn.disabled = false; btn.textContent = old;
}
}
function renderAppVerRow(x){
if(x.error)return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'+
'<td><span class="label label-warning">查询失败</span></td><td colspan="2">'+esc(x.error)+'</td></tr>';
if(x.installed)return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'+
'<td><span class="label label-success">已安装</span></td>'+
'<td>'+esc(x.version_name||'')+'</td><td style="font-family:monospace">'+esc(x.version_code||'')+'</td></tr>';
return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'+
'<td><span class="label label-default">未安装</span></td><td>-</td><td>-</td></tr>';
}
// ================== 工具:STF 设备管理 ==================
let _stfDevRows = []; // [{ip, inCfg, state}]
async function loadStfDevMgmt(){
const status = document.getElementById('stfdev-status');
const r = await apiGet('/api/stf/devmgmt/status');
@@ -387,26 +384,34 @@ async function loadStfDevMgmt(){
const s = d.serial;
connected[s.endsWith(':5555') ? s.slice(0,-5) : s] = d.state;
});
const ips = Array.from(new Set(configured.concat(Object.keys(connected))));
const tbody = document.getElementById('tb-stfdev');
if(!ips.length){
tbody.innerHTML='<tr><td colspan="4" class="empty">无设备(脚本未配置、220 adb 也无连接)</td></tr>';
status.textContent='';
return;
}
tbody.innerHTML = ips.map(ip=>{
const inCfg = configured.includes(ip);
const st = connected[ip];
const stHtml = st==='device'
? '<span class="label label-success">已连接</span>'
: (st ? '<span class="label label-warning">'+esc(st)+'</span>' : '<span class="label label-default">未连接</span>');
return '<tr><td style="font-family:monospace">'+esc(ip)+'</td>'+
'<td>'+(inCfg?'<span class="label label-info">脚本中</span>':'<span class="label label-default">未配置</span>')+'</td>'+
'<td>'+stHtml+'</td>'+
'<td><button class="btn btn-xs btn-danger" onclick="removeStfDev(\''+esc(ip)+'\')">移除(断开+删脚本)</button></td></tr>';
}).join('');
_stfDevRows = Array.from(new Set(configured.concat(Object.keys(connected)))).map(ip=>({
ip, inCfg: configured.includes(ip), state: connected[ip]||''
}));
setListPager('stfdev',{
data:()=>_stfDevRows,
filter:(x,kw)=>x.ip.toLowerCase().includes(kw),
sortValue:(x,k)=>{
if(k==='cfg')return x.inCfg?1:0;
if(k==='state'){ const o={device:0,offline:1,unauthorized:2,'':9}; return (o[x.state]!==undefined)?o[x.state]:9; }
return x.ip||'';
},
render:renderStfDevRow,
tbody:'tb-stfdev',pager:'pager-stfdev',pageSize:10,
empty:'<tr><td colspan="4" class="empty">无设备(脚本未配置、220 adb 也无连接)</td></tr>'
});
applyListPager('stfdev');
initListSearch('stfdev','search-stfdev');
status.textContent = '脚本配置 '+configured.length+' 台,已连接 '+Object.keys(connected).length+' 台';
}
function renderStfDevRow(x){
const stHtml = x.state==='device'
? '<span class="label label-success">已连接</span>'
: (x.state ? '<span class="label label-warning">'+esc(x.state)+'</span>' : '<span class="label label-default">未连接</span>');
return '<tr><td style="font-family:monospace">'+esc(x.ip)+'</td>'+
'<td>'+(x.inCfg?'<span class="label label-info">脚本中</span>':'<span class="label label-default">未配置</span>')+'</td>'+
'<td>'+stHtml+'</td>'+
'<td><button class="btn btn-xs btn-danger" onclick="removeStfDev(\''+esc(x.ip)+'\')">移除(断开+删脚本)</button></td></tr>';
}
async function addStfDev(){
const ip = document.getElementById('stfdev-ip').value.trim();
@@ -449,18 +454,22 @@ async function injectClipboard(){
}
}
// ================== 通用列表:搜索 + 分页 ==================
// ================== 通用列表:搜索 + 分页 + 排序 ==================
// 用法:
// 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');// 搜索框绑定
// tbody:'tb-id', pager:'pager-id', pageSize:N,
// sortValue:(item,colKey)=>值, // 可排序列的取值函数
// empty:'<tr>...'});
// applyListPager(key); // 数据变化后重渲染(含搜索/翻页/排序状态)
// initListSearch(key,'search-id'); // 搜索框绑定
// _lpSort(key,'colKey'); // 表头排序:th 加 data-sort="colKey" + onclick,
// // 再点切换升降序,第三次取消;排序指示器自动更新
var _LIST_PAGERS={};
function _lp(key){ return _LIST_PAGERS[key] || (_LIST_PAGERS[key]={page:1,kw:'',cfg:null}); }
function _lp(key){ return _LIST_PAGERS[key] || (_LIST_PAGERS[key]={page:1,kw:'',cfg:null,sort:null}); }
function setListPager(key,cfg){
// 只更新配置,不重置 page/kw —— 监控列表 5 秒自动刷新时保持用户当前页
// 只更新配置,不重置 page/kw/sort —— 监控列表 5 秒自动刷新时保持用户当前状态
var st=_lp(key); st.cfg=cfg;
}
function initListSearch(key,inputId){
@@ -471,6 +480,17 @@ function initListSearch(key,inputId){
var st=_lp(key); st.kw=this.value.trim(); st.page=1; applyListPager(key);
});
}
function _lpSort(key,colKey){
var st=_lp(key);
if(st.sort&&st.sort.key===colKey){
if(st.sort.dir===1)st.sort.dir=-1; // 升序 → 降序
else if(st.sort.dir===-1)st.sort=null; // 降序 → 取消排序
else st.sort={key:colKey,dir:1};
}else{
st.sort={key:colKey,dir:1};
}
applyListPager(key);
}
function applyListPager(key){
var st=_lp(key);
if(!st.cfg)return;
@@ -478,6 +498,18 @@ function applyListPager(key){
var all=(cfg.data&&cfg.data())||[];
var kw=st.kw.toLowerCase();
var filtered=kw?all.filter(function(x){return cfg.filter(x,kw);}):all;
// 排序:cfg.sortValue 取列值;空值/缺失恒排最后;字符串用中文数字感知比较(10 排在 9 后)
if(st.sort&&cfg.sortValue){
var sk=st.sort.key, dir=st.sort.dir;
filtered=filtered.slice().sort(function(a,b){
var va=cfg.sortValue(a,sk), vb=cfg.sortValue(b,sk);
if(va===vb)return 0;
if(va==null||va==='')return 1;
if(vb==null||vb==='')return -1;
if(typeof va==='number'&&typeof vb==='number')return (va-vb)*dir;
return String(va).localeCompare(String(vb),'zh-Hans-CN',{numeric:true})*dir;
});
}
var pageSize=cfg.pageSize||10;
var pages=Math.max(1,Math.ceil(filtered.length/pageSize));
if(st.page>pages)st.page=pages;
@@ -485,7 +517,18 @@ function applyListPager(key){
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>');
if(tb){
tb.innerHTML=slice.map(cfg.render).join('')||(cfg.empty||'<tr><td colspan="20" class="empty">无数据</td></tr>');
// 表头排序指示器(th[data-sort],所有表格统一)
if(cfg.sortValue){
var table=tb.closest('table');
if(table)table.querySelectorAll('thead th[data-sort]').forEach(function(th){
th.classList.add('sortable');
th.classList.toggle('sort-asc',!!st.sort&&st.sort.key===th.dataset.sort&&st.sort.dir===1);
th.classList.toggle('sort-desc',!!st.sort&&st.sort.key===th.dataset.sort&&st.sort.dir===-1);
});
}
}
var pg=document.getElementById(cfg.pager);
if(pg)pg.innerHTML=_lpPagerHtml(key,pages,st.page,filtered.length,pageSize);
}
@@ -595,33 +638,20 @@ async function loadSummary(){
}catch(e){console.error(e);}
}
// 渲染设备表格(带排序+搜索+分页,复选状态保持)
let _sortedDevices=[];
// 渲染设备表格(统一排序+搜索+分页,复选状态保持)
let _devicesSig='';
function renderDevices(){
if(!_statusData||!_statusData.devices){return;}
// 5秒自动刷新时若设备数据/分页/排序都没变化,跳过整表重渲染(避免DOM抖动)
// 5秒自动刷新时若设备数据/分页/搜索/排序都没变化,跳过整表重渲染(避免DOM抖动)
const _st=_lp('devices');
const _sig=JSON.stringify({d:_statusData.devices,p:_st.page,k:_st.kw,s:_sortKey+(_sortDir||'')});
const _sig=JSON.stringify({d:_statusData.devices,p:_st.page,k:_st.kw,s:_st.sort});
if(_sig===_devicesSig)return;
_devicesSig=_sig;
let devices=_statusData.devices.slice();
// 排序
if(_sortKey&&_sortDir){
const dir=_sortDir==='asc'?1:-1;
devices.sort((a,b)=>{
let va=_sortValue(a,_sortKey),vb=_sortValue(b,_sortKey);
if(typeof va==='string'&&typeof vb==='string'){
return va.localeCompare(vb)*dir;
}
va=Number(va)||0;vb=Number(vb)||0;
return (va-vb)*dir;
});
}
_sortedDevices=devices;
// 排序由 applyListPager 统一处理(sortValue + _lpSort)
setListPager('devices',{
data:()=>_sortedDevices,
data:()=>_statusData.devices||[],
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)),
sortValue:_sortValue,
render:renderDeviceRow,
tbody:'tb-devices',pager:'pager-devices',pageSize:10,
empty:'<tr><td colspan="11" class="empty">暂无设备</td></tr>'
@@ -804,6 +834,7 @@ function renderTaskTable(){
setListPager('tasks',{
data:()=>jobs,
filter:(j,kw)=>((j.name||'').toLowerCase().includes(kw)||(j.task_type||'').toLowerCase().includes(kw)||(j.id||'').toLowerCase().includes(kw)),
sortValue:(j,k)=>{ if(k==='id')return j.id||''; if(k==='enabled')return j.enabled?1:0; if(k==='next_run')return j.next_run||''; return j[k]||''; },
render:renderTaskRow,
tbody:'tb-tasks',pager:'pager-tasks',pageSize:10,
empty:'<tr><td colspan="9" class="empty">暂无任务,点击"新建任务"创建</td></tr>'
@@ -2124,6 +2155,7 @@ async function loadCustomActions(){
setListPager('actions',{
data:()=>_customActions,
filter:(a,kw)=>((a.name||'').toLowerCase().includes(kw)),
sortValue:(a,k)=>{ if(k==='steps')return (a.steps||[]).length; if(k==='created_at')return a.created_at||''; return a[k]||''; },
render:renderActionRow,
tbody:'tb-actions',pager:'pager-actions',pageSize:10,
empty:'<tr><td colspan="5" class="empty">暂无自定义动作,可点击"新建动作"或用任务编辑器☑选中步骤打包</td></tr>'
@@ -2211,6 +2243,7 @@ function renderGroupTable(){
setListPager('groups',{
data:()=>groups,
filter:(g,kw)=>((g.name||'').toLowerCase().includes(kw)||(g.description||'').toLowerCase().includes(kw)||(g.serials||[]).join(' ').toLowerCase().includes(kw)),
sortValue:(g,k)=>{ if(k==='count')return (g.serials||[]).length; return g[k]||''; },
render:renderGroupRow,
tbody:'tb-groups',pager:'pager-groups',pageSize:10,
empty:'<tr><td colspan="5" class="empty">暂无分组,点击"新建分组"创建</td></tr>'
@@ -2315,6 +2348,7 @@ async function loadUsers(){
setListPager('users',{
data:()=>users,
filter:(u,kw)=>(u.username||'').toLowerCase().includes(kw),
sortValue:(u,k)=>{ if(k==='is_admin')return u.is_admin?1:0; return u[k]||''; },
render:renderUserRow,
tbody:'tb-users',pager:'pager-users',pageSize:10,
empty:'<tr><td colspan="5" class="empty">暂无用户</td></tr>'
@@ -2515,6 +2549,13 @@ async function loadTailscaleDevices(){
setListPager('tailscale',{
data:()=>_tsDevices,
filter:(d,kw)=>((d.hostname||'').toLowerCase().includes(kw)||(d.name||'').toLowerCase().includes(kw)||(d.addresses||[]).join(',').includes(kw)),
sortValue:(d,k)=>{
if(k==='ip')return (d.addresses||[])[0]||'';
if(k==='online'){ if(d.online===true)return 0; if(d.online===false)return 2; return 1; } // null=最近12h在线
if(k==='authorized')return d.authorized?1:0;
if(k==='key_expiry')return d.key_expiry_disabled?1:0;
return d[k]||'';
},
render:renderTsRow,
tbody:'tb-tailscale',pager:'pager-tailscale',pageSize:10,
empty:'<tr><td colspan="8" class="empty">tailnet 暂无设备</td></tr>'
@@ -2637,6 +2678,7 @@ async function loadApks(){
document.getElementById('apk-hint').textContent='共 '+apks.length+' 个应用';
setListPager('apks',{
data:()=>apks,
sortValue:(a,k)=>{ if(k==='size')return a.size||0; if(k==='upload_time')return a.upload_time||''; if(k==='package_name')return a.package_name||''; if(k==='version_name')return a.version_name||''; return a.display_name||''; },
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,
@@ -2695,6 +2737,7 @@ async function loadDeviceApps(){
setListPager('devapps',{
data:()=>_devAppsData||[],
filter:(a,kw)=>((a.label||a.package||'').toLowerCase().includes(kw)||(a.package||'').toLowerCase().includes(kw)||(a.path||'').toLowerCase().includes(kw)),
sortValue:(a,k)=>{ if(k==='version_code')return parseInt(a.version_code)||0; if(k==='version_name')return a.version_name||''; if(k==='label')return (a.label||a.package||''); return a[k]||''; },
render:renderDevAppRow,
tbody:'tb-device-apps',pager:'pager-devapps',pageSize:20,
empty:'<tr><td colspan="4" class="empty">无应用</td></tr>'