feat: add settings tabs, auto backup, and show latest 5 backups

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-02 09:15:21 +08:00
parent 52ebbfbeae
commit 458cc42dd5
12 changed files with 621 additions and 190 deletions
+227 -133
View File
@@ -7,17 +7,21 @@ import {
logout,
restoreBackup,
updateAccountSettings,
updateAutoBackup,
} from "../api/client";
import AppNav from "../components/AppNav";
import LoginGate from "../components/LoginGate";
type TabKey = "account" | "backup";
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 {
function fmtTime(ms: number | null | undefined): string {
if (ms == null) return "—";
try {
return new Date(ms).toLocaleString("zh-CN", { hour12: false });
} catch {
@@ -26,6 +30,7 @@ function fmtTime(ms: number): string {
}
export default function SettingsPage() {
const [tab, setTab] = useState<TabKey>("account");
const [needLogin, setNeedLogin] = useState(false);
const [ready, setReady] = useState(false);
const [username, setUsername] = useState("admin");
@@ -41,15 +46,26 @@ export default function SettingsPage() {
const [backupInfo, setBackupInfo] = useState<BackupInfo | null>(null);
const [backupPassword, setBackupPassword] = useState("");
const [selectedBackup, setSelectedBackup] = useState("");
const [autoEnabled, setAutoEnabled] = useState(true);
const [intervalHours, setIntervalHours] = useState(24);
const [backupMsg, setBackupMsg] = useState<string | null>(null);
const [backupErr, setBackupErr] = useState<string | null>(null);
const [backupBusy, setBackupBusy] = useState(false);
const applyBackupInfo = useCallback((info: BackupInfo) => {
setBackupInfo(info);
setAutoEnabled(!!info.auto_enabled);
setIntervalHours(Number(info.interval_hours) || 24);
setSelectedBackup((prev) => {
if (prev && info.backups.some((b) => b.name === prev)) return prev;
return info.backups[0]?.name || "";
});
}, []);
const loadBackupInfo = useCallback(async () => {
const info = await fetchBackupInfo();
setBackupInfo(info);
setSelectedBackup((prev) => prev || info.backups[0]?.name || "");
}, []);
applyBackupInfo(info);
}, [applyBackupInfo]);
useEffect(() => {
Promise.all([fetchAccountSettings(), fetchBackupInfo()])
@@ -57,8 +73,7 @@ export default function SettingsPage() {
setUsername(account.username);
setNewUsername(account.username);
setAuthRequired(account.auth_required);
setBackupInfo(backup);
setSelectedBackup(backup.backups[0]?.name || "");
applyBackupInfo(backup);
setNeedLogin(false);
setReady(true);
})
@@ -68,7 +83,7 @@ export default function SettingsPage() {
else setErr(m);
setReady(true);
});
}, []);
}, [applyBackupInfo]);
const submit = async (e: FormEvent) => {
e.preventDefault();
@@ -104,6 +119,26 @@ export default function SettingsPage() {
}
};
const saveAuto = async () => {
setBackupBusy(true);
setBackupErr(null);
setBackupMsg(null);
try {
const res = await updateAutoBackup({
current_password: backupPassword,
enabled: autoEnabled,
interval_hours: intervalHours,
});
setBackupMsg(res.message || "自动备份设置已保存");
applyBackupInfo(res);
await loadBackupInfo();
} catch (ex) {
setBackupErr(String(ex));
} finally {
setBackupBusy(false);
}
};
const runBackup = async () => {
setBackupBusy(true);
setBackupErr(null);
@@ -154,137 +189,196 @@ 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">
<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"
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={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>}
<button className="btn-primary" type="submit" disabled={busy || !authRequired || !currentPassword}>
{busy ? "保存中…" : "保存账号"}
<div className="tile settings-card">
<div className="settings-tabs">
<button
type="button"
className={tab === "account" ? "active" : ""}
onClick={() => setTab("account")}
>
</button>
<button
type="button"
className={tab === "backup" ? "active" : ""}
onClick={() => setTab("backup")}
>
</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>
{tab === "account" && (
<form 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"
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={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>}
<button className="btn-primary" type="submit" disabled={busy || !authRequired || !currentPassword}>
{busy ? "保存中…" : "保存账号"}
</button>
</form>
)}
{tab === "backup" && (
<div>
<div className="settings-title"></div>
<p className="settings-hint">
<code>{backupInfo?.backup_dir || "/root/market_intel_backups"}</code>
<br />
{backupInfo?.display_limit ?? 5} {backupInfo?.keep_count ?? 30}
</p>
{!backupInfo?.writable && (
<p className="form-err"> /root/market_intel_backups Docker </p>
)}
<div className="auto-box">
<label className="check-row">
<input
type="checkbox"
checked={autoEnabled}
onChange={(e) => setAutoEnabled(e.target.checked)}
disabled={!authRequired}
/>
<span></span>
</label>
<label className="field">
<span className="label"></span>
<input
className="field-input"
type="number"
min={1}
max={168}
value={intervalHours}
onChange={(e) => setIntervalHours(Number(e.target.value) || 24)}
disabled={!authRequired || !autoEnabled}
/>
</label>
<p className="settings-hint" style={{ marginBottom: 0 }}>
{fmtTime(backupInfo?.latest_backup_ms)}
{autoEnabled ? ` · 下次约:${fmtTime(backupInfo?.next_due_ms)}` : ""}
</p>
</div>
<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"> {backupInfo.display_limit} </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}
onClick={saveAuto}
>
</button>
<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>