feat: match settings card width to ops map and add backup download
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import re
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
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")
|
||||
def restore_backup(body: RestoreBody) -> dict:
|
||||
_verify_password_or_401(body.current_password)
|
||||
|
||||
Vendored
+34
-4
@@ -41,7 +41,9 @@
|
||||
.hidden { display: none; }
|
||||
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: 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-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; }
|
||||
@@ -76,7 +78,7 @@
|
||||
<main>
|
||||
<section id="page-login" class="hidden">
|
||||
<div class="center-page">
|
||||
<div class="tile settings-card">
|
||||
<div class="tile login-card">
|
||||
<div class="settings-title">登录</div>
|
||||
<label class="field"><span class="label">用户名</span>
|
||||
<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="tabBackup" data-tab="backup">数据备份与恢复</button>
|
||||
</div>
|
||||
<div id="panelAccount">
|
||||
<div id="panelAccount" class="settings-form">
|
||||
<form id="settingsForm">
|
||||
<div class="settings-title">账户密码</div>
|
||||
<p id="settingsAuthHint" class="settings-hint"></p>
|
||||
@@ -162,7 +164,7 @@
|
||||
<button type="submit" class="btn-primary" id="settingsSaveBtn">保存账号</button>
|
||||
</form>
|
||||
</div>
|
||||
<div id="panelBackup" class="hidden">
|
||||
<div id="panelBackup" class="settings-form hidden">
|
||||
<div class="settings-title">数据备份与恢复</div>
|
||||
<p class="settings-hint">备份目录:<span id="backupDir">/root/market_intel_backups</span><br/>列表仅显示最近 5 条。</p>
|
||||
<p id="backupDirErr" class="form-err hidden"></p>
|
||||
@@ -187,6 +189,7 @@
|
||||
<div class="btn-row">
|
||||
<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="downloadBtn">下载备份</button>
|
||||
<button type="button" class="btn-danger" id="restoreBtn">恢复选中备份</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -340,6 +343,7 @@
|
||||
document.getElementById("autoInterval").disabled = noAuth || !info.auto_enabled;
|
||||
document.getElementById("autoSaveBtn").disabled = noAuth;
|
||||
document.getElementById("backupBtn").disabled = noAuth || noWrite;
|
||||
document.getElementById("downloadBtn").disabled = noAuth;
|
||||
document.getElementById("restoreBtn").disabled = noAuth || noWrite;
|
||||
}
|
||||
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 () => {
|
||||
const pwd = document.getElementById("backupPass").value;
|
||||
const name = document.getElementById("backupSelect").value;
|
||||
|
||||
@@ -148,6 +148,26 @@ export async function restoreBackup(current_password: string, backup_name: strin
|
||||
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> {
|
||||
setToken(null);
|
||||
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function LoginGate({ onOk }: Props) {
|
||||
|
||||
return (
|
||||
<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="sub" style={{ marginBottom: "1rem" }}>
|
||||
比特骆驼行情采集分析
|
||||
|
||||
@@ -2,6 +2,7 @@ import { FormEvent, useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
BackupInfo,
|
||||
createBackup,
|
||||
downloadBackup,
|
||||
fetchAccountSettings,
|
||||
fetchBackupInfo,
|
||||
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) {
|
||||
return (
|
||||
<div className="layout">
|
||||
@@ -192,8 +211,7 @@ export default function SettingsPage() {
|
||||
<div className="sub">系统设置</div>
|
||||
<AppNav />
|
||||
|
||||
<div className="center-page">
|
||||
<div className="tile settings-card">
|
||||
<div className="tile settings-card">
|
||||
<div className="settings-tabs">
|
||||
<button
|
||||
type="button"
|
||||
@@ -212,7 +230,7 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
|
||||
{tab === "account" && (
|
||||
<form onSubmit={submit}>
|
||||
<form className="settings-form" onSubmit={submit}>
|
||||
<div className="settings-title">账户密码</div>
|
||||
{!authRequired && (
|
||||
<p className="settings-hint">当前鉴权已关闭(AUTH_SECRET=disabled),请在 .env 中修改。</p>
|
||||
@@ -275,7 +293,7 @@ export default function SettingsPage() {
|
||||
)}
|
||||
|
||||
{tab === "backup" && (
|
||||
<div>
|
||||
<div className="settings-form">
|
||||
<div className="settings-title">数据备份与恢复</div>
|
||||
<p className="settings-hint">
|
||||
备份目录:<code>{backupInfo?.backup_dir || "/root/market_intel_backups"}</code>
|
||||
@@ -366,6 +384,14 @@ export default function SettingsPage() {
|
||||
>
|
||||
{backupBusy ? "处理中…" : "立即备份"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={backupBusy || !authRequired || !selectedBackup}
|
||||
onClick={runDownload}
|
||||
>
|
||||
下载备份
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger"
|
||||
@@ -379,7 +405,6 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+3
-1
@@ -31,7 +31,9 @@ a { color: var(--accent); text-decoration: none; }
|
||||
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-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; }
|
||||
|
||||
Reference in New Issue
Block a user