feat: add database backup and restore in system settings
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -8,6 +8,7 @@ AUTH_SECRET=change-me
|
|||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
ADMIN_PASSWORD=admin123
|
ADMIN_PASSWORD=admin123
|
||||||
ENV_FILE=/app/.env
|
ENV_FILE=/app/.env
|
||||||
|
BACKUP_DIR=/root/market_intel_backups
|
||||||
|
|
||||||
# ---- 采集(OKX 只读;公开行情可留空 Key)----
|
# ---- 采集(OKX 只读;公开行情可留空 Key)----
|
||||||
OKX_API_KEY=
|
OKX_API_KEY=
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""系统设置:管理员账号。"""
|
"""系统设置:管理员账号、数据备份与恢复。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ 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
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/settings",
|
prefix="/settings",
|
||||||
@@ -26,6 +27,87 @@ class UpdateAccountBody(BaseModel):
|
|||||||
new_password: str | None = Field(default=None, max_length=256)
|
new_password: str | None = Field(default=None, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
|
class BackupActionBody(BaseModel):
|
||||||
|
current_password: str = Field(min_length=1, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
|
class RestoreBody(BaseModel):
|
||||||
|
current_password: str = Field(min_length=1, max_length=256)
|
||||||
|
backup_name: str = Field(min_length=1, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_password_or_401(current_password: str) -> None:
|
||||||
|
s = get_settings()
|
||||||
|
if auth_disabled():
|
||||||
|
raise HTTPException(status_code=400, detail="auth disabled")
|
||||||
|
if not verify_credentials(s.admin_username, current_password):
|
||||||
|
raise HTTPException(status_code=401, detail="current password incorrect")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/backup")
|
||||||
|
def get_backup_info() -> dict:
|
||||||
|
s = get_settings()
|
||||||
|
backup_dir = s.backup_dir_path
|
||||||
|
writable = False
|
||||||
|
try:
|
||||||
|
ensure_backup_dir(backup_dir)
|
||||||
|
probe = backup_dir / ".write_probe"
|
||||||
|
probe.write_text("", encoding="utf-8")
|
||||||
|
probe.unlink(missing_ok=True)
|
||||||
|
writable = True
|
||||||
|
items = list_backups(backup_dir)
|
||||||
|
except OSError as e:
|
||||||
|
items = list_backups(backup_dir) if backup_dir.is_dir() else []
|
||||||
|
if not items:
|
||||||
|
raise HTTPException(status_code=500, detail=f"backup dir error: {e}") from e
|
||||||
|
return {
|
||||||
|
"backup_dir": str(backup_dir),
|
||||||
|
"db_path": str(s.db_path),
|
||||||
|
"writable": writable,
|
||||||
|
"backups": items,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/backup")
|
||||||
|
def create_backup(body: BackupActionBody) -> dict:
|
||||||
|
_verify_password_or_401(body.current_password)
|
||||||
|
s = get_settings()
|
||||||
|
try:
|
||||||
|
dest = backup_db(s.db_path, s.backup_dir_path, tz=s.tz)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||||
|
except OSError as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"backup failed: {e}") from e
|
||||||
|
st = dest.stat()
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"message": "备份完成",
|
||||||
|
"name": dest.name,
|
||||||
|
"path": str(dest),
|
||||||
|
"size_bytes": st.st_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/restore")
|
||||||
|
def restore_backup(body: RestoreBody) -> dict:
|
||||||
|
_verify_password_or_401(body.current_password)
|
||||||
|
s = get_settings()
|
||||||
|
try:
|
||||||
|
backup_path = resolve_backup_file(s.backup_dir_path, body.backup_name.strip())
|
||||||
|
restore_db(backup_path, s.db_path)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||||
|
except OSError as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"restore failed: {e}") from e
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"message": f"已从 {body.backup_name} 恢复数据库",
|
||||||
|
"restored_from": body.backup_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/account")
|
@router.get("/account")
|
||||||
def get_account() -> dict:
|
def get_account() -> dict:
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
|
|||||||
@@ -281,6 +281,7 @@ compose() {
|
|||||||
|
|
||||||
compose_up_build() {
|
compose_up_build() {
|
||||||
step "docker compose up -d --build"
|
step "docker compose up -d --build"
|
||||||
|
mkdir -p /root/market_intel_backups 2>/dev/null || true
|
||||||
compose up -d --build
|
compose up -d --build
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,11 +24,13 @@ services:
|
|||||||
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
|
ENV_FILE: /app/.env
|
||||||
|
BACKUP_DIR: /root/market_intel_backups
|
||||||
ports:
|
ports:
|
||||||
- "${MI_PORT:-5170}:5170"
|
- "${MI_PORT:-5170}:5170"
|
||||||
volumes:
|
volumes:
|
||||||
- mi_data:/app/data
|
- mi_data:/app/data
|
||||||
- ./.env:/app/.env
|
- ./.env:/app/.env
|
||||||
|
- /root/market_intel_backups:/root/market_intel_backups
|
||||||
depends_on:
|
depends_on:
|
||||||
- collector
|
- collector
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class Settings(BaseSettings):
|
|||||||
admin_username: str = Field(default="admin", alias="ADMIN_USERNAME")
|
admin_username: str = Field(default="admin", alias="ADMIN_USERNAME")
|
||||||
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")
|
||||||
|
|
||||||
# OKX
|
# OKX
|
||||||
okx_api_key: str = Field(default="", alias="OKX_API_KEY")
|
okx_api_key: str = Field(default="", alias="OKX_API_KEY")
|
||||||
@@ -62,6 +63,10 @@ class Settings(BaseSettings):
|
|||||||
def env_file_path(self) -> Path:
|
def env_file_path(self) -> Path:
|
||||||
return Path(self.env_file)
|
return Path(self.env_file)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def backup_dir_path(self) -> Path:
|
||||||
|
return Path(self.backup_dir)
|
||||||
|
|
||||||
|
|
||||||
def reload_settings() -> Settings:
|
def reload_settings() -> Settings:
|
||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""SQLite 在线备份与恢复。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
_BACKUP_NAME_RE = re.compile(r"^market_intel_\d{8}_\d{6}\.db$")
|
||||||
|
|
||||||
|
|
||||||
|
def _now_stamp(tz: str = "Asia/Shanghai") -> str:
|
||||||
|
return datetime.now(ZoneInfo(tz)).strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_backup_dir(path: Path) -> Path:
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_backup_name(name: str) -> bool:
|
||||||
|
return bool(_BACKUP_NAME_RE.fullmatch(name))
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_backup_file(backup_dir: Path, name: str) -> Path:
|
||||||
|
if not is_valid_backup_name(name):
|
||||||
|
raise ValueError("invalid backup filename")
|
||||||
|
path = (backup_dir / name).resolve()
|
||||||
|
root = backup_dir.resolve()
|
||||||
|
if not str(path).startswith(str(root)):
|
||||||
|
raise ValueError("invalid backup path")
|
||||||
|
if not path.is_file():
|
||||||
|
raise FileNotFoundError(name)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def backup_db(db_path: Path, backup_dir: Path, tz: str = "Asia/Shanghai") -> Path:
|
||||||
|
ensure_backup_dir(backup_dir)
|
||||||
|
if not db_path.is_file():
|
||||||
|
raise FileNotFoundError(f"database not found: {db_path}")
|
||||||
|
|
||||||
|
dest = backup_dir / f"market_intel_{_now_stamp(tz)}.db"
|
||||||
|
src = sqlite3.connect(str(db_path))
|
||||||
|
try:
|
||||||
|
dst = sqlite3.connect(str(dest))
|
||||||
|
try:
|
||||||
|
src.backup(dst)
|
||||||
|
finally:
|
||||||
|
dst.close()
|
||||||
|
finally:
|
||||||
|
src.close()
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def restore_db(backup_path: Path, db_path: Path) -> None:
|
||||||
|
if not backup_path.is_file():
|
||||||
|
raise FileNotFoundError(f"backup not found: {backup_path}")
|
||||||
|
|
||||||
|
src = sqlite3.connect(str(backup_path))
|
||||||
|
try:
|
||||||
|
dst = sqlite3.connect(str(db_path))
|
||||||
|
try:
|
||||||
|
src.backup(dst)
|
||||||
|
finally:
|
||||||
|
dst.close()
|
||||||
|
finally:
|
||||||
|
src.close()
|
||||||
|
|
||||||
|
|
||||||
|
def list_backups(backup_dir: Path) -> list[dict]:
|
||||||
|
if not backup_dir.is_dir():
|
||||||
|
return []
|
||||||
|
items: list[dict] = []
|
||||||
|
for p in sorted(backup_dir.glob("market_intel_*.db"), reverse=True):
|
||||||
|
if not p.is_file() or not is_valid_backup_name(p.name):
|
||||||
|
continue
|
||||||
|
st = p.stat()
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"name": p.name,
|
||||||
|
"size_bytes": st.st_size,
|
||||||
|
"modified_ms": int(st.st_mtime * 1000),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from packages.db.backup import (
|
||||||
|
backup_db,
|
||||||
|
is_valid_backup_name,
|
||||||
|
list_backups,
|
||||||
|
resolve_backup_file,
|
||||||
|
restore_db,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
dest = backup_db(db, backup_dir, tz="UTC")
|
||||||
|
assert dest.is_file()
|
||||||
|
assert is_valid_backup_name(dest.name)
|
||||||
|
|
||||||
|
conn = __import__("sqlite3").connect(str(db))
|
||||||
|
conn.execute("DELETE FROM t")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
restore_db(dest, db)
|
||||||
|
conn = __import__("sqlite3").connect(str(db))
|
||||||
|
row = conn.execute("SELECT v FROM t").fetchone()
|
||||||
|
conn.close()
|
||||||
|
assert row[0] == "hello"
|
||||||
|
|
||||||
|
items = list_backups(backup_dir)
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0]["name"] == dest.name
|
||||||
|
assert resolve_backup_file(backup_dir, dest.name) == dest.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_backup_name(tmp_path: Path):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
resolve_backup_file(tmp_path, "../etc/passwd")
|
||||||
Vendored
+116
-3
@@ -42,11 +42,16 @@
|
|||||||
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: 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-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; }
|
||||||
.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; }
|
||||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.btn-row { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 0.25rem; }
|
||||||
|
.btn-danger { padding: 0.55rem 1.2rem; border-radius: 6px; border: 0; background: #c45656; color: #fff; cursor: pointer; }
|
||||||
|
.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
.form-err { color: var(--bad); font-size: 0.85rem; }
|
.form-err { color: var(--bad); font-size: 0.85rem; }
|
||||||
.form-ok { color: var(--ok); font-size: 0.85rem; }
|
.form-ok { color: var(--ok); font-size: 0.85rem; }
|
||||||
</style>
|
</style>
|
||||||
@@ -126,9 +131,10 @@
|
|||||||
</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">
|
<form id="settingsForm" class="tile settings-card">
|
||||||
<div class="settings-title">系统设置</div>
|
<div class="settings-title">账号设置</div>
|
||||||
<p id="settingsAuthHint" class="hint" style="margin-bottom:1rem"></p>
|
<p id="settingsAuthHint" class="settings-hint" style="margin-bottom:1rem"></p>
|
||||||
<label class="field"><span class="label">用户名</span>
|
<label class="field"><span class="label">用户名</span>
|
||||||
<input id="setUsername" class="field-input" autocomplete="username" />
|
<input id="setUsername" class="field-input" autocomplete="username" />
|
||||||
</label>
|
</label>
|
||||||
@@ -143,8 +149,26 @@
|
|||||||
</label>
|
</label>
|
||||||
<p id="settingsErr" class="form-err"></p>
|
<p id="settingsErr" class="form-err"></p>
|
||||||
<p id="settingsOk" class="form-ok"></p>
|
<p id="settingsOk" class="form-ok"></p>
|
||||||
<button type="submit" class="btn-primary" id="settingsSaveBtn">保存</button>
|
<button type="submit" class="btn-primary" id="settingsSaveBtn">保存账号</button>
|
||||||
</form>
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -233,6 +257,50 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function fmtBytes(n) {
|
||||||
|
if (n < 1024) return n + " B";
|
||||||
|
if (n < 1048576) return (n / 1024).toFixed(1) + " KB";
|
||||||
|
return (n / 1048576).toFixed(2) + " MB";
|
||||||
|
}
|
||||||
|
function fmtBackupTime(ms) {
|
||||||
|
try { return new Date(ms).toLocaleString("zh-CN", { hour12: false }); } catch { return "—"; }
|
||||||
|
}
|
||||||
|
function renderBackupSelect(backups) {
|
||||||
|
const sel = document.getElementById("backupSelect");
|
||||||
|
sel.innerHTML = "";
|
||||||
|
if (!backups || !backups.length) {
|
||||||
|
sel.innerHTML = '<option value="">尚无备份</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
backups.forEach(b => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = b.name;
|
||||||
|
opt.textContent = b.name + " · " + fmtBytes(b.size_bytes) + " · " + fmtBackupTime(b.modified_ms);
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function loadBackupInfo() {
|
||||||
|
document.getElementById("backupErr").textContent = "";
|
||||||
|
document.getElementById("backupOk").textContent = "";
|
||||||
|
const r = await apiFetch("/api/settings/backup");
|
||||||
|
if (!r.ok) throw new Error("backup info " + r.status);
|
||||||
|
const info = await r.json();
|
||||||
|
state.backupWritable = !!info.writable;
|
||||||
|
document.getElementById("backupDir").textContent = info.backup_dir || "/root/market_intel_backups";
|
||||||
|
const dirErr = document.getElementById("backupDirErr");
|
||||||
|
if (!info.writable) {
|
||||||
|
dirErr.textContent = "备份目录不可写,请检查 /root/market_intel_backups 权限与 Docker 挂载";
|
||||||
|
dirErr.classList.remove("hidden");
|
||||||
|
} else {
|
||||||
|
dirErr.classList.add("hidden");
|
||||||
|
}
|
||||||
|
renderBackupSelect(info.backups || []);
|
||||||
|
const disabled = !state.authRequired || !info.writable;
|
||||||
|
["backupPass","backupSelect","backupBtn","restoreBtn"].forEach(id => {
|
||||||
|
document.getElementById(id).disabled = disabled;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
document.getElementById("settingsErr").textContent = "";
|
document.getElementById("settingsErr").textContent = "";
|
||||||
document.getElementById("settingsOk").textContent = "";
|
document.getElementById("settingsOk").textContent = "";
|
||||||
@@ -249,6 +317,7 @@
|
|||||||
["setUsername","setCurPass","setNewPass","setNewPass2","settingsSaveBtn"].forEach(id => {
|
["setUsername","setCurPass","setNewPass","setNewPass2","settingsSaveBtn"].forEach(id => {
|
||||||
document.getElementById(id).disabled = disabled;
|
document.getElementById(id).disabled = disabled;
|
||||||
});
|
});
|
||||||
|
await loadBackupInfo();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.getElementById("settingsErr").textContent = String(e);
|
document.getElementById("settingsErr").textContent = String(e);
|
||||||
}
|
}
|
||||||
@@ -292,6 +361,50 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById("backupBtn").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", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ current_password: pwd }),
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (!r.ok) throw new Error(d.detail || "backup failed");
|
||||||
|
document.getElementById("backupOk").textContent = (d.message || "备份完成") + ":" + d.name;
|
||||||
|
document.getElementById("backupPass").value = "";
|
||||||
|
await loadBackupInfo();
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById("backupErr").textContent = String(err.message || err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("restoreBtn").addEventListener("click", async () => {
|
||||||
|
const pwd = document.getElementById("backupPass").value;
|
||||||
|
const name = document.getElementById("backupSelect").value;
|
||||||
|
document.getElementById("backupErr").textContent = "";
|
||||||
|
document.getElementById("backupOk").textContent = "";
|
||||||
|
if (!pwd) { document.getElementById("backupErr").textContent = "请输入当前密码"; return; }
|
||||||
|
if (!name) { document.getElementById("backupErr").textContent = "请选择要恢复的备份"; return; }
|
||||||
|
if (!confirm("确认从 " + name + " 恢复数据库?当前数据将被覆盖。")) return;
|
||||||
|
try {
|
||||||
|
const r = await apiFetch("/api/settings/restore", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ current_password: pwd, backup_name: name }),
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (!r.ok) throw new Error(d.detail || "restore failed");
|
||||||
|
document.getElementById("backupOk").textContent = d.message || "恢复完成";
|
||||||
|
document.getElementById("backupPass").value = "";
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById("backupErr").textContent = String(err.message || err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.querySelectorAll("#rangeSeg button").forEach(b => b.addEventListener("click", () => {
|
document.querySelectorAll("#rangeSeg button").forEach(b => b.addEventListener("click", () => {
|
||||||
state.range = b.dataset.range;
|
state.range = b.dataset.range;
|
||||||
document.querySelectorAll("#rangeSeg button").forEach(x => x.classList.toggle("active", x === b));
|
document.querySelectorAll("#rangeSeg button").forEach(x => x.classList.toggle("active", x === b));
|
||||||
|
|||||||
@@ -86,6 +86,47 @@ export async function updateAccountSettings(body: {
|
|||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BackupItem = {
|
||||||
|
name: string;
|
||||||
|
size_bytes: number;
|
||||||
|
modified_ms: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BackupInfo = {
|
||||||
|
backup_dir: string;
|
||||||
|
db_path: string;
|
||||||
|
writable: boolean;
|
||||||
|
backups: BackupItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchBackupInfo(): Promise<BackupInfo> {
|
||||||
|
const r = await apiFetch("/api/settings/backup");
|
||||||
|
if (!r.ok) throw new Error(r.status === 401 ? "unauthorized" : "backup info failed");
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ current_password }),
|
||||||
|
});
|
||||||
|
const d = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok) throw new Error(d.detail || "backup failed");
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreBackup(current_password: string, backup_name: string): Promise<{ ok: boolean; message: string }> {
|
||||||
|
const r = await apiFetch("/api/settings/restore", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ current_password, backup_name }),
|
||||||
|
});
|
||||||
|
const d = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok) throw new Error(d.detail || "restore failed");
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
export async function logout(): Promise<void> {
|
export async function logout(): Promise<void> {
|
||||||
setToken(null);
|
setToken(null);
|
||||||
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
|
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
|
||||||
|
|||||||
+198
-61
@@ -1,12 +1,30 @@
|
|||||||
import { FormEvent, useEffect, useState } from "react";
|
import { FormEvent, useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
BackupInfo,
|
||||||
|
createBackup,
|
||||||
fetchAccountSettings,
|
fetchAccountSettings,
|
||||||
|
fetchBackupInfo,
|
||||||
logout,
|
logout,
|
||||||
|
restoreBackup,
|
||||||
updateAccountSettings,
|
updateAccountSettings,
|
||||||
} 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";
|
||||||
|
|
||||||
|
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 {
|
||||||
|
try {
|
||||||
|
return new Date(ms).toLocaleString("zh-CN", { hour12: false });
|
||||||
|
} catch {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [needLogin, setNeedLogin] = useState(false);
|
const [needLogin, setNeedLogin] = useState(false);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
@@ -20,12 +38,27 @@ export default function SettingsPage() {
|
|||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const [backupInfo, setBackupInfo] = useState<BackupInfo | null>(null);
|
||||||
|
const [backupPassword, setBackupPassword] = useState("");
|
||||||
|
const [selectedBackup, setSelectedBackup] = useState("");
|
||||||
|
const [backupMsg, setBackupMsg] = useState<string | null>(null);
|
||||||
|
const [backupErr, setBackupErr] = useState<string | null>(null);
|
||||||
|
const [backupBusy, setBackupBusy] = useState(false);
|
||||||
|
|
||||||
|
const loadBackupInfo = useCallback(async () => {
|
||||||
|
const info = await fetchBackupInfo();
|
||||||
|
setBackupInfo(info);
|
||||||
|
setSelectedBackup((prev) => prev || info.backups[0]?.name || "");
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAccountSettings()
|
Promise.all([fetchAccountSettings(), fetchBackupInfo()])
|
||||||
.then((d) => {
|
.then(([account, backup]) => {
|
||||||
setUsername(d.username);
|
setUsername(account.username);
|
||||||
setNewUsername(d.username);
|
setNewUsername(account.username);
|
||||||
setAuthRequired(d.auth_required);
|
setAuthRequired(account.auth_required);
|
||||||
|
setBackupInfo(backup);
|
||||||
|
setSelectedBackup(backup.backups[0]?.name || "");
|
||||||
setNeedLogin(false);
|
setNeedLogin(false);
|
||||||
setReady(true);
|
setReady(true);
|
||||||
})
|
})
|
||||||
@@ -71,6 +104,42 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const runBackup = async () => {
|
||||||
|
setBackupBusy(true);
|
||||||
|
setBackupErr(null);
|
||||||
|
setBackupMsg(null);
|
||||||
|
try {
|
||||||
|
const res = await createBackup(backupPassword);
|
||||||
|
setBackupMsg(`${res.message}:${res.name}`);
|
||||||
|
setBackupPassword("");
|
||||||
|
await loadBackupInfo();
|
||||||
|
} catch (ex) {
|
||||||
|
setBackupErr(String(ex));
|
||||||
|
} finally {
|
||||||
|
setBackupBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runRestore = async () => {
|
||||||
|
if (!selectedBackup) {
|
||||||
|
setBackupErr("请选择要恢复的备份");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!window.confirm(`确认从 ${selectedBackup} 恢复数据库?当前数据将被覆盖。`)) return;
|
||||||
|
setBackupBusy(true);
|
||||||
|
setBackupErr(null);
|
||||||
|
setBackupMsg(null);
|
||||||
|
try {
|
||||||
|
const res = await restoreBackup(backupPassword, selectedBackup);
|
||||||
|
setBackupMsg(res.message);
|
||||||
|
setBackupPassword("");
|
||||||
|
} catch (ex) {
|
||||||
|
setBackupErr(String(ex));
|
||||||
|
} finally {
|
||||||
|
setBackupBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
return (
|
return (
|
||||||
<div className="layout">
|
<div className="layout">
|
||||||
@@ -85,70 +154,138 @@ 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">
|
||||||
<form className="tile settings-card" onSubmit={submit}>
|
<div className="settings-stack">
|
||||||
<div className="settings-title">系统设置</div>
|
<form className="tile settings-card" onSubmit={submit}>
|
||||||
{!authRequired && (
|
<div className="settings-title">账号设置</div>
|
||||||
<p className="settings-hint">当前鉴权已关闭(AUTH_SECRET=disabled),请在 .env 中修改。</p>
|
{!authRequired && (
|
||||||
)}
|
<p className="settings-hint">当前鉴权已关闭(AUTH_SECRET=disabled),请在 .env 中修改。</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span className="label">用户名</span>
|
<span className="label">用户名</span>
|
||||||
<input
|
<input
|
||||||
className="field-input"
|
className="field-input"
|
||||||
value={newUsername}
|
value={newUsername}
|
||||||
onChange={(e) => setNewUsername(e.target.value)}
|
onChange={(e) => setNewUsername(e.target.value)}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
disabled={!authRequired}
|
disabled={!authRequired}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span className="label">当前密码</span>
|
<span className="label">当前密码</span>
|
||||||
<input
|
<input
|
||||||
className="field-input"
|
className="field-input"
|
||||||
type="password"
|
type="password"
|
||||||
value={currentPassword}
|
value={currentPassword}
|
||||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
disabled={!authRequired}
|
disabled={!authRequired}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span className="label">新密码(留空则不修改)</span>
|
<span className="label">新密码(留空则不修改)</span>
|
||||||
<input
|
<input
|
||||||
className="field-input"
|
className="field-input"
|
||||||
type="password"
|
type="password"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
disabled={!authRequired}
|
disabled={!authRequired}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span className="label">确认新密码</span>
|
<span className="label">确认新密码</span>
|
||||||
<input
|
<input
|
||||||
className="field-input"
|
className="field-input"
|
||||||
type="password"
|
type="password"
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
disabled={!authRequired}
|
disabled={!authRequired}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{err && <p className="form-err">{err}</p>}
|
{err && <p className="form-err">{err}</p>}
|
||||||
{msg && <p className="form-ok">{msg}</p>}
|
{msg && <p className="form-ok">{msg}</p>}
|
||||||
|
|
||||||
<button className="btn-primary" type="submit" disabled={busy || !authRequired || !currentPassword}>
|
<button className="btn-primary" type="submit" disabled={busy || !authRequired || !currentPassword}>
|
||||||
{busy ? "保存中…" : "保存"}
|
{busy ? "保存中…" : "保存账号"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,8 +32,22 @@ a { color: var(--accent); text-decoration: none; }
|
|||||||
padding: 1.5rem 0 3rem;
|
padding: 1.5rem 0 3rem;
|
||||||
}
|
}
|
||||||
.settings-card { width: 100%; max-width: 420px; }
|
.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-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; }
|
||||||
|
.btn-row { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 0.25rem; }
|
||||||
|
.btn-danger {
|
||||||
|
padding: 0.55rem 1.2rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 0;
|
||||||
|
background: #c45656;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
select.field-input { cursor: pointer; }
|
||||||
.field { display: block; margin-bottom: 1rem; }
|
.field { display: block; margin-bottom: 1rem; }
|
||||||
.field-input {
|
.field-input {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
Reference in New Issue
Block a user