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
+3
View File
@@ -9,6 +9,9 @@ ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
ENV_FILE=/app/.env
BACKUP_DIR=/root/market_intel_backups
BACKUP_AUTO_ENABLED=1
BACKUP_INTERVAL_HOURS=24
BACKUP_KEEP_COUNT=30
# ---- 采集(OKX 只读;公开行情可留空 Key)----
OKX_API_KEY=
+65 -3
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import re
import time
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
@@ -10,7 +11,16 @@ from pydantic import BaseModel, Field
from apps.api.auth import auth_disabled, require_auth, verify_credentials
from packages.config import get_settings, reload_settings
from packages.config.env_file import update_env_file
from packages.db.backup import backup_db, ensure_backup_dir, list_backups, resolve_backup_file, restore_db
from packages.db.backup import (
DISPLAY_LIMIT,
backup_db,
ensure_backup_dir,
latest_backup_mtime_ms,
list_backups,
prune_backups,
resolve_backup_file,
restore_db,
)
router = APIRouter(
prefix="/settings",
@@ -36,6 +46,12 @@ class RestoreBody(BaseModel):
backup_name: str = Field(min_length=1, max_length=128)
class AutoBackupBody(BaseModel):
current_password: str = Field(min_length=1, max_length=256)
enabled: bool
interval_hours: float = Field(default=24, ge=1, le=168)
def _verify_password_or_401(current_password: str) -> None:
s = get_settings()
if auth_disabled():
@@ -44,6 +60,25 @@ def _verify_password_or_401(current_password: str) -> None:
raise HTTPException(status_code=401, detail="current password incorrect")
def _backup_meta(s) -> dict:
backup_dir = s.backup_dir_path
latest_ms = latest_backup_mtime_ms(backup_dir)
next_due_ms = None
if s.backup_auto_enabled and s.backup_interval_hours > 0:
if latest_ms is None:
next_due_ms = int(time.time() * 1000)
else:
next_due_ms = latest_ms + int(s.backup_interval_hours * 3600 * 1000)
return {
"auto_enabled": s.backup_auto_enabled,
"interval_hours": s.backup_interval_hours,
"keep_count": s.backup_keep_count,
"display_limit": DISPLAY_LIMIT,
"latest_backup_ms": latest_ms,
"next_due_ms": next_due_ms,
}
@router.get("/backup")
def get_backup_info() -> dict:
s = get_settings()
@@ -55,9 +90,9 @@ def get_backup_info() -> dict:
probe.write_text("", encoding="utf-8")
probe.unlink(missing_ok=True)
writable = True
items = list_backups(backup_dir)
items = list_backups(backup_dir, limit=DISPLAY_LIMIT)
except OSError as e:
items = list_backups(backup_dir) if backup_dir.is_dir() else []
items = list_backups(backup_dir, limit=DISPLAY_LIMIT) if backup_dir.is_dir() else []
if not items:
raise HTTPException(status_code=500, detail=f"backup dir error: {e}") from e
return {
@@ -65,6 +100,32 @@ def get_backup_info() -> dict:
"db_path": str(s.db_path),
"writable": writable,
"backups": items,
**_backup_meta(s),
}
@router.put("/backup/auto")
def update_auto_backup(body: AutoBackupBody) -> dict:
_verify_password_or_401(body.current_password)
s = get_settings()
updates = {
"BACKUP_AUTO_ENABLED": "1" if body.enabled else "0",
"BACKUP_INTERVAL_HOURS": str(
int(body.interval_hours)
if float(body.interval_hours).is_integer()
else body.interval_hours
),
}
try:
update_env_file(s.env_file_path, updates)
except OSError as e:
raise HTTPException(status_code=500, detail=f"failed to write {s.env_file_path}: {e}") from e
reload_settings()
info = get_backup_info()
return {
"ok": True,
"message": "自动备份设置已保存",
**info,
}
@@ -74,6 +135,7 @@ def create_backup(body: BackupActionBody) -> dict:
s = get_settings()
try:
dest = backup_db(s.db_path, s.backup_dir_path, tz=s.tz)
prune_backups(s.backup_dir_path, s.backup_keep_count)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
except OSError as e:
+30 -3
View File
@@ -1,4 +1,4 @@
"""Worker 入口:周期回填到期结算指数。"""
"""Worker 入口:周期回填到期结算指数 + 自动备份"""
from __future__ import annotations
@@ -10,8 +10,9 @@ from typing import Any
from apps.collector.okx_rest import OkxRestClient
from apps.worker.settle import backfill_settlements
from packages.config import get_settings
from packages.config import get_settings, reload_settings
from packages.db import Repository
from packages.db.backup import run_auto_backup, should_auto_backup
logging.basicConfig(
level=logging.INFO,
@@ -29,13 +30,35 @@ def _handle_signal(signum: int, _frame: Any) -> None:
_STOP = True
def _maybe_auto_backup() -> None:
s = reload_settings()
if not should_auto_backup(
s.backup_dir_path,
enabled=s.backup_auto_enabled,
interval_hours=s.backup_interval_hours,
):
return
try:
dest = run_auto_backup(
s.db_path,
s.backup_dir_path,
tz=s.tz,
keep=s.backup_keep_count,
)
log.info("auto backup ok: %s", dest.name)
except Exception as e: # noqa: BLE001
log.exception("auto backup failed: %s", e)
def run() -> int:
settings = get_settings()
interval = max(60, int(settings.settle_backfill_interval_sec))
log.info(
"start settle backfill interval=%ss db=%s",
"start settle backfill interval=%ss db=%s backup_auto=%s every=%sh",
interval,
settings.db_path,
settings.backup_auto_enabled,
settings.backup_interval_hours,
)
repo = Repository(settings.db_path)
client = OkxRestClient(
@@ -43,6 +66,8 @@ def run() -> int:
proxy=settings.okx_proxy or None,
)
try:
# 启动时先尝试一次自动备份(若到期)
_maybe_auto_backup()
while not _STOP:
try:
result = backfill_settlements(
@@ -62,6 +87,8 @@ def run() -> int:
except Exception as e: # noqa: BLE001
log.exception("backfill loop failed: %s", e)
_maybe_auto_backup()
end = time.monotonic() + interval
while not _STOP and time.monotonic() < end:
time.sleep(min(1.0, end - time.monotonic()))
+2
View File
@@ -223,6 +223,8 @@ ensure_dotenv() {
ensure_env_key "${envf}" "AUTH_SECRET" "鉴权密钥(disabled 关闭)" "change-me"
ensure_env_key "${envf}" "ADMIN_USERNAME" "管理员用户名" "admin"
ensure_env_key "${envf}" "ADMIN_PASSWORD" "管理员密码" "admin123"
ensure_env_key "${envf}" "BACKUP_AUTO_ENABLED" "自动备份 1/0" "1"
ensure_env_key "${envf}" "BACKUP_INTERVAL_HOURS" "自动备份间隔小时" "24"
ensure_env_key "${envf}" "OKX_API_KEY" "OKX API Key(可空)" ""
ensure_env_key "${envf}" "OKX_API_SECRET" "OKX API Secret(可空)" ""
ensure_env_key "${envf}" "OKX_API_PASSPHRASE" "OKX Passphrase(可空)" ""
+4
View File
@@ -50,8 +50,12 @@ services:
environment:
TZ: Asia/Shanghai
MI_DB_PATH: /app/data/market_intel.db
ENV_FILE: /app/.env
BACKUP_DIR: /root/market_intel_backups
volumes:
- mi_data:/app/data
- ./.env:/app/.env
- /root/market_intel_backups:/root/market_intel_backups
depends_on:
- collector
restart: unless-stopped
+3
View File
@@ -24,6 +24,9 @@ class Settings(BaseSettings):
admin_password: str = Field(default="admin123", alias="ADMIN_PASSWORD")
env_file: str = Field(default=".env", alias="ENV_FILE")
backup_dir: str = Field(default="/root/market_intel_backups", alias="BACKUP_DIR")
backup_auto_enabled: bool = Field(default=True, alias="BACKUP_AUTO_ENABLED")
backup_interval_hours: float = Field(default=24.0, alias="BACKUP_INTERVAL_HOURS", ge=1, le=168)
backup_keep_count: int = Field(default=30, alias="BACKUP_KEEP_COUNT", ge=5, le=200)
# OKX
okx_api_key: str = Field(default="", alias="OKX_API_KEY")
+68 -1
View File
@@ -2,13 +2,17 @@
from __future__ import annotations
import logging
import re
import sqlite3
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
log = logging.getLogger("backup")
_BACKUP_NAME_RE = re.compile(r"^market_intel_\d{8}_\d{6}\.db$")
DISPLAY_LIMIT = 5
def _now_stamp(tz: str = "Asia/Shanghai") -> str:
@@ -69,7 +73,7 @@ def restore_db(backup_path: Path, db_path: Path) -> None:
src.close()
def list_backups(backup_dir: Path) -> list[dict]:
def list_backups(backup_dir: Path, limit: int | None = None) -> list[dict]:
if not backup_dir.is_dir():
return []
items: list[dict] = []
@@ -84,4 +88,67 @@ def list_backups(backup_dir: Path) -> list[dict]:
"modified_ms": int(st.st_mtime * 1000),
}
)
if limit is not None and len(items) >= limit:
break
return items
def prune_backups(backup_dir: Path, keep: int) -> int:
"""删除超出保留数量的旧备份,返回删除数。"""
if keep < 1:
return 0
if not backup_dir.is_dir():
return 0
files = sorted(
[
p
for p in backup_dir.glob("market_intel_*.db")
if p.is_file() and is_valid_backup_name(p.name)
],
key=lambda p: p.stat().st_mtime,
reverse=True,
)
removed = 0
for p in files[keep:]:
try:
p.unlink()
removed += 1
except OSError as e:
log.warning("failed to prune %s: %s", p, e)
return removed
def latest_backup_mtime_ms(backup_dir: Path) -> int | None:
items = list_backups(backup_dir, limit=1)
if not items:
return None
return int(items[0]["modified_ms"])
def should_auto_backup(
backup_dir: Path,
*,
enabled: bool,
interval_hours: float,
) -> bool:
if not enabled:
return False
if interval_hours <= 0:
return False
latest = latest_backup_mtime_ms(backup_dir)
if latest is None:
return True
age_ms = int(datetime.now().timestamp() * 1000) - latest
return age_ms >= interval_hours * 3600 * 1000
def run_auto_backup(
db_path: Path,
backup_dir: Path,
*,
tz: str = "Asia/Shanghai",
keep: int = 30,
) -> Path:
dest = backup_db(db_path, backup_dir, tz=tz)
prune_backups(backup_dir, keep)
return dest
+47 -5
View File
@@ -3,22 +3,29 @@ from pathlib import Path
import pytest
from packages.db.backup import (
DISPLAY_LIMIT,
backup_db,
is_valid_backup_name,
list_backups,
prune_backups,
resolve_backup_file,
restore_db,
should_auto_backup,
)
def _make_db(path: Path, value: str = "hello") -> None:
conn = __import__("sqlite3").connect(str(path))
conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
conn.execute("INSERT INTO t(v) VALUES (?)", (value,))
conn.commit()
conn.close()
def test_backup_roundtrip(tmp_path: Path):
db = tmp_path / "live.db"
backup_dir = tmp_path / "backups"
conn = __import__("sqlite3").connect(str(db))
conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
conn.execute("INSERT INTO t(v) VALUES ('hello')")
conn.commit()
conn.close()
_make_db(db)
dest = backup_db(db, backup_dir, tz="UTC")
assert dest.is_file()
@@ -41,6 +48,41 @@ def test_backup_roundtrip(tmp_path: Path):
assert resolve_backup_file(backup_dir, dest.name) == dest.resolve()
def test_list_limit_and_prune(tmp_path: Path):
backup_dir = tmp_path / "backups"
backup_dir.mkdir()
for i in range(7):
p = backup_dir / f"market_intel_2026080{i+1}_120000.db"
p.write_bytes(b"x" * (i + 1))
# mtime 递增,便于 prune 保留最新
import os
os.utime(p, (1_700_000_000 + i, 1_700_000_000 + i))
all_items = list_backups(backup_dir)
assert len(all_items) == 7
limited = list_backups(backup_dir, limit=DISPLAY_LIMIT)
assert len(limited) == 5
assert limited[0]["name"] == "market_intel_20260807_120000.db"
removed = prune_backups(backup_dir, keep=3)
assert removed == 4
left = list_backups(backup_dir)
assert len(left) == 3
assert left[0]["name"] == "market_intel_20260807_120000.db"
def test_should_auto_backup(tmp_path: Path):
assert should_auto_backup(tmp_path, enabled=False, interval_hours=24) is False
assert should_auto_backup(tmp_path, enabled=True, interval_hours=24) is True
db = tmp_path / "live.db"
_make_db(db)
backup_db(db, tmp_path, tz="UTC")
assert should_auto_backup(tmp_path, enabled=True, interval_hours=24) is False
assert should_auto_backup(tmp_path, enabled=True, interval_hours=0.000001) is True
def test_invalid_backup_name(tmp_path: Path):
with pytest.raises(ValueError):
resolve_backup_file(tmp_path, "../etc/passwd")
+111 -42
View File
@@ -41,10 +41,15 @@
.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: 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.75rem; }
.settings-card { width: 100%; max-width: 460px; }
.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; }
.settings-tabs button { appearance: none; border: 0; background: transparent; color: var(--muted); padding: 0.65rem 0.95rem; cursor: pointer; font-size: 0.92rem; border-bottom: 2px solid transparent; margin-bottom: -1px; }
.settings-tabs button.active { color: var(--text); border-bottom-color: var(--accent); font-weight: 600; }
.auto-box { background: #0c1117; border: 1px solid #243041; border-radius: 8px; padding: 0.85rem 0.95rem; margin-bottom: 1rem; }
.check-row { display: flex; align-items: center; gap: 0.55rem; margin-bottom: 0.75rem; color: var(--text); font-size: 0.92rem; }
.check-row input { width: auto; accent-color: var(--accent); }
.field { display: block; margin-bottom: 1rem; }
.field-input { width: 100%; margin-top: 0.4rem; padding: 0.55rem 0.65rem; border-radius: 6px; border: 1px solid #243041; background: #0c1117; color: var(--text); }
.btn-primary { margin-top: 0.5rem; padding: 0.55rem 1.2rem; border-radius: 6px; border: 0; background: var(--accent); color: #fff; cursor: pointer; }
@@ -131,43 +136,60 @@
</section>
<section id="page-settings" class="hidden">
<div class="center-page">
<div class="settings-stack">
<form id="settingsForm" class="tile settings-card">
<div class="settings-title">账号设置</div>
<p id="settingsAuthHint" class="settings-hint" style="margin-bottom:1rem"></p>
<label class="field"><span class="label">用户名</span>
<input id="setUsername" class="field-input" autocomplete="username" />
</label>
<label class="field"><span class="label">当前密码</span>
<input id="setCurPass" type="password" class="field-input" autocomplete="current-password" />
</label>
<label class="field"><span class="label">新密码(留空则不修改)</span>
<input id="setNewPass" type="password" class="field-input" autocomplete="new-password" />
</label>
<label class="field"><span class="label">确认新密码</span>
<input id="setNewPass2" type="password" class="field-input" autocomplete="new-password" />
</label>
<p id="settingsErr" class="form-err"></p>
<p id="settingsOk" class="form-ok"></p>
<button type="submit" class="btn-primary" id="settingsSaveBtn">保存账号</button>
</form>
<div class="tile settings-card">
<div class="settings-title">数据备份与恢复</div>
<p class="settings-hint">备份目录:<span id="backupDir">/root/market_intel_backups</span></p>
<p id="backupDirErr" class="form-err hidden"></p>
<label class="field"><span class="label">当前密码(备份/恢复需验证)</span>
<input id="backupPass" type="password" class="field-input" autocomplete="current-password" />
</label>
<label class="field"><span class="label">选择备份</span>
<select id="backupSelect" class="field-input"><option value="">尚无备份</option></select>
</label>
<p id="backupErr" class="form-err"></p>
<p id="backupOk" class="form-ok"></p>
<div class="btn-row">
<button type="button" class="btn-primary" id="backupBtn">立即备份</button>
<button type="button" class="btn-danger" id="restoreBtn">恢复选中备份</button>
<div class="settings-tabs">
<button type="button" id="tabAccount" class="active" data-tab="account">账户密码</button>
<button type="button" id="tabBackup" data-tab="backup">数据备份与恢复</button>
</div>
<div id="panelAccount">
<form id="settingsForm">
<div class="settings-title">账户密码</div>
<p id="settingsAuthHint" class="settings-hint"></p>
<label class="field"><span class="label">用户名</span>
<input id="setUsername" class="field-input" autocomplete="username" />
</label>
<label class="field"><span class="label">当前密码</span>
<input id="setCurPass" type="password" class="field-input" autocomplete="current-password" />
</label>
<label class="field"><span class="label">新密码(留空则不修改)</span>
<input id="setNewPass" type="password" class="field-input" autocomplete="new-password" />
</label>
<label class="field"><span class="label">确认新密码</span>
<input id="setNewPass2" type="password" class="field-input" autocomplete="new-password" />
</label>
<p id="settingsErr" class="form-err"></p>
<p id="settingsOk" class="form-ok"></p>
<button type="submit" class="btn-primary" id="settingsSaveBtn">保存账号</button>
</form>
</div>
<div id="panelBackup" class="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>
<div class="auto-box">
<label class="check-row">
<input type="checkbox" id="autoEnabled" checked />
<span>启用自动备份</span>
</label>
<label class="field"><span class="label">备份间隔(小时)</span>
<input id="autoInterval" type="number" min="1" max="168" value="24" class="field-input" />
</label>
<p class="settings-hint" style="margin-bottom:0" id="autoMeta">最近备份:—</p>
</div>
<label class="field"><span class="label">当前密码(操作需验证)</span>
<input id="backupPass" type="password" class="field-input" autocomplete="current-password" />
</label>
<label class="field"><span class="label">选择备份(最近 5 条)</span>
<select id="backupSelect" class="field-input"><option value="">尚无备份</option></select>
</label>
<p id="backupErr" class="form-err"></p>
<p id="backupOk" class="form-ok"></p>
<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-danger" id="restoreBtn">恢复选中备份</button>
</div>
</div>
</div>
</div>
</div>
</section>
@@ -279,6 +301,16 @@
sel.appendChild(opt);
});
}
function showSettingsTab(tab) {
const isAccount = tab === "account";
document.getElementById("panelAccount").classList.toggle("hidden", !isAccount);
document.getElementById("panelBackup").classList.toggle("hidden", isAccount);
document.getElementById("tabAccount").classList.toggle("active", isAccount);
document.getElementById("tabBackup").classList.toggle("active", !isAccount);
}
document.getElementById("tabAccount").addEventListener("click", () => showSettingsTab("account"));
document.getElementById("tabBackup").addEventListener("click", () => showSettingsTab("backup"));
async function loadBackupInfo() {
document.getElementById("backupErr").textContent = "";
document.getElementById("backupOk").textContent = "";
@@ -294,12 +326,25 @@
} else {
dirErr.classList.add("hidden");
}
document.getElementById("autoEnabled").checked = !!info.auto_enabled;
document.getElementById("autoInterval").value = info.interval_hours || 24;
let meta = "最近备份:" + (info.latest_backup_ms ? fmtBackupTime(info.latest_backup_ms) : "—");
if (info.auto_enabled) meta += " · 下次约:" + (info.next_due_ms ? fmtBackupTime(info.next_due_ms) : "即将");
document.getElementById("autoMeta").textContent = meta;
renderBackupSelect(info.backups || []);
const disabled = !state.authRequired || !info.writable;
["backupPass","backupSelect","backupBtn","restoreBtn"].forEach(id => {
document.getElementById(id).disabled = disabled;
});
const noAuth = !state.authRequired;
const noWrite = !info.writable;
document.getElementById("backupPass").disabled = noAuth;
document.getElementById("backupSelect").disabled = noAuth;
document.getElementById("autoEnabled").disabled = noAuth;
document.getElementById("autoInterval").disabled = noAuth || !info.auto_enabled;
document.getElementById("autoSaveBtn").disabled = noAuth;
document.getElementById("backupBtn").disabled = noAuth || noWrite;
document.getElementById("restoreBtn").disabled = noAuth || noWrite;
}
document.getElementById("autoEnabled").addEventListener("change", () => {
document.getElementById("autoInterval").disabled = !document.getElementById("autoEnabled").checked || !state.authRequired;
});
async function loadSettings() {
document.getElementById("settingsErr").textContent = "";
@@ -361,6 +406,30 @@
}
});
document.getElementById("autoSaveBtn").addEventListener("click", async () => {
const pwd = document.getElementById("backupPass").value;
document.getElementById("backupErr").textContent = "";
document.getElementById("backupOk").textContent = "";
if (!pwd) { document.getElementById("backupErr").textContent = "请输入当前密码"; return; }
try {
const r = await apiFetch("/api/settings/backup/auto", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
current_password: pwd,
enabled: document.getElementById("autoEnabled").checked,
interval_hours: Number(document.getElementById("autoInterval").value) || 24,
}),
});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || "save failed");
document.getElementById("backupOk").textContent = d.message || "自动备份设置已保存";
await loadBackupInfo();
} catch (err) {
document.getElementById("backupErr").textContent = String(err.message || err);
}
});
document.getElementById("backupBtn").addEventListener("click", async () => {
const pwd = document.getElementById("backupPass").value;
document.getElementById("backupErr").textContent = "";
+21
View File
@@ -97,6 +97,12 @@ export type BackupInfo = {
db_path: string;
writable: boolean;
backups: BackupItem[];
auto_enabled: boolean;
interval_hours: number;
keep_count: number;
display_limit: number;
latest_backup_ms: number | null;
next_due_ms: number | null;
};
export async function fetchBackupInfo(): Promise<BackupInfo> {
@@ -105,6 +111,21 @@ export async function fetchBackupInfo(): Promise<BackupInfo> {
return r.json();
}
export async function updateAutoBackup(body: {
current_password: string;
enabled: boolean;
interval_hours: number;
}): Promise<BackupInfo & { ok: boolean; message: string }> {
const r = await apiFetch("/api/settings/backup/auto", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.detail || "save auto backup failed");
return d;
}
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",
+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>
+40 -3
View File
@@ -31,11 +31,48 @@ a { color: var(--accent); text-decoration: none; }
min-height: 50vh;
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-card { width: 100%; max-width: 460px; }
.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; }
.settings-tabs {
display: flex;
gap: 0;
margin: -0.25rem -0.15rem 1.1rem;
border-bottom: 1px solid #243041;
}
.settings-tabs button {
appearance: none;
border: 0;
background: transparent;
color: var(--muted);
padding: 0.65rem 0.95rem;
cursor: pointer;
font-size: 0.92rem;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.settings-tabs button.active {
color: var(--text);
border-bottom-color: var(--accent);
font-weight: 600;
}
.auto-box {
background: #0c1117;
border: 1px solid #243041;
border-radius: 8px;
padding: 0.85rem 0.95rem;
margin-bottom: 1rem;
}
.check-row {
display: flex;
align-items: center;
gap: 0.55rem;
margin-bottom: 0.75rem;
color: var(--text);
font-size: 0.92rem;
}
.check-row input { width: auto; accent-color: var(--accent); }
.btn-row { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 0.25rem; }
.btn-danger {
padding: 0.55rem 1.2rem;