feat: 系统设置增加备份恢复与默认登录 admin
支持手动/每日自动备份四所数据库、K线库与 env,上传 zip 一键恢复;中控默认账号 admin/admin123。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -3969,6 +3969,7 @@
|
||||
syncSupervisorSettingsUI(data);
|
||||
renderSettingsList(data);
|
||||
initSettingsSectionFolds();
|
||||
if (typeof initBackupSettingsUI === "function") void initBackupSettingsUI();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function escAttr(s) {
|
||||
return esc(s).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -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>自动备份时刻(时,0–23)</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>
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
Reference in New Issue
Block a user