feat: 系统设置增加备份恢复与默认登录 admin

支持手动/每日自动备份四所数据库、K线库与 env,上传 zip 一键恢复;中控默认账号 admin/admin123。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-02 16:39:46 +08:00
parent 55261b7812
commit bfa3352122
16 changed files with 1052 additions and 22 deletions
+6 -5
View File
@@ -26,9 +26,9 @@ HUB_TRUST_LAN=true
# 云服务器用域名/HTTPS 反代访问中控时设为 true(否则公网可能看到 {"detail":"forbidden"}
# HUB_ALLOW_PUBLIC=true
# 中控 Web 登录(密码非空即启用;反代到公网时务必设置用户名+密码
# HUB_USERNAME=admin
# HUB_PASSWORD=your-strong-password-here
# 中控 Web 登录(默认 admin / admin123;生产环境请在 .env 中修改
HUB_USERNAME=admin
HUB_PASSWORD=admin123
# 会话签名密钥(建议单独随机串;未设则用用户名+密码拼接)
# HUB_SESSION_SECRET=another-long-random-string
# HTTPS 反代时建议 true:仅 HTTPS 访问会带 Secure Cookiehttp://内网IP:5100 仍可登录
@@ -106,6 +106,7 @@ AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest
TRADING_DAY_RESET_HOUR=8
# 资金概况 / AI 上下文:分户资金快照保留交易日数(默认 180)
# HUB_FUND_HISTORY_DAYS=180
# 资金概况:曲线与回撤统计起始交易日(页面说明与曲线均读取此项;该日之前不记、不展示
# 修改后须重启 manual-trading-hubpm2 restart manual-trading-hub
# 自动备份(系统设置 → 备份与恢复;也可设 HUB_BACKUP_ROOT
# HUB_BACKUP_ROOT=/root/backups/crypto_monitor_portal
# 资金概况:曲线与回撤统计起始交易日
HUB_FUND_HISTORY_START_DAY=2026-06-09
+125 -2
View File
@@ -86,7 +86,7 @@ from env_load import load_hub_dotenv
load_hub_dotenv()
import httpx
from fastapi import Body, FastAPI, HTTPException, Request
from fastapi import Body, FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
@@ -99,6 +99,15 @@ from settings_store import (
normalize_supervisor_settings,
save_settings,
)
from lib.hub.hub_backup_lib import (
backup_status,
normalize_backup_settings,
resolve_backup_download,
restore_backup_archive,
restore_backup_upload,
run_backup,
should_run_auto_backup,
)
from hub_web_auth import (
SESSION_COOKIE,
SESSION_MAX_AGE_SEC,
@@ -157,6 +166,8 @@ _last_archive_sync: dict | None = None
_volume_rank_stop: asyncio.Event | None = None
_volume_rank_task: asyncio.Task | None = None
_volume_rank_cache: dict | None = None
_backup_stop: asyncio.Event | None = None
_backup_task: asyncio.Task | None = None
HUB_AGENT_TIMEOUT = float(os.getenv("HUB_AGENT_TIMEOUT", "8"))
HUB_FLASK_TIMEOUT = float(os.getenv("HUB_FLASK_TIMEOUT", "10"))
HUB_BOARD_TIMEOUT = float(os.getenv("HUB_BOARD_TIMEOUT", "45"))
@@ -518,9 +529,28 @@ async def _run_supervisor_tick() -> dict:
)
async def _backup_scheduler_loop() -> None:
global _backup_stop
stop = _backup_stop
if stop is None:
return
while not stop.is_set():
try:
settings = load_settings()
if should_run_auto_backup(settings):
await asyncio.to_thread(run_backup, trigger="auto", settings=settings)
except Exception as e:
print(f"[backup] auto backup failed: {e}", flush=True)
try:
await asyncio.wait_for(stop.wait(), timeout=60.0)
except asyncio.TimeoutError:
pass
@asynccontextmanager
async def _hub_lifespan(_app: FastAPI):
global _archive_sync_stop, _archive_sync_task, _volume_rank_stop, _volume_rank_task
global _backup_stop, _backup_task
set_supervisor_notify_hook(supervisor_store.bump)
await board_store.start(_run_board_aggregate)
await dashboard_store.start(_run_dashboard_aggregate)
@@ -530,9 +560,21 @@ async def _hub_lifespan(_app: FastAPI):
_archive_sync_task = asyncio.create_task(_archive_sync_loop(), name="hub-archive-sync")
_volume_rank_stop = asyncio.Event()
_volume_rank_task = asyncio.create_task(_volume_rank_loop(), name="hub-volume-rank")
_backup_stop = asyncio.Event()
_backup_task = asyncio.create_task(_backup_scheduler_loop(), name="hub-backup-scheduler")
try:
yield
finally:
if _backup_stop:
_backup_stop.set()
if _backup_task:
_backup_task.cancel()
try:
await _backup_task
except asyncio.CancelledError:
pass
_backup_task = None
_backup_stop = None
if _archive_sync_stop:
_archive_sync_stop.set()
if _archive_sync_task:
@@ -879,10 +921,20 @@ class SupervisorSettingsBody(BaseModel):
reopen_after_close_minutes: int = 30
class BackupSettingsBody(BaseModel):
auto_enabled: bool = True
auto_hour: int = Field(default=0, ge=0, le=23)
retention_days: int = Field(default=30, ge=1, le=365)
include_env: bool = True
include_exchange_images: bool = False
backup_root: str = ""
class SettingsBody(BaseModel):
exchanges: list[dict] = Field(default_factory=list)
display: SettingsDisplayBody | None = None
supervisor: SupervisorSettingsBody | None = None
backup: BackupSettingsBody | None = None
@app.post("/api/settings")
@@ -903,7 +955,18 @@ def api_save_settings(body: SettingsBody):
supervisor = normalize_supervisor_settings(existing.get("supervisor"))
if body.supervisor is not None:
supervisor = normalize_supervisor_settings(body.supervisor.model_dump())
save_settings({"version": 1, "exchanges": to_save, "display": display, "supervisor": supervisor})
backup = normalize_backup_settings(existing.get("backup"))
if body.backup is not None:
backup = normalize_backup_settings(body.backup.model_dump())
save_settings(
{
"version": 1,
"exchanges": to_save,
"display": display,
"supervisor": supervisor,
"backup": backup,
}
)
return {"ok": True, "settings": load_settings()}
@@ -1290,6 +1353,65 @@ async def api_chart_poll_meta():
return chart_poll_store.event_dict()
@app.get("/api/backup/status")
def api_backup_status():
return backup_status(load_settings())
@app.post("/api/backup/run")
async def api_backup_run():
result = await asyncio.to_thread(run_backup, trigger="manual", settings=load_settings())
if not result.get("ok"):
raise HTTPException(status_code=500, detail=result.get("error") or "backup failed")
return result
@app.get("/api/backup/download/{name}")
def api_backup_download(name: str):
fp = resolve_backup_download(load_settings(), name)
if not fp:
raise HTTPException(status_code=404, detail="backup not found")
return FileResponse(
str(fp),
media_type="application/zip",
filename=fp.name,
)
@app.post("/api/backup/restore")
async def api_backup_restore(
file: UploadFile = File(...),
confirm: str = Form(""),
):
if (confirm or "").strip().upper() != "RESTORE":
raise HTTPException(status_code=400, detail='请在 confirm 字段填写 RESTORE 以确认恢复')
content = await file.read()
result = await asyncio.to_thread(
restore_backup_upload,
content,
file.filename or "backup.zip",
settings=load_settings(),
)
if not result.get("ok"):
raise HTTPException(status_code=500, detail=result.get("error") or "restore failed")
return result
@app.post("/api/backup/restore-local")
async def api_backup_restore_local(body: dict = Body(...)):
confirm = str(body.get("confirm") or "").strip().upper()
name = str(body.get("name") or "").strip()
if confirm != "RESTORE":
raise HTTPException(status_code=400, detail='请在 confirm 字段填写 RESTORE 以确认恢复')
fp = resolve_backup_download(load_settings(), name)
if not fp:
raise HTTPException(status_code=404, detail="backup not found")
result = await asyncio.to_thread(restore_backup_archive, fp, settings=load_settings())
if not result.get("ok"):
raise HTTPException(status_code=500, detail=result.get("error") or "restore failed")
return result
@app.get("/api/settings/meta")
def api_settings_meta():
po = public_origin()
@@ -1304,6 +1426,7 @@ def api_settings_meta():
else "复盘/展示链接已替换为对外地址"
),
"password_required": password_required(),
"default_username": expected_username(),
}
+5 -5
View File
@@ -13,6 +13,7 @@ from secrets import compare_digest
SESSION_COOKIE = "hub_sess"
SESSION_MAX_AGE_SEC = max(3600, int(os.getenv("HUB_SESSION_DAYS", "7")) * 86400)
DEFAULT_USERNAME = "admin"
DEFAULT_PASSWORD = "admin123"
def _env_username() -> str:
@@ -20,12 +21,13 @@ def _env_username() -> str:
def _env_password() -> str:
return (os.getenv("HUB_PASSWORD") or "").strip()
raw = (os.getenv("HUB_PASSWORD") or "").strip()
return raw or DEFAULT_PASSWORD
def password_required() -> bool:
"""已配置密码即要求登录(用户名未设时默认 admin)。"""
return bool(_env_password())
"""默认启用登录(admin / admin123,可通过 .env 覆盖)。"""
return True
def expected_username() -> str:
@@ -33,8 +35,6 @@ def expected_username() -> str:
def verify_credentials(username: str, password: str) -> bool:
if not _env_password():
return True
u_ok = compare_digest(expected_username(), (username or "").strip())
p_ok = compare_digest(_env_password(), (password or "").strip())
return u_ok and p_ok
+6
View File
@@ -8,13 +8,17 @@ from pathlib import Path
DIR = Path(__file__).resolve().parent
SETTINGS_PATH = DIR / "hub_settings.json"
_REPO_ROOT = DIR.parent
import sys
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
if str(DIR) not in sys.path:
sys.path.insert(0, str(DIR))
from hub_supervisor_lib import DEFAULT_SUPERVISOR, normalize_supervisor_settings
from lib.hub.hub_backup_lib import normalize_backup_settings
DEFAULT_DISPLAY = {
"show_account_pnl": True,
@@ -106,6 +110,7 @@ def load_settings() -> dict:
pass
data["display"] = normalize_display_prefs(data.get("display"))
data["supervisor"] = normalize_supervisor_settings(data.get("supervisor"))
data["backup"] = normalize_backup_settings(data.get("backup"))
force_off = env_force_disabled_ids()
for ex in data.get("exchanges") or []:
if str(ex.get("id")) in force_off:
@@ -120,6 +125,7 @@ def save_settings(data: dict) -> None:
payload = dict(data)
payload["display"] = normalize_display_prefs(payload.get("display"))
payload["supervisor"] = normalize_supervisor_settings(payload.get("supervisor"))
payload["backup"] = normalize_backup_settings(payload.get("backup"))
SETTINGS_PATH.write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
+82
View File
@@ -2904,6 +2904,88 @@ button.btn-sm {
line-height: 1.45;
}
.backup-settings-grid {
margin-top: 12px;
}
.backup-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-top: 16px;
}
.backup-status-line {
font-size: 0.82rem;
color: var(--muted);
}
.backup-status-line.err {
color: var(--danger, #f87171);
}
.backup-restore-upload {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.backup-upload-label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.82rem;
color: var(--muted);
}
.backup-list {
margin-top: 16px;
}
.backup-meta {
font-size: 0.78rem;
color: var(--muted);
line-height: 1.5;
margin-bottom: 10px;
}
.backup-meta code {
font-size: 0.76rem;
}
.backup-empty {
font-size: 0.82rem;
color: var(--muted);
}
.backup-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
}
.backup-table th,
.backup-table td {
padding: 8px 10px;
border-bottom: 1px solid var(--border);
text-align: left;
}
.backup-row-actions {
white-space: nowrap;
}
.backup-row-actions .ghost,
.backup-row-actions .danger {
font-size: 0.78rem;
padding: 4px 8px;
}
.settings-card {
background: var(--panel);
border: 1px solid var(--border);
+1
View File
@@ -3969,6 +3969,7 @@
syncSupervisorSettingsUI(data);
renderSettingsList(data);
initSettingsSectionFolds();
if (typeof initBackupSettingsUI === "function") void initBackupSettingsUI();
});
}
+250
View File
@@ -0,0 +1,250 @@
/**
* 系统设置 · 备份与恢复
*/
(function () {
const page = document.getElementById("page-settings");
if (!page) return;
const elAuto = document.getElementById("backup-auto-enabled");
const elHour = document.getElementById("backup-auto-hour");
const elRetention = document.getElementById("backup-retention-days");
const elIncludeEnv = document.getElementById("backup-include-env");
const elIncludeImages = document.getElementById("backup-include-images");
const elRoot = document.getElementById("backup-root");
const elStatus = document.getElementById("backup-status-line");
const elList = document.getElementById("backup-list");
const elRun = document.getElementById("backup-run-now");
const elRestoreFile = document.getElementById("backup-restore-file");
const elRestoreBtn = document.getElementById("backup-restore-upload-btn");
let settingsCache = null;
let statusCache = null;
function fmtBytes(n) {
const v = Number(n);
if (!Number.isFinite(v) || v < 0) return "—";
if (v < 1024) return v + " B";
if (v < 1024 * 1024) return (v / 1024).toFixed(1) + " KB";
return (v / (1024 * 1024)).toFixed(2) + " MB";
}
function setStatus(msg, isErr) {
if (!elStatus) return;
elStatus.textContent = msg || "";
elStatus.className = "backup-status-line" + (isErr ? " err" : "");
}
function collectBackupFromUI() {
return {
auto_enabled: !!(elAuto && elAuto.checked),
auto_hour: Math.max(0, Math.min(23, parseInt(elHour && elHour.value, 10) || 0)),
retention_days: Math.max(1, Math.min(365, parseInt(elRetention && elRetention.value, 10) || 30)),
include_env: !!(elIncludeEnv && elIncludeEnv.checked),
include_exchange_images: !!(elIncludeImages && elIncludeImages.checked),
backup_root: (elRoot && elRoot.value || "").trim(),
};
}
function syncBackupUI(data) {
const b = (data && data.backup) || {};
if (elAuto) elAuto.checked = b.auto_enabled !== false;
if (elHour) elHour.value = b.auto_hour != null ? b.auto_hour : 0;
if (elRetention) elRetention.value = b.retention_days != null ? b.retention_days : 30;
if (elIncludeEnv) elIncludeEnv.checked = b.include_env !== false;
if (elIncludeImages) elIncludeImages.checked = !!b.include_exchange_images;
if (elRoot) elRoot.value = b.backup_root || "";
}
function renderBackupList(status) {
if (!elList) return;
const rows = (status && status.backups) || [];
const state = (status && status.state) || {};
const root = (status && status.backup_root) || "";
let html = '<div class="backup-meta">';
html += '<div>目录:<code>' + esc(root) + '</code></div>';
if (state.last_backup_at) {
html += '<div>上次备份:' + esc(state.last_backup_at) + '' + esc(state.last_trigger || "") + "</div>";
}
if (state.last_auto_at) {
html += '<div>上次自动:' + esc(state.last_auto_at) + "</div>";
}
if (state.last_restore_at) {
html += '<div>上次恢复:' + esc(state.last_restore_at) + " ← " + esc(state.last_restore_from || "") + "</div>";
}
html += "</div>";
if (!rows.length) {
html += '<p class="backup-empty">暂无备份文件</p>';
elList.innerHTML = html;
return;
}
html += '<table class="backup-table"><thead><tr><th>文件</th><th>大小</th><th>时间</th><th></th></tr></thead><tbody>';
rows.forEach(function (row) {
html +=
"<tr><td>" +
esc(row.name) +
"</td><td>" +
fmtBytes(row.size) +
"</td><td>" +
esc(row.modified_at || "") +
'</td><td class="backup-row-actions">' +
'<a class="ghost" href="/api/backup/download/' +
encodeURIComponent(row.name) +
'" download>下载</a> ' +
'<button type="button" class="danger backup-restore-local" data-name="' +
escAttr(row.name) +
'">恢复</button></td></tr>';
});
html += "</tbody></table>";
elList.innerHTML = html;
elList.querySelectorAll(".backup-restore-local").forEach(function (btn) {
btn.addEventListener("click", function () {
restoreLocal(btn.getAttribute("data-name"));
});
});
}
function esc(s) {
return String(s || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/"/g, "&quot;");
}
function escAttr(s) {
return esc(s).replace(/'/g, "&#39;");
}
async function loadSettingsData() {
const r = await fetch("/api/settings", { credentials: "same-origin" });
if (!r.ok) throw new Error("加载设置失败");
settingsCache = await r.json();
syncBackupUI(settingsCache);
}
async function loadBackupStatus() {
const r = await fetch("/api/backup/status", { credentials: "same-origin" });
if (!r.ok) throw new Error("加载备份状态失败");
statusCache = await r.json();
renderBackupList(statusCache);
}
async function saveBackupSettings() {
if (!settingsCache) await loadSettingsData();
const body = {
exchanges: settingsCache.exchanges || [],
display: settingsCache.display,
supervisor: settingsCache.supervisor,
backup: collectBackupFromUI(),
};
const r = await fetch("/api/settings", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error("保存失败");
settingsCache = (await r.json()).settings || settingsCache;
syncBackupUI(settingsCache);
await loadBackupStatus();
if (typeof showToast === "function") showToast("备份设置已保存");
}
async function runBackupNow() {
setStatus("备份中…");
const r = await fetch("/api/backup/run", { method: "POST", credentials: "same-origin" });
const data = await r.json().catch(function () {
return {};
});
if (!r.ok) {
setStatus(data.detail || "备份失败", true);
return;
}
setStatus("完成:" + (data.file || "") + "" + fmtBytes(data.size) + "");
await loadBackupStatus();
if (typeof showToast === "function") showToast("备份完成");
}
async function restoreLocal(name) {
if (!name) return;
if (!window.confirm("确认从服务器备份 " + name + " 恢复?\n恢复前会自动做 pre-restore 快照并重启 PM2。")) return;
if (window.prompt('请输入 RESTORE 确认恢复') !== "RESTORE") return;
setStatus("恢复中…");
const r = await fetch("/api/backup/restore-local", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name, confirm: "RESTORE" }),
});
const data = await r.json().catch(function () {
return {};
});
if (!r.ok) {
setStatus(data.detail || "恢复失败", true);
return;
}
setStatus("恢复完成,已恢复 " + ((data.restored && data.restored.length) || 0) + " 个文件");
await loadBackupStatus();
if (typeof showToast === "function") showToast("恢复完成,请刷新页面");
}
async function restoreUpload() {
const file = elRestoreFile && elRestoreFile.files && elRestoreFile.files[0];
if (!file) {
setStatus("请选择 .zip 备份文件", true);
return;
}
if (!window.confirm("确认上传并恢复 " + file.name + "\n恢复前会自动做 pre-restore 快照并重启 PM2。")) return;
if (window.prompt('请输入 RESTORE 确认恢复') !== "RESTORE") return;
setStatus("上传并恢复中…");
const fd = new FormData();
fd.append("file", file);
fd.append("confirm", "RESTORE");
const r = await fetch("/api/backup/restore", {
method: "POST",
credentials: "same-origin",
body: fd,
});
const data = await r.json().catch(function () {
return {};
});
if (!r.ok) {
setStatus(data.detail || "恢复失败", true);
return;
}
setStatus("恢复完成,已恢复 " + ((data.restored && data.restored.length) || 0) + " 个文件");
if (elRestoreFile) elRestoreFile.value = "";
await loadBackupStatus();
if (typeof showToast === "function") showToast("恢复完成,请刷新页面");
}
window.initBackupSettingsUI = async function () {
try {
await loadSettingsData();
await loadBackupStatus();
setStatus("");
} catch (e) {
setStatus(e.message || String(e), true);
}
};
if (elRun) elRun.addEventListener("click", function () {
runBackupNow().catch(function (e) {
setStatus(e.message || String(e), true);
});
});
if (elRestoreBtn) elRestoreBtn.addEventListener("click", function () {
restoreUpload().catch(function (e) {
setStatus(e.message || String(e), true);
});
});
page.addEventListener("click", function (ev) {
const btn = ev.target.closest(".settings-section-save[data-settings-section='backup']");
if (!btn) return;
ev.preventDefault();
saveBackupSettings().catch(function (e) {
setStatus(e.message || String(e), true);
});
});
})();
+53 -1
View File
@@ -862,7 +862,7 @@
<div class="hint-body">
保存后写入 <code>hub_settings.json</code>。Flask / Agent 填本机地址即可;复盘链接可留空(由 Flask 地址自动生成)。<br />
<code>HUB_DISABLED_IDS</code> 可强制关闭账户;<code>HUB_BRIDGE_TOKEN</code> 与实例一致,或实例 <code>APP_AUTH_DISABLED=true</code><br />
公网反代请在 hub <code>.env</code> 设置 <code>HUB_USERNAME</code><code>HUB_PASSWORD</code>HTTPS 反代建议 <code>HUB_COOKIE_SECURE=true</code>
公网反代请在 hub <code>.env</code> 设置 <code>HUB_USERNAME</code><code>HUB_PASSWORD</code>(默认 <code>admin</code> / <code>admin123</code>HTTPS 反代建议 <code>HUB_COOKIE_SECURE=true</code>
</div>
</details>
<p id="settings-meta-line" class="settings-meta-line"></p>
@@ -989,6 +989,57 @@
</div>
</div>
</section>
<section class="settings-section card settings-backup-panel" data-settings-section="backup">
<div class="settings-section-head">
<button type="button" class="settings-section-fold" aria-expanded="true" aria-label="折叠"></button>
<h3 class="settings-display-title">备份与恢复</h3>
<button type="button" class="primary settings-section-save" data-settings-section="backup">保存</button>
</div>
<div class="settings-section-body">
<p class="settings-display-hint">
打包四所 <code>crypto.db</code>、中控 K 线/归档等 SQLite、<code>hub_settings.json</code><code>.env</code>(可选)。
恢复前会自动做一次 pre-restore 快照,并尝试 <code>pm2 restart all</code>
</p>
<div class="settings-grid backup-settings-grid">
<label class="chk-label settings-display-chk">
<input type="checkbox" id="backup-auto-enabled" checked />
每日自动备份(北京时间)
</label>
<div class="field">
<label>自动备份时刻(时,023</label>
<input id="backup-auto-hour" type="number" min="0" max="23" step="1" value="0" />
</div>
<div class="field">
<label>保留天数</label>
<input id="backup-retention-days" type="number" min="1" max="365" step="1" value="30" />
</div>
<label class="chk-label settings-display-chk">
<input type="checkbox" id="backup-include-env" checked />
包含 .env 配置文件
</label>
<label class="chk-label settings-display-chk">
<input type="checkbox" id="backup-include-images" />
包含四所 static/images 截图
</label>
<div class="field field-wide">
<label>备份目录(留空默认 /root/backups/crypto_monitor_portal</label>
<input id="backup-root" type="text" placeholder="/root/backups/crypto_monitor_portal" autocomplete="off" />
</div>
</div>
<div class="backup-actions">
<button type="button" id="backup-run-now" class="primary">立即备份</button>
<span id="backup-status-line" class="backup-status-line"></span>
</div>
<div class="backup-restore-upload">
<label class="backup-upload-label">
<span>上传备份包恢复(.zip</span>
<input id="backup-restore-file" type="file" accept=".zip,application/zip" />
</label>
<button type="button" id="backup-restore-upload-btn" class="danger">上传并恢复</button>
</div>
<div id="backup-list" class="backup-list"></div>
</div>
</section>
<section class="settings-section card" data-settings-section="exchanges">
<div class="settings-section-head">
<button type="button" class="settings-section-fold" aria-expanded="true" aria-label="折叠"></button>
@@ -1064,6 +1115,7 @@
<script src="/assets/dashboard.js?v=20260612-dash-monitor-count"></script>
<script src="/assets/ai_review_render.js?v=3"></script>
<script src="/assets/time_close_ui.js?v=2"></script>
<script src="/assets/backup.js?v=1"></script>
<script src="/assets/app.js?v=20260614-instance-nav"></script>
</body>
</html>
+1 -1
View File
@@ -51,7 +51,7 @@
<p id="login-err" class="login-err" hidden></p>
<p id="login-hint" class="login-foot" hidden></p>
</form>
<p class="login-foot">账号在云端 hub <code>.env</code><code>HUB_USERNAME</code> / <code>HUB_PASSWORD</code></p>
<p class="login-foot">默认账号 <code>admin</code> / <code>admin123</code>;可在 hub <code>.env</code> 修改 <code>HUB_USERNAME</code> / <code>HUB_PASSWORD</code></p>
</div>
<script>
(function () {