feat: 顶栏新增「系统」栏目——数据备份导出/导入恢复子分栏(仅管理员)

monitor.html:导航 .tabs 追加「系统」(data-perm=admin) + #tab-system 面板,
内含「数据备份 / 导入恢复」两个子分栏;base.js _activeSubs 记忆子分栏并在
showTab 里切回;system.js:导出(含 APK 勾选→zip 下载)、导入(选文件→
校验预览:文件/schema/表行数/告警→确认应用,原生 confirm),全程含
「全量快照含密钥」警告与重启生效提示。
This commit is contained in:
2026-09-09 14:49:40 +08:00
parent 55b1b74944
commit 470c76221e
3 changed files with 171 additions and 1 deletions
+125
View File
@@ -0,0 +1,125 @@
// 「系统」Tab:数据备份导出/导入(依赖 base.js:esc/_csrfHeaders/apiPost/showToast/handleResult)
let _restoreToken = ''; // 预览通过后的暂存标识(用于 apply)
// ================== 导出 ==================
async function doExportBackup(){
const st=document.getElementById('backup-status');
const btn=event&&event.target||null;
if(btn)btn.disabled=true;
const includeApk=document.getElementById('backup-include-apk').checked;
if(st)st.textContent='正在生成备份并打包…';
try{
const r=await fetch('/api/system/backup/export',{
method:'POST',
headers:{'Content-Type':'application/json',..._csrfHeaders()},
body:JSON.stringify({include_apk:includeApk})
});
if(r.status===401){window.location='/login';return;}
if(!r.ok){
let msg='导出失败';
try{const d=await r.json();if(d&&d.error)msg=d.error;}catch(e){}
showToast(msg,'error');
if(st)st.textContent='';
return;
}
const blob=await r.blob();
const cd=r.headers.get('Content-Disposition')||'';
const m=/filename="?([^";]+)"?/i.exec(cd);
const fname=m?m[1]:('auto_control_backup_'+Date.now()+'.zip');
const url=URL.createObjectURL(blob);
const a=document.createElement('a');
a.href=url;a.download=fname;document.body.appendChild(a);
a.click();a.remove();
setTimeout(()=>URL.revokeObjectURL(url),3000);
if(st)st.textContent='已生成 '+fname;
showToast('导出成功','success');
}catch(e){
showToast('导出失败: '+e,'error');
if(st)st.textContent='';
}finally{
if(btn)btn.disabled=false;
}
}
// ================== 导入:上传预览 ==================
async function previewRestore(){
const fileEl=document.getElementById('restore-file');
const f=fileEl&&fileEl.files&&fileEl.files[0];
const box=document.getElementById('restore-preview');
const st=document.getElementById('restore-status');
const applyBtn=document.getElementById('btn-apply-restore');
if(!f){showToast('请先选择备份文件(.zip 或 .db)','error');return;}
_restoreToken='';
if(applyBtn)applyBtn.style.display='none';
if(st)st.textContent='';
box.innerHTML='<span class="text-muted">解析校验中…</span>';
const fd=new FormData();
fd.append('file',f);
try{
const r=await fetch('/api/system/backup/preview',{
method:'POST',
headers:_csrfHeaders(), // FormData 不能手动设 Content-Type
body:fd
});
if(r.status===401){window.location='/login';return;}
const d=await r.json();
box.innerHTML='';
if(!d.ok){showToast(d.error||'解析失败','error');return;}
_restoreToken=d.token;
renderRestorePreview(d.preview);
if(applyBtn)applyBtn.style.display='';
}catch(e){
box.innerHTML='';
showToast('上传失败: '+e,'error');
}
}
function renderRestorePreview(p){
const box=document.getElementById('restore-preview');
const rows=(p.tables||[]).map(t=>
'<tr><td>'+esc(t.label||t.table)+'</td><td>'+esc(t.table)+'</td><td>'+esc(t.rows)+'</td></tr>').join('');
const warns=(p.warnings||[]).map(w=>'<li>'+esc(w)+'</li>').join('');
box.innerHTML=
'<table class="table table-hover table-sm" style="max-width:560px">'+
'<tbody>'+
'<tr><th style="width:120px">文件</th><td>'+esc(p.file_name||'')+'('+(p.file_size!=null?fmtSize(p.file_size):'')+')</td></tr>'+
'<tr><th>库结构版本</th><td>备份 v'+esc(p.schema_version)+' / 当前 v'+esc(p.current_schema_version)+'</td></tr>'+
'<tr><th>完整性</th><td>'+esc(p.integrity||'')+'</td></tr>'+
'</tbody>'+
'</table>'+
'<div class="section-title" style="margin-top:8px">表数据行数</div>'+
'<table class="table table-hover table-sm" style="max-width:560px"><thead><tr><th>业务</th><th>表</th><th>行数</th></tr></thead><tbody>'+(rows||'<tr><td colspan="3">—</td></tr>')+'</tbody></table>'+
(warns?'<div style="background:#fff3cd;border:1px solid #ffda6a;color:#7a5b00;padding:8px 12px;border-radius:6px;margin-top:8px"><ul style="margin:0;padding-left:18px">'+warns+'</ul></div>':'');
}
function fmtSize(n){
if(n==null)return '';
if(n<1024)return n+' B';
if(n<1024*1024)return (n/1024).toFixed(1)+' KB';
return (n/1024/1024).toFixed(1)+' MB';
}
// ================== 导入:确认应用 ==================
async function applyRestore(){
const applyBtn=document.getElementById('btn-apply-restore');
const st=document.getElementById('restore-status');
if(!_restoreToken)return;
if(!confirm('确认应用导入?\n\n系统会先自动备份当前库(data/backups/pre_restore_*.db),再把待导入数据覆盖现有全部数据。\n导入将在重启 web_server 后生效。\n\n确定继续?'))return;
if(applyBtn)applyBtn.disabled=true;
if(st)st.textContent='正在生成恢复任务…';
const r=await apiPost('/api/system/backup/apply',{token:_restoreToken});
if(!r)return;
if(r.ok){
const box=document.getElementById('restore-preview');
box.innerHTML='<div style="background:#e8f5e9;border:1px solid #a5d6a7;padding:10px 14px;border-radius:6px">'+
'✅ 恢复任务已生成。<br>当前库已自动备份到 <code>'+esc(r.backup_name||'')+'</code>。<br>'+
'请<b>重启 web_server</b>,启动时将自动应用导入数据(恢复完成前不要再次导入)。</div>';
if(st)st.textContent='';
if(applyBtn)applyBtn.style.display='none';
document.getElementById('restore-file').value='';
_restoreToken='';
}else{
showToast(r.error||'应用导入失败','error');
if(applyBtn)applyBtn.disabled=false;
}
}