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
+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>
);