339 lines
15 KiB
JavaScript
339 lines
15 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();
|
||
}
|
||
|
||
|
||
|
||
// ================== 设备端应用商店(Agent 直连安装) ==================
|
||
// 设备上的 Agent 拉 /api/device/agent/bootstrap 拿清单 → 自己下载 → 自己调系统安装器。
|
||
// 因为安装由设备上的 App 发起,走普通应用安装流程,不会触发 MIUI 的「USB 安装」拦截。
|
||
let _storeToken='';
|
||
|
||
async function loadAgentStore(){
|
||
try{
|
||
const cfg=await (await fetch('/api/agent-store/config',{headers:_csrfHeaders()})).json();
|
||
if(cfg&&cfg.ok){
|
||
_storeToken=cfg.token||'';
|
||
const en=document.getElementById('store-enabled');
|
||
if(en)en.checked=!!cfg.enabled;
|
||
const u=document.getElementById('store-url');
|
||
if(u)u.value=cfg.base_url_hint||'';
|
||
const t=document.getElementById('store-token');
|
||
if(t)t.value=_storeToken||'(未生成,勾选启用后自动生成)';
|
||
}
|
||
const d=await (await fetch('/api/agent-store/logs?limit=30',{headers:_csrfHeaders()})).json();
|
||
renderStoreLogs((d&&d.logs)||[]);
|
||
}catch(e){ /* 面板没打开时静默 */ }
|
||
}
|
||
|
||
function renderStoreLogs(logs){
|
||
const tb=document.getElementById('tb-store-logs');
|
||
if(!tb)return;
|
||
if(!logs.length){
|
||
tb.innerHTML='<tr><td colspan="5" class="empty">暂无记录(设备还没从商店装过东西)</td></tr>';
|
||
return;
|
||
}
|
||
const act={download:'⬇ 已下载',install_ok:'✅ 安装成功',install_fail:'❌ 安装失败'};
|
||
tb.innerHTML=logs.map(l=>{
|
||
const app=[l.display_name||l.package_name||l.apk_id,
|
||
l.version_name?('v'+l.version_name):''].filter(Boolean).join(' ');
|
||
const cls=l.action==='install_fail'?'style="color:#b42318"':(l.action==='install_ok'?'style="color:#067647"':'');
|
||
return '<tr><td>'+esc(l.created_at||'')+'</td><td>'+esc(l.device_name||l.serial||'-')+'</td>'+
|
||
'<td title="'+esc(l.package_name||'')+'('+esc(l.apk_id||'')+')">'+esc(_cut(app,30))+'</td>'+
|
||
'<td '+cls+'>'+esc(act[l.action]||l.action||'')+'</td>'+
|
||
'<td>'+esc(_cut(l.message||'',60))+'</td></tr>';
|
||
}).join('');
|
||
}
|
||
|
||
async function toggleAgentStore(on){
|
||
const r=await apiPost('/api/agent-store/config',{enabled:!!on});
|
||
if(!r)return;
|
||
if(r.ok){
|
||
showToast(on?'设备端商店已启用':'设备端商店已停用','success');
|
||
loadAgentStore();
|
||
}else{
|
||
showToast(r.error||'设置失败','error');
|
||
document.getElementById('store-enabled').checked=!on;
|
||
}
|
||
}
|
||
|
||
async function regenStoreToken(){
|
||
if(!confirm('重置设备令牌?\n\n所有已配置该令牌的设备会立即失效,需要在设备端 Agent 里重新填新令牌。'))return;
|
||
const r=await apiPost('/api/agent-store/config',{regenerate_token:true});
|
||
if(r&&r.ok){showToast('令牌已重置','success');loadAgentStore();}
|
||
else if(r)showToast(r.error||'重置失败','error');
|
||
}
|
||
|
||
function copyStoreToken(){
|
||
if(!_storeToken){showToast('还没有令牌(先启用设备端商店)','error');return;}
|
||
const done=()=>showToast('令牌已复制','success');
|
||
if(navigator.clipboard&&navigator.clipboard.writeText){
|
||
navigator.clipboard.writeText(_storeToken).then(done).catch(()=>{
|
||
window.prompt('手动复制令牌:',_storeToken);
|
||
});
|
||
}else{
|
||
window.prompt('手动复制令牌:',_storeToken);
|
||
}
|
||
}
|
||
|
||
function _cut(s,n){s=String(s==null?'':s);return s.length>n?(s.slice(0,n-1)+'…'):s;}
|