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 ADMIN_PASSWORD=admin123
ENV_FILE=/app/.env ENV_FILE=/app/.env
BACKUP_DIR=/root/market_intel_backups BACKUP_DIR=/root/market_intel_backups
BACKUP_AUTO_ENABLED=1
BACKUP_INTERVAL_HOURS=24
BACKUP_KEEP_COUNT=30
# ---- 采集(OKX 只读;公开行情可留空 Key)---- # ---- 采集(OKX 只读;公开行情可留空 Key)----
OKX_API_KEY= OKX_API_KEY=
+65 -3
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import re import re
import time
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field 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 apps.api.auth import auth_disabled, require_auth, verify_credentials
from packages.config import get_settings, reload_settings from packages.config import get_settings, reload_settings
from packages.config.env_file import update_env_file 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( router = APIRouter(
prefix="/settings", prefix="/settings",
@@ -36,6 +46,12 @@ class RestoreBody(BaseModel):
backup_name: str = Field(min_length=1, max_length=128) 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: def _verify_password_or_401(current_password: str) -> None:
s = get_settings() s = get_settings()
if auth_disabled(): 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") 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") @router.get("/backup")
def get_backup_info() -> dict: def get_backup_info() -> dict:
s = get_settings() s = get_settings()
@@ -55,9 +90,9 @@ def get_backup_info() -> dict:
probe.write_text("", encoding="utf-8") probe.write_text("", encoding="utf-8")
probe.unlink(missing_ok=True) probe.unlink(missing_ok=True)
writable = True writable = True
items = list_backups(backup_dir) items = list_backups(backup_dir, limit=DISPLAY_LIMIT)
except OSError as e: 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: if not items:
raise HTTPException(status_code=500, detail=f"backup dir error: {e}") from e raise HTTPException(status_code=500, detail=f"backup dir error: {e}") from e
return { return {
@@ -65,6 +100,32 @@ def get_backup_info() -> dict:
"db_path": str(s.db_path), "db_path": str(s.db_path),
"writable": writable, "writable": writable,
"backups": items, "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() s = get_settings()
try: try:
dest = backup_db(s.db_path, s.backup_dir_path, tz=s.tz) 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: except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e raise HTTPException(status_code=404, detail=str(e)) from e
except OSError as e: except OSError as e:
+30 -3
View File
@@ -1,4 +1,4 @@
"""Worker 入口:周期回填到期结算指数。""" """Worker 入口:周期回填到期结算指数 + 自动备份"""
from __future__ import annotations from __future__ import annotations
@@ -10,8 +10,9 @@ from typing import Any
from apps.collector.okx_rest import OkxRestClient from apps.collector.okx_rest import OkxRestClient
from apps.worker.settle import backfill_settlements 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 import Repository
from packages.db.backup import run_auto_backup, should_auto_backup
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@@ -29,13 +30,35 @@ def _handle_signal(signum: int, _frame: Any) -> None:
_STOP = True _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: def run() -> int:
settings = get_settings() settings = get_settings()
interval = max(60, int(settings.settle_backfill_interval_sec)) interval = max(60, int(settings.settle_backfill_interval_sec))
log.info( log.info(
"start settle backfill interval=%ss db=%s", "start settle backfill interval=%ss db=%s backup_auto=%s every=%sh",
interval, interval,
settings.db_path, settings.db_path,
settings.backup_auto_enabled,
settings.backup_interval_hours,
) )
repo = Repository(settings.db_path) repo = Repository(settings.db_path)
client = OkxRestClient( client = OkxRestClient(
@@ -43,6 +66,8 @@ def run() -> int:
proxy=settings.okx_proxy or None, proxy=settings.okx_proxy or None,
) )
try: try:
# 启动时先尝试一次自动备份(若到期)
_maybe_auto_backup()
while not _STOP: while not _STOP:
try: try:
result = backfill_settlements( result = backfill_settlements(
@@ -62,6 +87,8 @@ def run() -> int:
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
log.exception("backfill loop failed: %s", e) log.exception("backfill loop failed: %s", e)
_maybe_auto_backup()
end = time.monotonic() + interval end = time.monotonic() + interval
while not _STOP and time.monotonic() < end: while not _STOP and time.monotonic() < end:
time.sleep(min(1.0, end - time.monotonic())) 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}" "AUTH_SECRET" "鉴权密钥(disabled 关闭)" "change-me"
ensure_env_key "${envf}" "ADMIN_USERNAME" "管理员用户名" "admin" ensure_env_key "${envf}" "ADMIN_USERNAME" "管理员用户名" "admin"
ensure_env_key "${envf}" "ADMIN_PASSWORD" "管理员密码" "admin123" 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_KEY" "OKX API Key(可空)" ""
ensure_env_key "${envf}" "OKX_API_SECRET" "OKX API Secret(可空)" "" ensure_env_key "${envf}" "OKX_API_SECRET" "OKX API Secret(可空)" ""
ensure_env_key "${envf}" "OKX_API_PASSPHRASE" "OKX Passphrase(可空)" "" ensure_env_key "${envf}" "OKX_API_PASSPHRASE" "OKX Passphrase(可空)" ""
+4
View File
@@ -50,8 +50,12 @@ services:
environment: environment:
TZ: Asia/Shanghai TZ: Asia/Shanghai
MI_DB_PATH: /app/data/market_intel.db MI_DB_PATH: /app/data/market_intel.db
ENV_FILE: /app/.env
BACKUP_DIR: /root/market_intel_backups
volumes: volumes:
- mi_data:/app/data - mi_data:/app/data
- ./.env:/app/.env
- /root/market_intel_backups:/root/market_intel_backups
depends_on: depends_on:
- collector - collector
restart: unless-stopped restart: unless-stopped
+3
View File
@@ -24,6 +24,9 @@ class Settings(BaseSettings):
admin_password: str = Field(default="admin123", alias="ADMIN_PASSWORD") admin_password: str = Field(default="admin123", alias="ADMIN_PASSWORD")
env_file: str = Field(default=".env", alias="ENV_FILE") env_file: str = Field(default=".env", alias="ENV_FILE")
backup_dir: str = Field(default="/root/market_intel_backups", alias="BACKUP_DIR") 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
okx_api_key: str = Field(default="", alias="OKX_API_KEY") okx_api_key: str = Field(default="", alias="OKX_API_KEY")
+68 -1
View File
@@ -2,13 +2,17 @@
from __future__ import annotations from __future__ import annotations
import logging
import re import re
import sqlite3 import sqlite3
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
log = logging.getLogger("backup")
_BACKUP_NAME_RE = re.compile(r"^market_intel_\d{8}_\d{6}\.db$") _BACKUP_NAME_RE = re.compile(r"^market_intel_\d{8}_\d{6}\.db$")
DISPLAY_LIMIT = 5
def _now_stamp(tz: str = "Asia/Shanghai") -> str: def _now_stamp(tz: str = "Asia/Shanghai") -> str:
@@ -69,7 +73,7 @@ def restore_db(backup_path: Path, db_path: Path) -> None:
src.close() 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(): if not backup_dir.is_dir():
return [] return []
items: list[dict] = [] items: list[dict] = []
@@ -84,4 +88,67 @@ def list_backups(backup_dir: Path) -> list[dict]:
"modified_ms": int(st.st_mtime * 1000), "modified_ms": int(st.st_mtime * 1000),
} }
) )
if limit is not None and len(items) >= limit:
break
return items 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 import pytest
from packages.db.backup import ( from packages.db.backup import (
DISPLAY_LIMIT,
backup_db, backup_db,
is_valid_backup_name, is_valid_backup_name,
list_backups, list_backups,
prune_backups,
resolve_backup_file, resolve_backup_file,
restore_db, 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): def test_backup_roundtrip(tmp_path: Path):
db = tmp_path / "live.db" db = tmp_path / "live.db"
backup_dir = tmp_path / "backups" backup_dir = tmp_path / "backups"
conn = __import__("sqlite3").connect(str(db)) _make_db(db)
conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)")
conn.execute("INSERT INTO t(v) VALUES ('hello')")
conn.commit()
conn.close()
dest = backup_db(db, backup_dir, tz="UTC") dest = backup_db(db, backup_dir, tz="UTC")
assert dest.is_file() 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() 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): def test_invalid_backup_name(tmp_path: Path):
with pytest.raises(ValueError): with pytest.raises(ValueError):
resolve_backup_file(tmp_path, "../etc/passwd") resolve_backup_file(tmp_path, "../etc/passwd")
+111 -42
View File
@@ -41,10 +41,15 @@
.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: 420px; } .settings-card { width: 100%; max-width: 460px; }
.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: 0.25rem 0 0.85rem; }
.settings-title { font-size: 1.15rem; font-weight: 650; margin-bottom: 0.75rem; }
.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 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 { 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); } .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; } .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>
<section id="page-settings" class="hidden"> <section id="page-settings" class="hidden">
<div class="center-page"> <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="tile settings-card">
<div class="settings-title">数据备份与恢复</div> <div class="settings-tabs">
<p class="settings-hint">备份目录:<span id="backupDir">/root/market_intel_backups</span></p> <button type="button" id="tabAccount" class="active" data-tab="account">账户密码</button>
<p id="backupDirErr" class="form-err hidden"></p> <button type="button" id="tabBackup" data-tab="backup">数据备份与恢复</button>
<label class="field"><span class="label">当前密码(备份/恢复需验证)</span> </div>
<input id="backupPass" type="password" class="field-input" autocomplete="current-password" /> <div id="panelAccount">
</label> <form id="settingsForm">
<label class="field"><span class="label">选择备份</span> <div class="settings-title">账户密码</div>
<select id="backupSelect" class="field-input"><option value="">尚无备份</option></select> <p id="settingsAuthHint" class="settings-hint"></p>
</label> <label class="field"><span class="label">用户名</span>
<p id="backupErr" class="form-err"></p> <input id="setUsername" class="field-input" autocomplete="username" />
<p id="backupOk" class="form-ok"></p> </label>
<div class="btn-row"> <label class="field"><span class="label">当前密码</span>
<button type="button" class="btn-primary" id="backupBtn">立即备份</button> <input id="setCurPass" type="password" class="field-input" autocomplete="current-password" />
<button type="button" class="btn-danger" id="restoreBtn">恢复选中备份</button> </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> </div>
</div> </div>
</section> </section>
@@ -279,6 +301,16 @@
sel.appendChild(opt); 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() { async function loadBackupInfo() {
document.getElementById("backupErr").textContent = ""; document.getElementById("backupErr").textContent = "";
document.getElementById("backupOk").textContent = ""; document.getElementById("backupOk").textContent = "";
@@ -294,12 +326,25 @@
} else { } else {
dirErr.classList.add("hidden"); 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 || []); renderBackupSelect(info.backups || []);
const disabled = !state.authRequired || !info.writable; const noAuth = !state.authRequired;
["backupPass","backupSelect","backupBtn","restoreBtn"].forEach(id => { const noWrite = !info.writable;
document.getElementById(id).disabled = disabled; 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() { async function loadSettings() {
document.getElementById("settingsErr").textContent = ""; 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 () => { document.getElementById("backupBtn").addEventListener("click", async () => {
const pwd = document.getElementById("backupPass").value; const pwd = document.getElementById("backupPass").value;
document.getElementById("backupErr").textContent = ""; document.getElementById("backupErr").textContent = "";
+21
View File
@@ -97,6 +97,12 @@ export type BackupInfo = {
db_path: string; db_path: string;
writable: boolean; writable: boolean;
backups: BackupItem[]; 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> { export async function fetchBackupInfo(): Promise<BackupInfo> {
@@ -105,6 +111,21 @@ export async function fetchBackupInfo(): Promise<BackupInfo> {
return r.json(); 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 }> { export async function createBackup(current_password: string): Promise<{ ok: boolean; message: string; name: string; path: string }> {
const r = await apiFetch("/api/settings/backup", { const r = await apiFetch("/api/settings/backup", {
method: "POST", method: "POST",
+227 -133
View File
@@ -7,17 +7,21 @@ import {
logout, logout,
restoreBackup, restoreBackup,
updateAccountSettings, updateAccountSettings,
updateAutoBackup,
} from "../api/client"; } from "../api/client";
import AppNav from "../components/AppNav"; import AppNav from "../components/AppNav";
import LoginGate from "../components/LoginGate"; import LoginGate from "../components/LoginGate";
type TabKey = "account" | "backup";
function fmtBytes(n: number): string { function fmtBytes(n: number): string {
if (n < 1024) return `${n} B`; if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(2)} MB`; return `${(n / (1024 * 1024)).toFixed(2)} MB`;
} }
function fmtTime(ms: number): string { function fmtTime(ms: number | null | undefined): string {
if (ms == null) return "—";
try { try {
return new Date(ms).toLocaleString("zh-CN", { hour12: false }); return new Date(ms).toLocaleString("zh-CN", { hour12: false });
} catch { } catch {
@@ -26,6 +30,7 @@ function fmtTime(ms: number): string {
} }
export default function SettingsPage() { export default function SettingsPage() {
const [tab, setTab] = useState<TabKey>("account");
const [needLogin, setNeedLogin] = useState(false); const [needLogin, setNeedLogin] = useState(false);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [username, setUsername] = useState("admin"); const [username, setUsername] = useState("admin");
@@ -41,15 +46,26 @@ export default function SettingsPage() {
const [backupInfo, setBackupInfo] = useState<BackupInfo | null>(null); const [backupInfo, setBackupInfo] = useState<BackupInfo | null>(null);
const [backupPassword, setBackupPassword] = useState(""); const [backupPassword, setBackupPassword] = useState("");
const [selectedBackup, setSelectedBackup] = useState(""); const [selectedBackup, setSelectedBackup] = useState("");
const [autoEnabled, setAutoEnabled] = useState(true);
const [intervalHours, setIntervalHours] = useState(24);
const [backupMsg, setBackupMsg] = useState<string | null>(null); const [backupMsg, setBackupMsg] = useState<string | null>(null);
const [backupErr, setBackupErr] = useState<string | null>(null); const [backupErr, setBackupErr] = useState<string | null>(null);
const [backupBusy, setBackupBusy] = useState(false); 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 loadBackupInfo = useCallback(async () => {
const info = await fetchBackupInfo(); const info = await fetchBackupInfo();
setBackupInfo(info); applyBackupInfo(info);
setSelectedBackup((prev) => prev || info.backups[0]?.name || ""); }, [applyBackupInfo]);
}, []);
useEffect(() => { useEffect(() => {
Promise.all([fetchAccountSettings(), fetchBackupInfo()]) Promise.all([fetchAccountSettings(), fetchBackupInfo()])
@@ -57,8 +73,7 @@ export default function SettingsPage() {
setUsername(account.username); setUsername(account.username);
setNewUsername(account.username); setNewUsername(account.username);
setAuthRequired(account.auth_required); setAuthRequired(account.auth_required);
setBackupInfo(backup); applyBackupInfo(backup);
setSelectedBackup(backup.backups[0]?.name || "");
setNeedLogin(false); setNeedLogin(false);
setReady(true); setReady(true);
}) })
@@ -68,7 +83,7 @@ export default function SettingsPage() {
else setErr(m); else setErr(m);
setReady(true); setReady(true);
}); });
}, []); }, [applyBackupInfo]);
const submit = async (e: FormEvent) => { const submit = async (e: FormEvent) => {
e.preventDefault(); 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 () => { const runBackup = async () => {
setBackupBusy(true); setBackupBusy(true);
setBackupErr(null); setBackupErr(null);
@@ -154,137 +189,196 @@ export default function SettingsPage() {
return ( return (
<div className="layout"> <div className="layout">
<div className="brand"></div> <div className="brand"></div>
<div className="sub"> · </div> <div className="sub"></div>
<AppNav /> <AppNav />
<div className="center-page"> <div className="center-page">
<div className="settings-stack"> <div className="tile settings-card">
<form className="tile settings-card" onSubmit={submit}> <div className="settings-tabs">
<div className="settings-title"></div> <button
{!authRequired && ( type="button"
<p className="settings-hint">AUTH_SECRET=disabled .env </p> className={tab === "account" ? "active" : ""}
)} onClick={() => setTab("account")}
>
<label className="field">
<span className="label"></span> </button>
<input <button
className="field-input" type="button"
value={newUsername} className={tab === "backup" ? "active" : ""}
onChange={(e) => setNewUsername(e.target.value)} onClick={() => setTab("backup")}
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> </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> </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> </div>
</div> </div>
+40 -3
View File
@@ -31,11 +31,48 @@ 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: 420px; } .settings-card { width: 100%; max-width: 460px; }
.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: 0.25rem 0 0.85rem; }
.settings-title { font-size: 1.15rem; font-weight: 650; margin-bottom: 0.25rem; }
.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; }
.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-row { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 0.25rem; }
.btn-danger { .btn-danger {
padding: 0.55rem 1.2rem; padding: 0.55rem 1.2rem;