diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 5c842c0..10f714f 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -1650,6 +1650,48 @@ def api_settings_meta(): } +class HubPasswordBody(BaseModel): + old_password: str = "" + new_username: str = "" + new_password: str = "" + confirm_password: str = "" + + +@app.post("/api/settings/password") +def api_change_hub_password(body: HubPasswordBody): + from hub_env_lib import update_hub_credentials + + if not verify_credentials(expected_username(), body.old_password): + raise HTTPException(status_code=400, detail="当前密码错误") + if len(body.new_password or "") < 6: + raise HTTPException(status_code=400, detail="新密码至少 6 位") + if body.new_password != body.confirm_password: + raise HTTPException(status_code=400, detail="两次输入的新密码不一致") + new_user = (body.new_username or "").strip() + changed = update_hub_credentials( + new_password=body.new_password, + new_username=new_user or None, + ) + if not changed: + return {"ok": True, "changed_keys": [], "restart_required": False} + return {"ok": True, "changed_keys": changed, "restart_required": True} + + +@app.get("/api/admin/health") +def api_admin_health(): + return {"ok": True, "status": "up"} + + +@app.post("/api/admin/restart") +def api_admin_restart(): + from hub_env_lib import restart_hub_pm2 + + result = restart_hub_pm2() + if not result.get("ok"): + raise HTTPException(status_code=500, detail=result.get("msg") or "restart failed") + return result + + async def _fetch_agent_status(client: httpx.AsyncClient, ex: dict) -> dict: url = f"{ex['agent_url'].rstrip('/')}/status" try: diff --git a/manual_trading_hub/hub_env_lib.py b/manual_trading_hub/hub_env_lib.py new file mode 100644 index 0000000..f08a2e0 --- /dev/null +++ b/manual_trading_hub/hub_env_lib.py @@ -0,0 +1,53 @@ +"""中控 .env 读写与 PM2 重启。""" +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + +from lib.env.env_file_lib import apply_env_updates, load_env_file_into_environ + +HUB_DIR = Path(__file__).resolve().parent + + +def hub_env_path() -> str: + return str(HUB_DIR / ".env") + + +def update_hub_credentials(*, new_password: str, new_username: str | None = None) -> list[str]: + updates: dict[str, str] = {"HUB_PASSWORD": new_password} + if new_username: + updates["HUB_USERNAME"] = new_username + path = hub_env_path() + changed = apply_env_updates(path, updates) + if changed: + load_env_file_into_environ(path) + return changed + + +def restart_hub_pm2() -> dict[str, Any]: + app_name = (os.getenv("PM2_APP_NAME") or "").strip() or "manual-trading-hub" + if not sys.platform.startswith("linux"): + return {"ok": False, "msg": "仅 Linux 服务器支持 PM2 重启", "app": app_name} + try: + proc = subprocess.run( + ["pm2", "restart", app_name, "--update-env"], + capture_output=True, + text=True, + timeout=120, + ) + ok = proc.returncode == 0 + return { + "ok": ok, + "app": app_name, + "msg": (proc.stdout or proc.stderr or "").strip()[:500], + "returncode": proc.returncode, + } + except FileNotFoundError: + return {"ok": False, "msg": "未找到 pm2 命令", "app": app_name} + except subprocess.TimeoutExpired: + return {"ok": False, "msg": "pm2 restart 超时", "app": app_name} + except Exception as e: + return {"ok": False, "msg": str(e), "app": app_name} diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 2050c8c..6e5fe20 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -2989,6 +2989,125 @@ button.btn-sm { margin-bottom: 0; } +/* 中控系统设置 · CSS Tab(与实例 env 配置同方案) */ +.hub-config-body { + margin-bottom: 16px; +} + +.hub-tab-radio { + position: absolute; + opacity: 0; + pointer-events: none; + width: 0; + height: 0; + border: 0; +} + +.hub-config-tabs { + display: flex; + flex-wrap: nowrap; + gap: 0; + overflow-x: auto; + border-bottom: 1px solid var(--border-soft); + padding: 0 8px; + scrollbar-width: thin; +} + +.hub-tab-btn { + flex: 0 0 auto; + display: inline-block; + border: none; + background: transparent; + color: var(--muted); + font-size: 0.8rem; + padding: 10px 14px; + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + white-space: nowrap; + transition: color 0.15s, border-color 0.15s; + user-select: none; +} + +.hub-tab-btn:hover { + color: color-mix(in srgb, var(--text) 80%, var(--muted)); +} + +.hub-config-panels .hub-panel { + display: none; +} + +#hub-sec-0:checked ~ .hub-config-tabs label[for="hub-sec-0"], +#hub-sec-1:checked ~ .hub-config-tabs label[for="hub-sec-1"], +#hub-sec-2:checked ~ .hub-config-tabs label[for="hub-sec-2"], +#hub-sec-3:checked ~ .hub-config-tabs label[for="hub-sec-3"], +#hub-sec-4:checked ~ .hub-config-tabs label[for="hub-sec-4"], +#hub-sec-5:checked ~ .hub-config-tabs label[for="hub-sec-5"] { + color: var(--text); + border-bottom-color: var(--accent); + font-weight: 600; +} + +#hub-sec-0:checked ~ .hub-config-panels .hub-panel--0, +#hub-sec-1:checked ~ .hub-config-panels .hub-panel--1, +#hub-sec-2:checked ~ .hub-config-panels .hub-panel--2, +#hub-sec-3:checked ~ .hub-config-panels .hub-panel--3, +#hub-sec-4:checked ~ .hub-config-panels .hub-panel--4, +#hub-sec-5:checked ~ .hub-config-panels .hub-panel--5 { + display: block; +} + +.hub-config-panels { + padding: 14px 16px 16px; +} + +.hub-settings-tab-panel { + margin: 0; +} + +.hub-settings-tab-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.hub-settings-tab-title { + margin: 0; + font-size: 1rem; + font-weight: 600; +} + +.hub-settings-tab-head-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.hub-settings-tab-actions { + display: flex; + align-items: center; + gap: 10px; + margin-top: 12px; + flex-wrap: wrap; +} + +.hub-password-grid { + margin-top: 8px; +} + +.settings-status-line { + font-size: 0.8rem; + color: var(--muted); +} + +.settings-status-line.is-err { + color: var(--danger, #f87171); +} + .settings-section { margin-bottom: 16px; } diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index c501626..2366de6 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -4168,6 +4168,8 @@ const parts = []; if (m.password_required) parts.push("已启用用户名+密码登录"); else parts.push("未设 HUB_PASSWORD(反代公网暴露时建议设置 HUB_USERNAME + HUB_PASSWORD)"); + const userEl = document.getElementById("hub-pwd-current-user"); + if (userEl && m.default_username) userEl.textContent = m.default_username; if (m.hub_bridge_token_set) parts.push("中控已配置 HUB_BRIDGE_TOKEN"); else parts.push("中控未设 HUB_BRIDGE_TOKEN(实例需 APP_AUTH_DISABLED 或同令牌)"); if (m.public_origin) parts.push("浏览器外链基址: " + m.public_origin); @@ -4258,21 +4260,6 @@ } function initSettingsSectionFolds() { - document.querySelectorAll(".settings-section[data-settings-section]").forEach((el) => { - applySettingsSectionFold(el); - if (el.dataset.foldBound === "1") return; - el.dataset.foldBound = "1"; - const foldBtn = el.querySelector(":scope > .settings-section-head > .settings-section-fold"); - if (foldBtn) { - foldBtn.addEventListener("click", () => { - const section = el.dataset.settingsSection; - const collapsed = !el.classList.contains("is-collapsed"); - el.classList.toggle("is-collapsed", collapsed); - foldBtn.setAttribute("aria-expanded", collapsed ? "false" : "true"); - setSettingsFoldState(section, collapsed); - }); - } - }); document.querySelectorAll(".settings-section-save").forEach((btn) => { if (btn.dataset.saveBound === "1") return; btn.dataset.saveBound = "1"; @@ -4290,12 +4277,76 @@ ? "交易监管" : section === "exchanges" ? "交易所账户" - : "设置"; + : section === "backup" + ? "备份设置" + : "设置"; + if (section === "backup") return; void saveSettingsSection(section, { label }); }); }); } + async function waitHubHealth() { + const deadline = Date.now() + 90000; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 2000)); + try { + const r = await fetch("/api/admin/health", { credentials: "same-origin" }); + if (r.ok) return; + } catch (_) {} + } + throw new Error("重启后中控未在预期时间内恢复"); + } + + async function saveHubPassword() { + const status = document.getElementById("hub-pwd-save-status"); + const setStatus = (msg, err) => { + if (!status) return; + status.textContent = msg || ""; + status.classList.toggle("is-err", !!err); + }; + setStatus("保存中…"); + try { + const r = await apiFetch("/api/settings/password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + old_password: (document.getElementById("hub-pwd-old") || {}).value || "", + new_username: (document.getElementById("hub-pwd-new-username") || {}).value || "", + new_password: (document.getElementById("hub-pwd-new") || {}).value || "", + confirm_password: (document.getElementById("hub-pwd-confirm") || {}).value || "", + }), + }); + const j = await r.json(); + if (!r.ok) throw new Error(j.detail || j.msg || "保存失败"); + if (!j.restart_required) { + setStatus("未修改(与当前配置相同)"); + showToast("未修改"); + return; + } + setStatus("密码已保存,正在重启中控…"); + await apiFetch("/api/admin/restart", { method: "POST" }); + await waitHubHealth(); + setStatus("密码已更新,请用新密码重新登录"); + showToast("密码已更新,请重新登录"); + setTimeout(() => { + location.href = "/login"; + }, 1200); + } catch (e) { + setStatus(String(e.message || e), true); + showToast(String(e.message || e), true); + } + } + + function initHubPasswordSettings() { + const btn = document.getElementById("hub-pwd-save-btn"); + if (!btn || btn.dataset.bound === "1") return; + btn.dataset.bound = "1"; + btn.addEventListener("click", () => { + void saveHubPassword(); + }); + } + function macroDatetimeLocalToApi(v) { if (!v) return ""; return String(v).trim().replace("T", " ").slice(0, 16); @@ -4439,6 +4490,7 @@ function loadSettingsUI() { loadSettingsMetaLine(); initMacroCalendarSettings(); + initHubPasswordSettings(); loadMacroCalendarUI(); loadSettings().then((data) => { syncDisplayPrefsUI(data); diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index f1d3bc0..5931762 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -15,7 +15,7 @@ - + @@ -885,208 +885,234 @@ -
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+

备份与恢复

+ +
+

+ 打包三所 crypto.db、中控 K 线/归档等 SQLite、hub_settings.json.env(可选)。 + 恢复前会自动做一次 pre-restore 快照,并尝试 pm2 restart all。 +

+
+ +
+ + +
+
+ + +
+ + +
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+

交易所账户

+
+ + +
+
+
+
- -
-
- -

交易所账户

-
- - -
-
-
-
-
-
+ +