feat: add database backup and restore in system settings

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-02 09:07:00 +08:00
parent 97fb95436f
commit 52ebbfbeae
11 changed files with 594 additions and 65 deletions
+116 -3
View File
@@ -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));
+41
View File
@@ -86,6 +86,47 @@ export async function updateAccountSettings(body: {
return r.json();
}
export type BackupItem = {
name: string;
size_bytes: number;
modified_ms: number;
};
export type BackupInfo = {
backup_dir: string;
db_path: string;
writable: boolean;
backups: BackupItem[];
};
export async function fetchBackupInfo(): Promise<BackupInfo> {
const r = await apiFetch("/api/settings/backup");
if (!r.ok) throw new Error(r.status === 401 ? "unauthorized" : "backup info failed");
return r.json();
}
export async function createBackup(current_password: string): Promise<{ ok: boolean; message: string; name: string; path: string }> {
const r = await apiFetch("/api/settings/backup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ current_password }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.detail || "backup failed");
return d;
}
export async function restoreBackup(current_password: string, backup_name: string): Promise<{ ok: boolean; message: string }> {
const r = await apiFetch("/api/settings/restore", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ current_password, backup_name }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.detail || "restore failed");
return d;
}
export async function logout(): Promise<void> {
setToken(null);
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
+198 -61
View File
@@ -1,12 +1,30 @@
import { FormEvent, useEffect, useState } from "react";
import { FormEvent, useCallback, useEffect, useState } from "react";
import {
BackupInfo,
createBackup,
fetchAccountSettings,
fetchBackupInfo,
logout,
restoreBackup,
updateAccountSettings,
} from "../api/client";
import AppNav from "../components/AppNav";
import LoginGate from "../components/LoginGate";
function fmtBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
}
function fmtTime(ms: number): string {
try {
return new Date(ms).toLocaleString("zh-CN", { hour12: false });
} catch {
return "—";
}
}
export default function SettingsPage() {
const [needLogin, setNeedLogin] = useState(false);
const [ready, setReady] = useState(false);
@@ -20,12 +38,27 @@ export default function SettingsPage() {
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [backupInfo, setBackupInfo] = useState<BackupInfo | null>(null);
const [backupPassword, setBackupPassword] = useState("");
const [selectedBackup, setSelectedBackup] = useState("");
const [backupMsg, setBackupMsg] = useState<string | null>(null);
const [backupErr, setBackupErr] = useState<string | null>(null);
const [backupBusy, setBackupBusy] = useState(false);
const loadBackupInfo = useCallback(async () => {
const info = await fetchBackupInfo();
setBackupInfo(info);
setSelectedBackup((prev) => prev || info.backups[0]?.name || "");
}, []);
useEffect(() => {
fetchAccountSettings()
.then((d) => {
setUsername(d.username);
setNewUsername(d.username);
setAuthRequired(d.auth_required);
Promise.all([fetchAccountSettings(), fetchBackupInfo()])
.then(([account, backup]) => {
setUsername(account.username);
setNewUsername(account.username);
setAuthRequired(account.auth_required);
setBackupInfo(backup);
setSelectedBackup(backup.backups[0]?.name || "");
setNeedLogin(false);
setReady(true);
})
@@ -71,6 +104,42 @@ export default function SettingsPage() {
}
};
const runBackup = async () => {
setBackupBusy(true);
setBackupErr(null);
setBackupMsg(null);
try {
const res = await createBackup(backupPassword);
setBackupMsg(`${res.message}${res.name}`);
setBackupPassword("");
await loadBackupInfo();
} catch (ex) {
setBackupErr(String(ex));
} finally {
setBackupBusy(false);
}
};
const runRestore = async () => {
if (!selectedBackup) {
setBackupErr("请选择要恢复的备份");
return;
}
if (!window.confirm(`确认从 ${selectedBackup} 恢复数据库?当前数据将被覆盖。`)) return;
setBackupBusy(true);
setBackupErr(null);
setBackupMsg(null);
try {
const res = await restoreBackup(backupPassword, selectedBackup);
setBackupMsg(res.message);
setBackupPassword("");
} catch (ex) {
setBackupErr(String(ex));
} finally {
setBackupBusy(false);
}
};
if (!ready) {
return (
<div className="layout">
@@ -85,70 +154,138 @@ export default function SettingsPage() {
return (
<div className="layout">
<div className="brand"></div>
<div className="sub"> · </div>
<div className="sub"> · </div>
<AppNav />
<div className="center-page">
<form className="tile settings-card" onSubmit={submit}>
<div className="settings-title"></div>
{!authRequired && (
<p className="settings-hint">AUTH_SECRET=disabled .env </p>
)}
<div className="settings-stack">
<form className="tile settings-card" onSubmit={submit}>
<div className="settings-title"></div>
{!authRequired && (
<p className="settings-hint">AUTH_SECRET=disabled .env </p>
)}
<label className="field">
<span className="label"></span>
<input
className="field-input"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
autoComplete="username"
disabled={!authRequired}
/>
</label>
<label className="field">
<span className="label"></span>
<input
className="field-input"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
autoComplete="username"
disabled={!authRequired}
/>
</label>
<label className="field">
<span className="label"></span>
<input
className="field-input"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password"
disabled={!authRequired}
/>
</label>
<label className="field">
<span className="label"></span>
<input
className="field-input"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password"
disabled={!authRequired}
/>
</label>
<label className="field">
<span className="label"></span>
<input
className="field-input"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
disabled={!authRequired}
/>
</label>
<label className="field">
<span className="label"></span>
<input
className="field-input"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
disabled={!authRequired}
/>
</label>
<label className="field">
<span className="label"></span>
<input
className="field-input"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
disabled={!authRequired}
/>
</label>
<label className="field">
<span className="label"></span>
<input
className="field-input"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
disabled={!authRequired}
/>
</label>
{err && <p className="form-err">{err}</p>}
{msg && <p className="form-ok">{msg}</p>}
{err && <p className="form-err">{err}</p>}
{msg && <p className="form-ok">{msg}</p>}
<button className="btn-primary" type="submit" disabled={busy || !authRequired || !currentPassword}>
{busy ? "保存中…" : "保存"}
</button>
</form>
<button className="btn-primary" type="submit" disabled={busy || !authRequired || !currentPassword}>
{busy ? "保存中…" : "保存账号"}
</button>
</form>
<div className="tile settings-card">
<div className="settings-title"></div>
<p className="settings-hint">
<code>{backupInfo?.backup_dir || "/root/market_intel_backups"}</code>
</p>
{!backupInfo?.writable && (
<p className="form-err"> /root/market_intel_backups Docker </p>
)}
<label className="field">
<span className="label">/</span>
<input
className="field-input"
type="password"
value={backupPassword}
onChange={(e) => setBackupPassword(e.target.value)}
autoComplete="current-password"
disabled={!authRequired}
/>
</label>
{backupInfo && backupInfo.backups.length > 0 ? (
<label className="field">
<span className="label"></span>
<select
className="field-input"
value={selectedBackup}
onChange={(e) => setSelectedBackup(e.target.value)}
disabled={!authRequired}
>
{backupInfo.backups.map((b) => (
<option key={b.name} value={b.name}>
{b.name} · {fmtBytes(b.size_bytes)} · {fmtTime(b.modified_ms)}
</option>
))}
</select>
</label>
) : (
<p className="settings-hint"></p>
)}
{backupErr && <p className="form-err">{backupErr}</p>}
{backupMsg && <p className="form-ok">{backupMsg}</p>}
<div className="btn-row">
<button
type="button"
className="btn-primary"
disabled={backupBusy || !authRequired || !backupPassword || !backupInfo?.writable}
onClick={runBackup}
>
{backupBusy ? "处理中…" : "立即备份"}
</button>
<button
type="button"
className="btn-danger"
disabled={
backupBusy || !authRequired || !backupPassword || !selectedBackup || !backupInfo?.writable
}
onClick={runRestore}
>
</button>
</div>
</div>
</div>
</div>
</div>
);
+14
View File
@@ -32,8 +32,22 @@ a { color: var(--accent); text-decoration: none; }
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.25rem; }
.settings-hint { color: var(--muted); font-size: 0.85rem; line-height: 1.5; margin: 0 0 1rem; }
.settings-hint code { color: var(--text); font-size: 0.82rem; }
.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;
font-size: 0.95rem;
}
.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }
select.field-input { cursor: pointer; }
.field { display: block; margin-bottom: 1rem; }
.field-input {
display: block;