feat: add database backup and restore in system settings
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Vendored
+116
-3
@@ -42,11 +42,16 @@
|
||||
pre { background: var(--panel); border: 1px solid #243041; border-radius: 10px; padding: 1rem; overflow: auto; font-size: 0.78rem; color: #b7c5d4; }
|
||||
.center-page { display: flex; justify-content: center; align-items: flex-start; min-height: 50vh; padding: 1.5rem 0 3rem; }
|
||||
.settings-card { width: 100%; max-width: 420px; }
|
||||
.settings-stack { width: 100%; max-width: 420px; display: flex; flex-direction: column; gap: 1.25rem; }
|
||||
.settings-title { font-size: 1.15rem; font-weight: 650; margin-bottom: 0.75rem; }
|
||||
.settings-hint { color: var(--muted); font-size: 0.85rem; line-height: 1.5; margin: 0 0 1rem; }
|
||||
.field { display: block; margin-bottom: 1rem; }
|
||||
.field-input { width: 100%; margin-top: 0.4rem; padding: 0.55rem 0.65rem; border-radius: 6px; border: 1px solid #243041; background: #0c1117; color: var(--text); }
|
||||
.btn-primary { margin-top: 0.5rem; padding: 0.55rem 1.2rem; border-radius: 6px; border: 0; background: var(--accent); color: #fff; cursor: pointer; }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-row { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 0.25rem; }
|
||||
.btn-danger { padding: 0.55rem 1.2rem; border-radius: 6px; border: 0; background: #c45656; color: #fff; cursor: pointer; }
|
||||
.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.form-err { color: var(--bad); font-size: 0.85rem; }
|
||||
.form-ok { color: var(--ok); font-size: 0.85rem; }
|
||||
</style>
|
||||
@@ -126,9 +131,10 @@
|
||||
</section>
|
||||
<section id="page-settings" class="hidden">
|
||||
<div class="center-page">
|
||||
<div class="settings-stack">
|
||||
<form id="settingsForm" class="tile settings-card">
|
||||
<div class="settings-title">系统设置</div>
|
||||
<p id="settingsAuthHint" class="hint" style="margin-bottom:1rem"></p>
|
||||
<div class="settings-title">账号设置</div>
|
||||
<p id="settingsAuthHint" class="settings-hint" style="margin-bottom:1rem"></p>
|
||||
<label class="field"><span class="label">用户名</span>
|
||||
<input id="setUsername" class="field-input" autocomplete="username" />
|
||||
</label>
|
||||
@@ -143,8 +149,26 @@
|
||||
</label>
|
||||
<p id="settingsErr" class="form-err"></p>
|
||||
<p id="settingsOk" class="form-ok"></p>
|
||||
<button type="submit" class="btn-primary" id="settingsSaveBtn">保存</button>
|
||||
<button type="submit" class="btn-primary" id="settingsSaveBtn">保存账号</button>
|
||||
</form>
|
||||
<div class="tile settings-card">
|
||||
<div class="settings-title">数据备份与恢复</div>
|
||||
<p class="settings-hint">备份目录:<span id="backupDir">/root/market_intel_backups</span></p>
|
||||
<p id="backupDirErr" class="form-err hidden"></p>
|
||||
<label class="field"><span class="label">当前密码(备份/恢复需验证)</span>
|
||||
<input id="backupPass" type="password" class="field-input" autocomplete="current-password" />
|
||||
</label>
|
||||
<label class="field"><span class="label">选择备份</span>
|
||||
<select id="backupSelect" class="field-input"><option value="">尚无备份</option></select>
|
||||
</label>
|
||||
<p id="backupErr" class="form-err"></p>
|
||||
<p id="backupOk" class="form-ok"></p>
|
||||
<div class="btn-row">
|
||||
<button type="button" class="btn-primary" id="backupBtn">立即备份</button>
|
||||
<button type="button" class="btn-danger" id="restoreBtn">恢复选中备份</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
@@ -233,6 +257,50 @@
|
||||
}
|
||||
});
|
||||
|
||||
function fmtBytes(n) {
|
||||
if (n < 1024) return n + " B";
|
||||
if (n < 1048576) return (n / 1024).toFixed(1) + " KB";
|
||||
return (n / 1048576).toFixed(2) + " MB";
|
||||
}
|
||||
function fmtBackupTime(ms) {
|
||||
try { return new Date(ms).toLocaleString("zh-CN", { hour12: false }); } catch { return "—"; }
|
||||
}
|
||||
function renderBackupSelect(backups) {
|
||||
const sel = document.getElementById("backupSelect");
|
||||
sel.innerHTML = "";
|
||||
if (!backups || !backups.length) {
|
||||
sel.innerHTML = '<option value="">尚无备份</option>';
|
||||
return;
|
||||
}
|
||||
backups.forEach(b => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = b.name;
|
||||
opt.textContent = b.name + " · " + fmtBytes(b.size_bytes) + " · " + fmtBackupTime(b.modified_ms);
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
async function loadBackupInfo() {
|
||||
document.getElementById("backupErr").textContent = "";
|
||||
document.getElementById("backupOk").textContent = "";
|
||||
const r = await apiFetch("/api/settings/backup");
|
||||
if (!r.ok) throw new Error("backup info " + r.status);
|
||||
const info = await r.json();
|
||||
state.backupWritable = !!info.writable;
|
||||
document.getElementById("backupDir").textContent = info.backup_dir || "/root/market_intel_backups";
|
||||
const dirErr = document.getElementById("backupDirErr");
|
||||
if (!info.writable) {
|
||||
dirErr.textContent = "备份目录不可写,请检查 /root/market_intel_backups 权限与 Docker 挂载";
|
||||
dirErr.classList.remove("hidden");
|
||||
} else {
|
||||
dirErr.classList.add("hidden");
|
||||
}
|
||||
renderBackupSelect(info.backups || []);
|
||||
const disabled = !state.authRequired || !info.writable;
|
||||
["backupPass","backupSelect","backupBtn","restoreBtn"].forEach(id => {
|
||||
document.getElementById(id).disabled = disabled;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
document.getElementById("settingsErr").textContent = "";
|
||||
document.getElementById("settingsOk").textContent = "";
|
||||
@@ -249,6 +317,7 @@
|
||||
["setUsername","setCurPass","setNewPass","setNewPass2","settingsSaveBtn"].forEach(id => {
|
||||
document.getElementById(id).disabled = disabled;
|
||||
});
|
||||
await loadBackupInfo();
|
||||
} catch (e) {
|
||||
document.getElementById("settingsErr").textContent = String(e);
|
||||
}
|
||||
@@ -292,6 +361,50 @@
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("backupBtn").addEventListener("click", async () => {
|
||||
const pwd = document.getElementById("backupPass").value;
|
||||
document.getElementById("backupErr").textContent = "";
|
||||
document.getElementById("backupOk").textContent = "";
|
||||
if (!pwd) { document.getElementById("backupErr").textContent = "请输入当前密码"; return; }
|
||||
try {
|
||||
const r = await apiFetch("/api/settings/backup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ current_password: pwd }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.detail || "backup failed");
|
||||
document.getElementById("backupOk").textContent = (d.message || "备份完成") + ":" + d.name;
|
||||
document.getElementById("backupPass").value = "";
|
||||
await loadBackupInfo();
|
||||
} catch (err) {
|
||||
document.getElementById("backupErr").textContent = String(err.message || err);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("restoreBtn").addEventListener("click", async () => {
|
||||
const pwd = document.getElementById("backupPass").value;
|
||||
const name = document.getElementById("backupSelect").value;
|
||||
document.getElementById("backupErr").textContent = "";
|
||||
document.getElementById("backupOk").textContent = "";
|
||||
if (!pwd) { document.getElementById("backupErr").textContent = "请输入当前密码"; return; }
|
||||
if (!name) { document.getElementById("backupErr").textContent = "请选择要恢复的备份"; return; }
|
||||
if (!confirm("确认从 " + name + " 恢复数据库?当前数据将被覆盖。")) return;
|
||||
try {
|
||||
const r = await apiFetch("/api/settings/restore", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ current_password: pwd, backup_name: name }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.detail || "restore failed");
|
||||
document.getElementById("backupOk").textContent = d.message || "恢复完成";
|
||||
document.getElementById("backupPass").value = "";
|
||||
} catch (err) {
|
||||
document.getElementById("backupErr").textContent = String(err.message || err);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll("#rangeSeg button").forEach(b => b.addEventListener("click", () => {
|
||||
state.range = b.dataset.range;
|
||||
document.querySelectorAll("#rangeSeg button").forEach(x => x.classList.toggle("active", x === b));
|
||||
|
||||
Reference in New Issue
Block a user