按 git 红线撤回:该功能未经 dev 验收就被合并进 main(是我提交时没切分支、
又把自己的自测当成了用户验收)—— main 必须保持"已验收可部署"的状态。
功能本身没问题,代码仍在 **dev**(dad1af8)与 feature 分支上,等设备端 Agent
写出来、端到端验收通过后,再从 dev 合并回 main。
main 内容已回到 66632bb(git diff 66632bb HEAD 为空)。
265 lines
12 KiB
JavaScript
265 lines
12 KiB
JavaScript
// 工具 Tab-应用管理:APK 上传/安装/删除 + 设备已装应用
|
||
// ================== Tab: 应用 ==================
|
||
let _apksCache=[];
|
||
let _installTimer=null;
|
||
let _devAppsData=[];
|
||
|
||
function fmtSize(b){
|
||
if(!b)return '0 B';
|
||
if(b<1024)return b+' B';
|
||
if(b<1048576)return (b/1024).toFixed(1)+' KB';
|
||
return (b/1048576).toFixed(1)+' MB';
|
||
}
|
||
|
||
async function loadApks(){
|
||
const r=await apiGet('/api/apks');if(!r||!r.ok)return;
|
||
const apks=r.apks||[];
|
||
_apksCache=apks;
|
||
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,
|
||
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>'+
|
||
'<td>'+fmtSize(a.size)+'</td>'+
|
||
'<td style="font-size:12px;color:#94a3b8">'+esc(a.upload_time)+'</td>'+
|
||
'<td>'+
|
||
'<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>';
|
||
}
|
||
|
||
// 填充"设备已装应用"的设备下拉(保留当前选择)
|
||
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)),
|
||
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>'
|
||
});
|
||
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){
|
||
if(!input.files||!input.files[0])return;
|
||
const file=input.files[0];
|
||
if(!file.name.toLowerCase().endsWith('.apk')){
|
||
showToast('请选择 .apk 文件','error');return;
|
||
}
|
||
const hint=document.getElementById('apk-hint');
|
||
const fd=new FormData();
|
||
fd.append('file',file);
|
||
// 用 XMLHttpRequest 才能拿到上传进度(fetch 不支持上传进度事件)
|
||
const xhr=new XMLHttpRequest();
|
||
xhr.open('POST','/api/apks/upload');
|
||
if(_csrfToken)xhr.setRequestHeader('X-CSRF-Token',_csrfToken);
|
||
xhr.upload.onprogress=function(e){
|
||
if(e.lengthComputable){
|
||
const pct=Math.round(e.loaded*100/e.total);
|
||
const loaded=(e.loaded/1048576).toFixed(1);
|
||
const total=(e.total/1048576).toFixed(1);
|
||
hint.innerHTML='<span style="color:#f59e0b">正在上传 '+esc(file.name)+' '+pct+'% ('+loaded+'/'+total+' MB)</span>'+
|
||
'<div class="progress" style="width:200px;height:6px;margin-top:4px"><div class="progress-bar" style="width:'+pct+'%"></div></div>';
|
||
}
|
||
};
|
||
xhr.onload=function(){
|
||
if(xhr.status===401){window.location='/login';return;}
|
||
try{
|
||
const data=JSON.parse(xhr.responseText);
|
||
if(data.ok){
|
||
showToast(data.msg||'上传成功','success');
|
||
loadApks();
|
||
}else{
|
||
showToast(data.error||'上传失败','error');
|
||
hint.textContent='';
|
||
}
|
||
}catch(e){
|
||
showToast('解析响应失败','error');
|
||
hint.textContent='';
|
||
}
|
||
};
|
||
xhr.onerror=function(){
|
||
showToast('上传失败:网络错误','error');
|
||
hint.textContent='';
|
||
};
|
||
xhr.send(fd);
|
||
input.value='';
|
||
}
|
||
|
||
async function deleteApk(id){
|
||
if(!confirm('确认删除此APK文件?'))return;
|
||
const r=await apiDelete('/api/apks/'+id);
|
||
handleResult(r,loadApks);
|
||
}
|
||
|
||
async function openInstallModal(apkId){
|
||
const apk=_apksCache.find(a=>a.id===apkId);
|
||
const apkName=apk?(apk.display_name||'APK'):'APK';
|
||
const r=await apiGet('/api/apks/install/devices');
|
||
const online=(r&&r.ok)?(r.devices||[]):[];
|
||
if(!online.length){
|
||
showToast('没有可用设备(设备池或本机 adb 均无)','error');return;
|
||
}
|
||
const body=
|
||
'<div style="margin-bottom:12px">'+
|
||
'<strong>安装应用:</strong><span class="label label-primary">'+esc(apkName)+'</span>'+
|
||
' <span style="font-size:12px;color:#94a3b8">运行中设备将自动跳过</span>'+
|
||
'</div>'+
|
||
'<div style="margin-bottom:8px">'+
|
||
'<button class="btn btn-sm" onclick="document.querySelectorAll(\'#install-dev-list input[type=checkbox]\').forEach(c=>c.checked=true)">全选</button> '+
|
||
'<button class="btn btn-sm" onclick="document.querySelectorAll(\'#install-dev-list input[type=checkbox]\').forEach(c=>c.checked=false)">全不选</button> '+
|
||
'<button class="btn btn-sm btn-primary" onclick="selectIdleForInstall()">全选空闲</button>'+
|
||
'</div>'+
|
||
'<div id="install-dev-list" class="device-list">'+
|
||
online.map(d=>{
|
||
const isUsb=d.source==='usb';
|
||
const st='<span class="label label-'+(isUsb?'primary':'default')+'" style="margin-left:6px">'+(isUsb?'本地USB':(d.source==='pool'?'设备池':'adb'))+'</span>';
|
||
// serial 唯一,显示 IP + 型号便于区分同型号设备
|
||
const label=(d.serial||'').replace(/:5555$/,'')+' · '+(d.model||(isUsb?'USB 有线设备':'设备'));
|
||
return '<label><input type="checkbox" value="'+esc(d.serial)+'" checked>'+esc(label)+st+'</label>';
|
||
}).join('')+
|
||
'</div>';
|
||
|
||
document.getElementById('modal-title').textContent='安装应用到设备';
|
||
document.getElementById('modal-body').innerHTML=body;
|
||
document.getElementById('modal-footer').innerHTML=
|
||
'<button class="btn" onclick="closeModal()">取消</button>'+
|
||
'<button class="btn btn-primary" onclick="startInstall(\''+apkId+'\')">开始安装</button>';
|
||
document.getElementById('modal-overlay').classList.add('show');
|
||
}
|
||
|
||
function selectIdleForInstall(){
|
||
document.querySelectorAll('#install-dev-list label').forEach(lbl=>{
|
||
const cb=lbl.querySelector('input[type=checkbox]');
|
||
const isRunning=lbl.querySelector('.label-success');
|
||
cb.checked=!isRunning;
|
||
});
|
||
}
|
||
|
||
async function startInstall(apkId){
|
||
const checked=document.querySelectorAll('#install-dev-list input[type=checkbox]:checked');
|
||
const serials=Array.from(checked).map(c=>c.value);
|
||
if(!serials.length){
|
||
showToast('请至少选择一台设备','error');return;
|
||
}
|
||
const r=await apiPost('/api/apks/install',{apk_id:apkId,serials:serials});
|
||
if(!r||!r.ok){showToast(r?r.error:'请求失败','error');return;}
|
||
showToast(r.msg||'安装已启动','success');
|
||
showInstallProgress();
|
||
}
|
||
|
||
function showInstallProgress(){
|
||
document.getElementById('modal-title').textContent='安装进度';
|
||
document.getElementById('modal-body').innerHTML=
|
||
'<div id="install-progress" style="min-height:200px"><p class="text-muted">加载中...</p></div>';
|
||
document.getElementById('modal-footer').innerHTML=
|
||
'<button class="btn" onclick="closeInstallModal()" id="install-close-btn" style="display:none">关闭</button>';
|
||
pollInstallStatus();
|
||
if(_installTimer)clearInterval(_installTimer);
|
||
_installTimer=setInterval(pollInstallStatus,3000);
|
||
}
|
||
|
||
async function pollInstallStatus(){
|
||
const r=await apiGet('/api/apks/install/status');
|
||
if(!r||!r.ok)return;
|
||
const st=r.status;
|
||
if(!st){
|
||
document.getElementById('install-progress').innerHTML='<p class="text-muted">无安装任务</p>';
|
||
if(_installTimer){clearInterval(_installTimer);_installTimer=null;}
|
||
return;
|
||
}
|
||
const SS={pending:{t:'等待',c:'default'},installing:{t:'安装中',c:'warning'},success:{t:'成功',c:'success'},failed:{t:'失败',c:'danger'},skipped:{t:'跳过',c:'default'}};
|
||
const items=st.items||{};
|
||
const itemRows=Object.entries(items).map(([serial,info])=>{
|
||
const s=SS[info.status]||{t:info.status,c:'default'};
|
||
return '<tr><td>'+esc(info.name||serial)+'</td>'+
|
||
'<td style="font-family:monospace;font-size:11px;color:#94a3b8">'+esc(serial)+'</td>'+
|
||
'<td><span class="label label-'+s.c+'">'+s.t+'</span></td>'+
|
||
'<td style="font-size:12px;color:#64748b">'+esc(info.msg||'')+'</td></tr>';
|
||
}).join('');
|
||
|
||
const pct=st.total>0?Math.round((st.success+st.failed+st.skipped)*100/st.total):0;
|
||
const summary=
|
||
'<div style="margin-bottom:12px">'+
|
||
'<div style="display:flex;gap:8px;margin-bottom:8px;font-size:13px;flex-wrap:wrap">'+
|
||
'<span>应用:<strong>'+esc(st.apk_name)+'</strong></span>'+
|
||
'<span class="label label-success">成功 '+st.success+'</span>'+
|
||
'<span class="label label-danger">失败 '+st.failed+'</span>'+
|
||
'<span class="label label-default">跳过 '+st.skipped+'</span>'+
|
||
'<span class="label label-warning">安装中 '+st.installing+'</span>'+
|
||
'<span class="label label-default">等待 '+st.pending+'</span>'+
|
||
'</div>'+
|
||
'<div class="progress" style="width:100%;height:8px"><div class="progress-bar" style="width:'+pct+'%"></div></div>'+
|
||
'</div>';
|
||
|
||
document.getElementById('install-progress').innerHTML=
|
||
summary+'<table class="table" style="margin-bottom:0"><thead><tr><th>设备</th><th>Serial</th><th>状态</th><th>详情</th></tr></thead><tbody>'+itemRows+'</tbody></table>';
|
||
|
||
if(st.finished){
|
||
if(_installTimer){clearInterval(_installTimer);_installTimer=null;}
|
||
document.getElementById('install-close-btn').style.display='';
|
||
showToast('安装完成:成功'+st.success+' 失败'+st.failed+' 跳过'+st.skipped,'success');
|
||
}
|
||
}
|
||
|
||
function closeInstallModal(){
|
||
if(_installTimer){clearInterval(_installTimer);_installTimer=null;}
|
||
closeModal();
|
||
}
|
||
|
||
|