feat: match settings card width to ops map and add backup download

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-02 09:23:08 +08:00
parent 667cee33fa
commit 352faf404c
6 changed files with 105 additions and 11 deletions
+17
View File
@@ -6,6 +6,7 @@ import re
import time import time
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from apps.api.auth import auth_disabled, require_auth, verify_credentials from apps.api.auth import auth_disabled, require_auth, verify_credentials
@@ -150,6 +151,22 @@ def create_backup(body: BackupActionBody) -> dict:
} }
@router.get("/backup/download/{backup_name}")
def download_backup(backup_name: str) -> FileResponse:
s = get_settings()
try:
path = resolve_backup_file(s.backup_dir_path, backup_name.strip())
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
return FileResponse(
path=str(path),
filename=path.name,
media_type="application/octet-stream",
)
@router.post("/restore") @router.post("/restore")
def restore_backup(body: RestoreBody) -> dict: def restore_backup(body: RestoreBody) -> dict:
_verify_password_or_401(body.current_password) _verify_password_or_401(body.current_password)
+34 -4
View File
@@ -41,7 +41,9 @@
.hidden { display: none; } .hidden { display: none; }
pre { background: var(--panel); border: 1px solid #243041; border-radius: 10px; padding: 1rem; overflow: auto; font-size: 0.78rem; color: #b7c5d4; } 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; } .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: 460px; } .settings-card { width: 100%; }
.login-card { width: 100%; max-width: 420px; }
.settings-form { max-width: 520px; }
.settings-title { font-size: 1.15rem; font-weight: 650; margin: 0.25rem 0 0.85rem; } .settings-title { font-size: 1.15rem; font-weight: 650; margin: 0.25rem 0 0.85rem; }
.settings-hint { color: var(--muted); font-size: 0.85rem; line-height: 1.5; margin: 0 0 1rem; } .settings-hint { color: var(--muted); font-size: 0.85rem; line-height: 1.5; margin: 0 0 1rem; }
.settings-tabs { display: flex; border-bottom: 1px solid #243041; margin: -0.25rem -0.15rem 1.1rem; } .settings-tabs { display: flex; border-bottom: 1px solid #243041; margin: -0.25rem -0.15rem 1.1rem; }
@@ -76,7 +78,7 @@
<main> <main>
<section id="page-login" class="hidden"> <section id="page-login" class="hidden">
<div class="center-page"> <div class="center-page">
<div class="tile settings-card"> <div class="tile login-card">
<div class="settings-title">登录</div> <div class="settings-title">登录</div>
<label class="field"><span class="label">用户名</span> <label class="field"><span class="label">用户名</span>
<input id="loginUser" class="field-input" value="admin" autocomplete="username" /> <input id="loginUser" class="field-input" value="admin" autocomplete="username" />
@@ -141,7 +143,7 @@
<button type="button" id="tabAccount" class="active" data-tab="account">账户密码</button> <button type="button" id="tabAccount" class="active" data-tab="account">账户密码</button>
<button type="button" id="tabBackup" data-tab="backup">数据备份与恢复</button> <button type="button" id="tabBackup" data-tab="backup">数据备份与恢复</button>
</div> </div>
<div id="panelAccount"> <div id="panelAccount" class="settings-form">
<form id="settingsForm"> <form id="settingsForm">
<div class="settings-title">账户密码</div> <div class="settings-title">账户密码</div>
<p id="settingsAuthHint" class="settings-hint"></p> <p id="settingsAuthHint" class="settings-hint"></p>
@@ -162,7 +164,7 @@
<button type="submit" class="btn-primary" id="settingsSaveBtn">保存账号</button> <button type="submit" class="btn-primary" id="settingsSaveBtn">保存账号</button>
</form> </form>
</div> </div>
<div id="panelBackup" class="hidden"> <div id="panelBackup" class="settings-form hidden">
<div class="settings-title">数据备份与恢复</div> <div class="settings-title">数据备份与恢复</div>
<p class="settings-hint">备份目录:<span id="backupDir">/root/market_intel_backups</span><br/>列表仅显示最近 5 条。</p> <p class="settings-hint">备份目录:<span id="backupDir">/root/market_intel_backups</span><br/>列表仅显示最近 5 条。</p>
<p id="backupDirErr" class="form-err hidden"></p> <p id="backupDirErr" class="form-err hidden"></p>
@@ -187,6 +189,7 @@
<div class="btn-row"> <div class="btn-row">
<button type="button" class="btn-primary" id="autoSaveBtn">保存自动备份</button> <button type="button" class="btn-primary" id="autoSaveBtn">保存自动备份</button>
<button type="button" class="btn-primary" id="backupBtn">立即备份</button> <button type="button" class="btn-primary" id="backupBtn">立即备份</button>
<button type="button" class="btn-primary" id="downloadBtn">下载备份</button>
<button type="button" class="btn-danger" id="restoreBtn">恢复选中备份</button> <button type="button" class="btn-danger" id="restoreBtn">恢复选中备份</button>
</div> </div>
</div> </div>
@@ -340,6 +343,7 @@
document.getElementById("autoInterval").disabled = noAuth || !info.auto_enabled; document.getElementById("autoInterval").disabled = noAuth || !info.auto_enabled;
document.getElementById("autoSaveBtn").disabled = noAuth; document.getElementById("autoSaveBtn").disabled = noAuth;
document.getElementById("backupBtn").disabled = noAuth || noWrite; document.getElementById("backupBtn").disabled = noAuth || noWrite;
document.getElementById("downloadBtn").disabled = noAuth;
document.getElementById("restoreBtn").disabled = noAuth || noWrite; document.getElementById("restoreBtn").disabled = noAuth || noWrite;
} }
document.getElementById("autoEnabled").addEventListener("change", () => { document.getElementById("autoEnabled").addEventListener("change", () => {
@@ -451,6 +455,32 @@
} }
}); });
document.getElementById("downloadBtn").addEventListener("click", async () => {
const name = document.getElementById("backupSelect").value;
document.getElementById("backupErr").textContent = "";
document.getElementById("backupOk").textContent = "";
if (!name) { document.getElementById("backupErr").textContent = "请选择要下载的备份"; return; }
try {
const r = await apiFetch("/api/settings/backup/download/" + encodeURIComponent(name));
if (!r.ok) {
const d = await r.json().catch(() => ({}));
throw new Error(d.detail || "download failed");
}
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
document.getElementById("backupOk").textContent = "已开始下载:" + name;
} catch (err) {
document.getElementById("backupErr").textContent = String(err.message || err);
}
});
document.getElementById("restoreBtn").addEventListener("click", async () => { document.getElementById("restoreBtn").addEventListener("click", async () => {
const pwd = document.getElementById("backupPass").value; const pwd = document.getElementById("backupPass").value;
const name = document.getElementById("backupSelect").value; const name = document.getElementById("backupSelect").value;
+20
View File
@@ -148,6 +148,26 @@ export async function restoreBackup(current_password: string, backup_name: strin
return d; return d;
} }
export async function downloadBackup(backup_name: string): Promise<void> {
const r = await apiFetch(`/api/settings/backup/download/${encodeURIComponent(backup_name)}`);
if (!r.ok) {
const d = await r.json().catch(() => ({}));
throw new Error(d.detail || "download failed");
}
const blob = await r.blob();
const url = URL.createObjectURL(blob);
try {
const a = document.createElement("a");
a.href = url;
a.download = backup_name;
document.body.appendChild(a);
a.click();
a.remove();
} finally {
URL.revokeObjectURL(url);
}
}
export async function logout(): Promise<void> { export async function logout(): Promise<void> {
setToken(null); setToken(null);
await fetch("/api/auth/logout", { method: "POST", credentials: "include" }); await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
+1 -1
View File
@@ -27,7 +27,7 @@ export default function LoginGate({ onOk }: Props) {
return ( return (
<div className="center-page"> <div className="center-page">
<form className="tile settings-card" onSubmit={submit}> <form className="tile login-card" onSubmit={submit}>
<div className="settings-title"></div> <div className="settings-title"></div>
<div className="sub" style={{ marginBottom: "1rem" }}> <div className="sub" style={{ marginBottom: "1rem" }}>
+29 -4
View File
@@ -2,6 +2,7 @@ import { FormEvent, useCallback, useEffect, useState } from "react";
import { import {
BackupInfo, BackupInfo,
createBackup, createBackup,
downloadBackup,
fetchAccountSettings, fetchAccountSettings,
fetchBackupInfo, fetchBackupInfo,
logout, logout,
@@ -175,6 +176,24 @@ export default function SettingsPage() {
} }
}; };
const runDownload = async () => {
if (!selectedBackup) {
setBackupErr("请选择要下载的备份");
return;
}
setBackupBusy(true);
setBackupErr(null);
setBackupMsg(null);
try {
await downloadBackup(selectedBackup);
setBackupMsg(`已开始下载:${selectedBackup}`);
} catch (ex) {
setBackupErr(String(ex));
} finally {
setBackupBusy(false);
}
};
if (!ready) { if (!ready) {
return ( return (
<div className="layout"> <div className="layout">
@@ -192,7 +211,6 @@ export default function SettingsPage() {
<div className="sub"></div> <div className="sub"></div>
<AppNav /> <AppNav />
<div className="center-page">
<div className="tile settings-card"> <div className="tile settings-card">
<div className="settings-tabs"> <div className="settings-tabs">
<button <button
@@ -212,7 +230,7 @@ export default function SettingsPage() {
</div> </div>
{tab === "account" && ( {tab === "account" && (
<form onSubmit={submit}> <form className="settings-form" onSubmit={submit}>
<div className="settings-title"></div> <div className="settings-title"></div>
{!authRequired && ( {!authRequired && (
<p className="settings-hint">AUTH_SECRET=disabled .env </p> <p className="settings-hint">AUTH_SECRET=disabled .env </p>
@@ -275,7 +293,7 @@ export default function SettingsPage() {
)} )}
{tab === "backup" && ( {tab === "backup" && (
<div> <div className="settings-form">
<div className="settings-title"></div> <div className="settings-title"></div>
<p className="settings-hint"> <p className="settings-hint">
<code>{backupInfo?.backup_dir || "/root/market_intel_backups"}</code> <code>{backupInfo?.backup_dir || "/root/market_intel_backups"}</code>
@@ -366,6 +384,14 @@ export default function SettingsPage() {
> >
{backupBusy ? "处理中…" : "立即备份"} {backupBusy ? "处理中…" : "立即备份"}
</button> </button>
<button
type="button"
className="btn-primary"
disabled={backupBusy || !authRequired || !selectedBackup}
onClick={runDownload}
>
</button>
<button <button
type="button" type="button"
className="btn-danger" className="btn-danger"
@@ -381,6 +407,5 @@ export default function SettingsPage() {
)} )}
</div> </div>
</div> </div>
</div>
); );
} }
+3 -1
View File
@@ -31,7 +31,9 @@ a { color: var(--accent); text-decoration: none; }
min-height: 50vh; min-height: 50vh;
padding: 1.5rem 0 3rem; padding: 1.5rem 0 3rem;
} }
.settings-card { width: 100%; max-width: 460px; } .settings-card { width: 100%; }
.login-card { width: 100%; max-width: 420px; }
.settings-form { max-width: 520px; }
.settings-title { font-size: 1.15rem; font-weight: 650; margin: 0.25rem 0 0.85rem; } .settings-title { font-size: 1.15rem; font-weight: 650; margin: 0.25rem 0 0.85rem; }
.settings-hint { color: var(--muted); font-size: 0.85rem; line-height: 1.5; margin: 0 0 1rem; } .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; } .settings-hint code { color: var(--text); font-size: 0.82rem; }