diff --git a/.env.example b/.env.example index a205fc5..14a5646 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/apps/api/routes/settings.py b/apps/api/routes/settings.py index f54b59e..0260f59 100644 --- a/apps/api/routes/settings.py +++ b/apps/api/routes/settings.py @@ -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: diff --git a/apps/worker/main.py b/apps/worker/main.py index 2e4bca9..05a6433 100644 --- a/apps/worker/main.py +++ b/apps/worker/main.py @@ -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())) diff --git a/deploy/lib/common.sh b/deploy/lib/common.sh index 3fa16ad..6786537 100644 --- a/deploy/lib/common.sh +++ b/deploy/lib/common.sh @@ -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(可空)" "" diff --git a/docker-compose.yml b/docker-compose.yml index 5163ce9..8cfc7a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/packages/config/settings.py b/packages/config/settings.py index b61e752..46e93da 100644 --- a/packages/config/settings.py +++ b/packages/config/settings.py @@ -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") diff --git a/packages/db/backup.py b/packages/db/backup.py index dc33b2a..b783fce 100644 --- a/packages/db/backup.py +++ b/packages/db/backup.py @@ -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 diff --git a/tests/test_backup.py b/tests/test_backup.py index 0016cda..7c32b4e 100644 --- a/tests/test_backup.py +++ b/tests/test_backup.py @@ -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") diff --git a/web/dist/index.html b/web/dist/index.html index d733ed8..ac79b9e 100644 --- a/web/dist/index.html +++ b/web/dist/index.html @@ -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 @@ @@ -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 = ""; diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 25355ae..f13b811 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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 { @@ -105,6 +111,21 @@ export async function fetchBackupInfo(): Promise { return r.json(); } +export async function updateAutoBackup(body: { + current_password: string; + enabled: boolean; + interval_hours: number; +}): Promise { + 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", diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 1cbb9c3..cfaf8f7 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -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("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(null); const [backupPassword, setBackupPassword] = useState(""); const [selectedBackup, setSelectedBackup] = useState(""); + const [autoEnabled, setAutoEnabled] = useState(true); + const [intervalHours, setIntervalHours] = useState(24); const [backupMsg, setBackupMsg] = useState(null); const [backupErr, setBackupErr] = useState(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 (
比特骆驼行情采集分析
-
系统设置 · 账号与数据
+
系统设置
-
-
-
账号设置
- {!authRequired && ( -

当前鉴权已关闭(AUTH_SECRET=disabled),请在 .env 中修改。

- )} - - - - - - - - - - {err &&

{err}

} - {msg &&

{msg}

} - - + -
- -
-
数据备份与恢复
-

- 备份目录:{backupInfo?.backup_dir || "/root/market_intel_backups"} -

- {!backupInfo?.writable && ( -

备份目录不可写,请检查服务器 /root/market_intel_backups 权限与 Docker 挂载。

- )} - - - - {backupInfo && backupInfo.backups.length > 0 ? ( - - ) : ( -

尚无备份文件

- )} - - {backupErr &&

{backupErr}

} - {backupMsg &&

{backupMsg}

} - -
- - -
+ + {tab === "account" && ( +
+
账户密码
+ {!authRequired && ( +

当前鉴权已关闭(AUTH_SECRET=disabled),请在 .env 中修改。

+ )} + + + + + + + + + + {err &&

{err}

} + {msg &&

{msg}

} + + +
+ )} + + {tab === "backup" && ( +
+
数据备份与恢复
+

+ 备份目录:{backupInfo?.backup_dir || "/root/market_intel_backups"} +
+ 列表仅显示最近 {backupInfo?.display_limit ?? 5} 条;磁盘最多保留 {backupInfo?.keep_count ?? 30} 份。 +

+ {!backupInfo?.writable && ( +

备份目录不可写,请检查服务器 /root/market_intel_backups 权限与 Docker 挂载。

+ )} + +
+ + +

+ 最近备份:{fmtTime(backupInfo?.latest_backup_ms)} + {autoEnabled ? ` · 下次约:${fmtTime(backupInfo?.next_due_ms)}` : ""} +

+
+ + + + {backupInfo && backupInfo.backups.length > 0 ? ( + + ) : ( +

尚无备份文件

+ )} + + {backupErr &&

{backupErr}

} + {backupMsg &&

{backupMsg}

} + +
+ + + +
+
+ )}
diff --git a/web/src/styles.css b/web/src/styles.css index 3a174fe..c8f7f65 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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;