feat: 顶栏新增「系统」栏目——数据备份导出/导入恢复子分栏(仅管理员)
monitor.html:导航 .tabs 追加「系统」(data-perm=admin) + #tab-system 面板, 内含「数据备份 / 导入恢复」两个子分栏;base.js _activeSubs 记忆子分栏并在 showTab 里切回;system.js:导出(含 APK 勾选→zip 下载)、导入(选文件→ 校验预览:文件/schema/表行数/告警→确认应用,原生 confirm),全程含 「全量快照含密钥」警告与重启生效提示。
This commit is contained in:
@@ -102,11 +102,12 @@ function showTab(name){
|
||||
if(name==='logs'){loadLogs();if(document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);}
|
||||
if(name==='agent' && typeof initAgentChat==='function') initAgentChat();
|
||||
if(name==='users')loadUsers();
|
||||
if(name==='system'){showSubTab('system',_activeSubs.system||'backup');}
|
||||
}
|
||||
|
||||
// ================== 页内子分栏(任务/工具 通用) ==================
|
||||
// 每个带子分栏的 Tab 记住上次选中的子分栏,切走再切回来保持原位
|
||||
let _activeSubs = {tasks: 'plan', tools: 'clipboard'};
|
||||
let _activeSubs = {tasks: 'plan', tools: 'clipboard', system: 'backup'};
|
||||
let _discoveryTimer = null; // 设备自动发现 10s 轮询(仅 devpool 子分栏激活时)
|
||||
|
||||
function showSubTab(tabId, name){
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -365,6 +365,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
||||
<button class="tab" data-tab="users" onclick="showTab('users')" data-perm="admin">用户</button>
|
||||
<button class="tab" data-tab="tools" onclick="showTab('tools')" data-perm="admin">工具</button>
|
||||
<button class="tab" data-tab="agent" onclick="showTab('agent')" data-perm="admin">AI 控制台</button>
|
||||
<button class="tab" data-tab="system" onclick="showTab('system')" data-perm="admin">系统</button>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<span id="me-username">...</span>
|
||||
@@ -850,9 +851,51 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
||||
<div class="list-pager" id="pager-groups"></div>
|
||||
<div class="help">管理设备分组,用于任务按分组执行。分组内的设备需先在<b>设备池管理</b>中确认(不在池内的设备不参与调度)。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========== Tab 9: 系统(仅管理员) ========== -->
|
||||
<div id="tab-system" class="tab-panel">
|
||||
<div class="page-title">系统</div>
|
||||
<div class="page-sub">系统数据备份导出/导入(仅管理员)</div>
|
||||
|
||||
<div class="sub-tabs">
|
||||
<button class="sub-tab active" data-sub="backup" onclick="showSubTab('system','backup')">数据备份</button>
|
||||
<button class="sub-tab" data-sub="restore" onclick="showSubTab('system','restore')">导入恢复</button>
|
||||
</div>
|
||||
|
||||
<!-- 子分栏 1:数据备份 -->
|
||||
<div class="sub-panel active" id="system-sub-backup">
|
||||
<div class="help">
|
||||
<b>导出</b>当前系统数据为 zip:含 <code>users.db</code>(用户 / 任务计划 / 设备分组 / 自定义动作 /
|
||||
设备池 / AI 会话 / 经验库等全部表,SQLite 在线一致快照)+ 可选的 <code>apks/</code> 应用文件。
|
||||
恢复用:换机 / 整库迁移 / 出问题前留底。
|
||||
</div>
|
||||
<div class="toolbar" style="gap:8px">
|
||||
<label style="display:inline-flex;align-items:center;gap:4px;margin:0 6px">
|
||||
<input type="checkbox" id="backup-include-apk" checked> 包含 APK 文件(体积较大,~100MB 级)
|
||||
</label>
|
||||
<button class="btn btn-primary" onclick="doExportBackup()">导出备份</button>
|
||||
<span id="backup-status" class="text-muted"></span>
|
||||
</div>
|
||||
<div style="background:#fff8e1;border:1px solid #ffe082;color:#8a6d00;padding:8px 12px;border-radius:6px;margin:8px 0">
|
||||
⚠️ 备份为<b>全量快照</b>:含用户口令哈希、AI 配置里的 API Key 等敏感信息,请妥善保管,勿外传、勿提交到 git。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 子分栏 2:导入恢复 -->
|
||||
<div class="sub-panel" id="system-sub-restore">
|
||||
<div class="help">
|
||||
<b>导入恢复</b>:上传平台导出的 <code>.zip</code> 或 <code>.db</code> → 先<b>校验预览</b> →
|
||||
确认后系统会<b>自动备份当前库</b>并把恢复任务挂起,<b>重启 web_server 后生效</b>(导入将覆盖现有全部数据)。
|
||||
</div>
|
||||
<div class="toolbar" style="gap:8px">
|
||||
<input type="file" id="restore-file" accept=".zip,.db">
|
||||
<button class="btn" onclick="previewRestore()">上传并预览</button>
|
||||
<button class="btn btn-danger" id="btn-apply-restore" onclick="applyRestore()" style="display:none">确认应用导入</button>
|
||||
<span id="restore-status" class="text-muted"></span>
|
||||
</div>
|
||||
<div id="restore-preview" style="margin-top:8px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /.content -->
|
||||
@@ -918,5 +961,6 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
||||
<script src="/static/admin/apps.js"></script>
|
||||
<script src="/static/admin/admin.js"></script>
|
||||
<script src="/static/admin/agent.js"></script>
|
||||
<script src="/static/admin/system.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user