feat: add database backup and restore in system settings
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user