diff --git a/.env.example b/.env.example
index e872f9b..852a718 100644
--- a/.env.example
+++ b/.env.example
@@ -14,8 +14,9 @@
# - 若该用户已存在且设置 NAV_ADMIN_UPDATE_PASSWORD=1:用 NAV_ADMIN_PASSWORD 覆盖其密码(改完请删去或置 0,避免每次启动重置)。
# NAV_ADMIN_UPDATE_PASSWORD=1
-# 数据库(默认 SQLite 文件在当前工作目录)
+# 数据库(默认 SQLite 文件在当前工作目录;生产建议放 instance/ 且勿提交 Git)
# NAV_DATABASE_URL=sqlite:///nav_local.db
+# NAV_DATABASE_URL=sqlite:///instance/nav_local.db
# 仅 python app.py 直连启动时生效
# NAV_HOST=0.0.0.0
diff --git a/app.py b/app.py
index 53048b7..be99465 100644
--- a/app.py
+++ b/app.py
@@ -93,6 +93,103 @@ def _apply_session_cookie_settings(app: Flask) -> None:
app.config["REMEMBER_COOKIE_SECURE"] = False
+_HUB_SESSION_COOKIE = "hub_sess"
+
+
+def _hub_api_login(base: str, username: str, password: str) -> tuple[str | None, str | None]:
+ """服务端登录中控,返回 (session_token, error_detail)。"""
+ payload = json.dumps({"username": username, "password": password}).encode("utf-8")
+ req = urllib.request.Request(
+ f"{base.rstrip('/')}/api/auth/login",
+ data=payload,
+ headers={"Content-Type": "application/json", "Accept": "application/json"},
+ method="POST",
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=15) as resp:
+ raw = resp.read().decode("utf-8", errors="replace")
+ except urllib.error.HTTPError as e:
+ try:
+ err_body = e.read().decode("utf-8", errors="replace")
+ detail = json.loads(err_body).get("detail", err_body)
+ except Exception:
+ detail = str(e)
+ return None, detail or "中控登录失败"
+ except Exception as e:
+ return None, f"无法连接中控: {e}"
+
+ try:
+ data = json.loads(raw)
+ except json.JSONDecodeError:
+ return None, "中控返回非 JSON"
+ if not data.get("ok"):
+ return None, data.get("detail") or "登录失败"
+ token = (data.get("session_token") or "").strip()
+ if not token:
+ return None, "中控未返回 session_token,请升级云端 hub 后重试"
+ return token, None
+
+
+def _hub_instance_open_url(
+ base: str, session_token: str, exchange_id: str, next_path: str, *, embed: bool = True
+) -> tuple[str | None, str | None]:
+ """已登录中控会话下签发实例 SSO 打开链接。"""
+ params = {"exchange_id": str(exchange_id), "next": next_path or "/"}
+ if embed:
+ params["embed"] = "1"
+ q = urlencode(params)
+ req = urllib.request.Request(
+ f"{base.rstrip('/')}/api/instance/open-url?{q}",
+ headers={
+ "Accept": "application/json",
+ "Cookie": f"{_HUB_SESSION_COOKIE}={session_token}",
+ },
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=15) as resp:
+ raw = resp.read().decode("utf-8", errors="replace")
+ except urllib.error.HTTPError as e:
+ try:
+ err_body = e.read().decode("utf-8", errors="replace")
+ detail = json.loads(err_body).get("detail", err_body)
+ except Exception:
+ detail = str(e)
+ return None, detail or "无法签发实例链接"
+ except Exception as e:
+ return None, f"无法连接中控: {e}"
+
+ try:
+ data = json.loads(raw)
+ except json.JSONDecodeError:
+ return None, "中控返回非 JSON"
+ url = (data.get("url") or "").strip()
+ if not data.get("ok") or not url:
+ return None, data.get("detail") or "无法签发实例链接"
+ return url, None
+
+
+def _resolve_hub_embed_service(body: dict) -> tuple[str | None, str | None, str | None]:
+ """返回 (base_origin, default_next_path, error_detail)。"""
+ sid = body.get("service_id")
+ base = (body.get("base_url") or "").strip().rstrip("/")
+ next_path = (body.get("next") or "/monitor").strip() or "/monitor"
+ if not next_path.startswith("/"):
+ next_path = "/" + next_path
+
+ if sid:
+ svc = db.session.get(Service, int(sid))
+ if not svc or not svc.is_hub_embed():
+ return None, None, "服务不存在或未标记为中控"
+ base = svc.build_origin()
+ if not body.get("next") or next_path == "/monitor":
+ p = (svc.path or "/monitor").strip() or "/monitor"
+ next_path = p if p.startswith("/") else "/" + p
+
+ if not base:
+ return None, None, "缺少 base_url"
+ return base, next_path, None
+
+
def create_app() -> Flask:
app = Flask(__name__)
app.config["SECRET_KEY"] = os.environ.get("NAV_SECRET_KEY") or secrets.token_hex(32)
@@ -178,65 +275,71 @@ def create_app() -> Flask:
避免浏览器在跨站 iframe 里丢弃 Set-Cookie。
"""
body = request.get_json(silent=True) or {}
- sid = body.get("service_id")
- base = (body.get("base_url") or "").strip().rstrip("/")
- next_path = (body.get("next") or "/monitor").strip() or "/monitor"
- if not next_path.startswith("/"):
- next_path = "/" + next_path
username = (body.get("username") or os.environ.get("NAV_HUB_USERNAME") or "").strip()
password = body.get("password")
if password is None:
password = os.environ.get("NAV_HUB_PASSWORD") or ""
password = str(password)
- if sid:
- svc = db.session.get(Service, int(sid))
- if not svc or not svc.is_hub_embed():
- return jsonify({"ok": False, "detail": "服务不存在或未标记为中控"}), 400
- base = svc.build_origin()
- if not next_path or next_path == "/monitor":
- p = (svc.path or "/monitor").strip() or "/monitor"
- next_path = p if p.startswith("/") else "/" + p
-
- if not base:
- return jsonify({"ok": False, "detail": "缺少 base_url"}), 400
+ base, next_path, err = _resolve_hub_embed_service(body)
+ if err:
+ return jsonify({"ok": False, "detail": err}), 400
if not username or not password:
return jsonify({"ok": False, "detail": "缺少中控用户名或密码(可配置 NAV_HUB_USERNAME / NAV_HUB_PASSWORD)"}), 400
- payload = json.dumps({"username": username, "password": password}).encode("utf-8")
- req = urllib.request.Request(
- f"{base}/api/auth/login",
- data=payload,
- headers={"Content-Type": "application/json", "Accept": "application/json"},
- method="POST",
- )
- try:
- with urllib.request.urlopen(req, timeout=15) as resp:
- raw = resp.read().decode("utf-8", errors="replace")
- except urllib.error.HTTPError as e:
- try:
- err_body = e.read().decode("utf-8", errors="replace")
- detail = json.loads(err_body).get("detail", err_body)
- except Exception:
- detail = str(e)
- return jsonify({"ok": False, "detail": detail or "中控登录失败"}), 401
- except Exception as e:
- return jsonify({"ok": False, "detail": f"无法连接中控: {e}"}), 502
-
- try:
- data = json.loads(raw)
- except json.JSONDecodeError:
- return jsonify({"ok": False, "detail": "中控返回非 JSON"}), 502
- if not data.get("ok"):
- return jsonify({"ok": False, "detail": data.get("detail") or "登录失败"}), 401
- token = data.get("session_token")
- if not token:
- return jsonify({"ok": False, "detail": "中控未返回 session_token,请升级云端 hub 后重试"}), 502
+ token, err = _hub_api_login(base, username, password)
+ if err:
+ status = 401 if "登录" in err or "401" in err else 502
+ return jsonify({"ok": False, "detail": err}), status
q = urlencode({"token": token, "next": next_path})
embed_url = f"{base}/embed-auth?{q}"
return jsonify({"ok": True, "embed_auth_url": embed_url, "next": next_path})
+ @app.route("/api/embed/hub-instance-url", methods=["POST"])
+ @csrf.exempt
+ @login_required
+ def api_embed_hub_instance_url():
+ """
+ 本地导航代签实例 SSO:服务端登录中控后调用 /api/instance/open-url。
+ 用于 LocalNav iframe 直接打开实例(避免中控内再嵌一层 iframe)。
+ """
+ body = request.get_json(silent=True) or {}
+ exchange_id = str(body.get("exchange_id") or "").strip()
+ next_path = (body.get("next") or "/").strip() or "/"
+ if not next_path.startswith("/"):
+ next_path = "/" + next_path
+ if not exchange_id:
+ return jsonify({"ok": False, "detail": "缺少 exchange_id"}), 400
+
+ username = (body.get("username") or os.environ.get("NAV_HUB_USERNAME") or "").strip()
+ password = body.get("password")
+ if password is None:
+ password = os.environ.get("NAV_HUB_PASSWORD") or ""
+ password = str(password)
+
+ base, _, err = _resolve_hub_embed_service(body)
+ if err:
+ return jsonify({"ok": False, "detail": err}), 400
+ if not username or not password:
+ return jsonify({"ok": False, "detail": "缺少中控用户名或密码(可配置 NAV_HUB_USERNAME / NAV_HUB_PASSWORD)"}), 400
+
+ token, err = _hub_api_login(base, username, password)
+ if err:
+ status = 401 if "登录" in err or "401" in err else 502
+ return jsonify({"ok": False, "detail": err}), status
+
+ url, err = _hub_instance_open_url(
+ base,
+ token,
+ exchange_id,
+ next_path,
+ embed=(body.get("embed") or "1").strip().lower() not in ("0", "false", "no"),
+ )
+ if err:
+ return jsonify({"ok": False, "detail": err}), 502
+ return jsonify({"ok": True, "url": url, "exchange_id": exchange_id, "next": next_path})
+
# ---------- 分组管理 ----------
@app.route("/admin/groups")
@login_required
diff --git a/instance/nav_local.db b/instance/nav_local.db
deleted file mode 100644
index 932131d..0000000
Binary files a/instance/nav_local.db and /dev/null differ
diff --git a/models.py b/models.py
index 47a3ddc..32d816e 100644
--- a/models.py
+++ b/models.py
@@ -54,7 +54,10 @@ class Service(db.Model):
proto = (self.scheme or "http").strip().lower()
if proto not in ("http", "https"):
proto = "http"
- return f"{proto}://{self.host}:{self.port}"
+ port = int(self.port or (443 if proto == "https" else 80))
+ if (proto == "https" and port == 443) or (proto == "http" and port == 80):
+ return f"{proto}://{self.host}"
+ return f"{proto}://{self.host}:{port}"
def build_url(self) -> str:
p = (self.path or "/").strip()
diff --git a/static/style.css b/static/style.css
index 9b40ed2..6a91a4f 100644
--- a/static/style.css
+++ b/static/style.css
@@ -348,6 +348,19 @@ a:hover {
background: rgba(61, 139, 253, 0.1);
}
+#frame-back-hub {
+ color: #9ecbff;
+ border-color: rgba(61, 139, 253, 0.45);
+ background: rgba(61, 139, 253, 0.12);
+ font-weight: 600;
+}
+
+#frame-back-hub:hover {
+ color: #fff;
+ border-color: #3d8bfd;
+ background: rgba(61, 139, 253, 0.22);
+}
+
.frame-title {
font-size: 0.92rem;
font-weight: 600;
diff --git a/templates/index.html b/templates/index.html
index edccf63..984a714 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -106,6 +106,15 @@
+
@@ -118,6 +127,15 @@
>
中控登录
+
@@ -132,7 +150,7 @@
-
+
@@ -181,18 +199,55 @@
var btnRefresh = document.getElementById("frame-refresh");
var btnForceRefresh = document.getElementById("frame-force-refresh");
var btnBack = document.getElementById("frame-back-overview");
+ var btnBackHub = document.getElementById("frame-back-hub");
var btnHubLogin = document.getElementById("frame-hub-login");
+ var btnInstanceSso = document.getElementById("frame-instance-sso");
var currentBaseUrl = "";
var currentOpenUrl = "";
var currentEmbedKind = "";
var currentServiceId = "";
var currentOrigin = "";
var currentNextPath = "/monitor";
+ var currentViewMode = "service";
+ var hubReturnState = null;
+ var instanceNavCtx = null;
+
+ function normalizeOrigin(raw) {
+ if (!raw) return "";
+ try {
+ var u = new URL(raw.indexOf("://") >= 0 ? raw : "http://" + raw);
+ var port = u.port;
+ if (u.protocol === "https:" && (!port || port === "443")) {
+ return u.protocol + "//" + u.hostname;
+ }
+ if (u.protocol === "http:" && (!port || port === "80")) {
+ return u.protocol + "//" + u.hostname;
+ }
+ return u.origin;
+ } catch (e) {
+ return String(raw).replace(/\/+$/, "");
+ }
+ }
+
+ function originsCompatible(expected, actual) {
+ var e = normalizeOrigin(expected);
+ var a = normalizeOrigin(actual);
+ if (!e || !a) return true;
+ return e === a;
+ }
function isHubEmbed(kind) {
return (kind || "").toLowerCase() === "hub";
}
+ function toggleInstanceBackBtn(show) {
+ if (btnBackHub) btnBackHub.hidden = !show;
+ }
+
+ function toggleInstanceSsoBtn(show) {
+ if (btnInstanceSso) btnInstanceSso.hidden = !show;
+ }
+
function toggleHubLoginBtn(show) {
if (btnHubLogin) btnHubLogin.hidden = !show;
}
@@ -202,6 +257,48 @@
frame.src = url;
}
+ function iframeLooksLikeHub(href) {
+ if (!href) return false;
+ var hubOrigin = normalizeOrigin(currentOrigin);
+ var h = String(href);
+ if (hubOrigin && h.indexOf(hubOrigin) !== 0) return false;
+ return (
+ /\/monitor(\?|#|$)/.test(h) ||
+ h.indexOf("/embed-auth") >= 0 ||
+ (h.indexOf("/login") >= 0 && h.indexOf("embed=1") >= 0) ||
+ h.indexOf("/settings") >= 0
+ );
+ }
+
+ function syncHubInstanceBackBtn() {
+ if (!isHubEmbed(currentEmbedKind) || frameStack.hidden || !currentBaseUrl) {
+ toggleInstanceBackBtn(false);
+ toggleInstanceSsoBtn(false);
+ return;
+ }
+ var onHub = false;
+ try {
+ onHub = iframeLooksLikeHub(frame.contentWindow.location.href);
+ } catch (e) {
+ onHub = false;
+ }
+ if (onHub) {
+ currentViewMode = "hub";
+ toggleInstanceBackBtn(false);
+ toggleInstanceSsoBtn(false);
+ toggleHubLoginBtn(true);
+ return;
+ }
+ currentViewMode = "hub-instance";
+ toggleInstanceBackBtn(true);
+ toggleHubLoginBtn(false);
+ toggleInstanceSsoBtn(!!(instanceNavCtx && instanceNavCtx.exchangeId));
+ }
+
+ if (frame) {
+ frame.addEventListener("load", syncHubInstanceBackBtn);
+ }
+
function hubLoginViaProxy(done) {
if (!currentServiceId && !currentOrigin) {
if (done) done(false, "未选择中控服务");
@@ -234,12 +331,127 @@
window.addEventListener("message", function (ev) {
var data = ev.data;
- if (!data || data.type !== "hub:login-ok") return;
- if (data.embed_auth_url) {
- applyIframeUrl(data.embed_auth_url);
+ if (!data || !data.type) return;
+ if (data.type === "hub:login-ok") {
+ if (data.embed_auth_url) {
+ applyIframeUrl(data.embed_auth_url);
+ }
+ return;
}
+ if (data.type === "hub:open-instance-nav") {
+ instanceNavCtx = {
+ exchangeId: String(data.exchangeId || ""),
+ nextPath: data.nextPath || "/",
+ title: data.title || "交易所实例",
+ serviceId: currentServiceId,
+ };
+ currentViewMode = "hub-instance";
+ if (data.title) nameEl.textContent = data.title;
+ toggleHubLoginBtn(false);
+ toggleInstanceBackBtn(true);
+ toggleInstanceSsoBtn(!!instanceNavCtx.exchangeId);
+ return;
+ }
+ if (data.type !== "hub:open-instance") return;
+ if (frameStack.hidden || !currentServiceId) return;
+ if (!originsCompatible(currentOrigin, ev.origin)) {
+ console.warn(
+ "[LocalNav] hub:open-instance origin 不匹配,已忽略",
+ normalizeOrigin(currentOrigin),
+ normalizeOrigin(ev.origin)
+ );
+ return;
+ }
+ if (!data.url) return;
+
+ try {
+ if (ev.source) {
+ ev.source.postMessage({ type: "hub:open-instance-ack", ok: true }, ev.origin || "*");
+ }
+ } catch (e) {}
+
+ hubReturnState = {
+ openUrl: currentOpenUrl,
+ baseUrl: currentBaseUrl,
+ name: nameEl.textContent,
+ embedKind: currentEmbedKind,
+ serviceId: currentServiceId,
+ origin: currentOrigin,
+ nextPath: currentNextPath,
+ };
+ instanceNavCtx = {
+ exchangeId: String(data.exchangeId || ""),
+ nextPath: data.nextPath || "/",
+ title: data.title || "交易所实例",
+ serviceId: currentServiceId,
+ };
+ currentViewMode = "hub-instance";
+ nameEl.textContent = instanceNavCtx.title;
+ toggleHubLoginBtn(false);
+ toggleInstanceBackBtn(true);
+ applyIframeUrl(data.url);
});
+ function refreshInstanceViaProxy(done) {
+ if (!instanceNavCtx || !instanceNavCtx.exchangeId) {
+ if (done) done(false, null, "缺少实例上下文");
+ return;
+ }
+ fetch("/api/embed/hub-instance-url", {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "Accept": "application/json" },
+ body: JSON.stringify({
+ service_id: parseInt(instanceNavCtx.serviceId, 10) || undefined,
+ exchange_id: instanceNavCtx.exchangeId,
+ next: instanceNavCtx.nextPath || "/",
+ embed: "1",
+ }),
+ })
+ .then(function (r) {
+ return r.json().then(function (j) {
+ return { ok: r.ok, j: j };
+ });
+ })
+ .then(function (res) {
+ if (res.ok && res.j.ok && res.j.url) {
+ if (done) done(true, res.j.url, null);
+ return;
+ }
+ if (done) done(false, null, (res.j && res.j.detail) || "无法重新打开实例");
+ })
+ .catch(function (e) {
+ if (done) done(false, null, String(e));
+ });
+ }
+
+ function returnToHubMonitor() {
+ var st = hubReturnState;
+ currentViewMode = "hub";
+ instanceNavCtx = null;
+ hubReturnState = null;
+ toggleInstanceBackBtn(false);
+ toggleInstanceSsoBtn(false);
+ if (st) {
+ currentOpenUrl = st.openUrl || currentOpenUrl;
+ currentBaseUrl = st.baseUrl || st.openUrl || currentBaseUrl;
+ currentEmbedKind = st.embedKind || currentEmbedKind;
+ currentServiceId = st.serviceId || currentServiceId;
+ currentOrigin = st.origin || currentOrigin;
+ currentNextPath = st.nextPath || currentNextPath;
+ nameEl.textContent = st.name || nameEl.textContent;
+ }
+ toggleHubLoginBtn(isHubEmbed(currentEmbedKind));
+ if (isHubEmbed(currentEmbedKind) && hubAutoLogin) {
+ hubLoginViaProxy(function (ok) {
+ if (!ok) applyIframeUrl(currentOpenUrl || currentBaseUrl);
+ syncHubInstanceBackBtn();
+ });
+ return;
+ }
+ applyIframeUrl(currentOpenUrl || currentBaseUrl);
+ syncHubInstanceBackBtn();
+ }
+
function setActive(el) {
links.forEach(function (a) {
a.classList.remove("active");
@@ -264,6 +476,10 @@
currentServiceId = meta.serviceId || "";
currentOrigin = meta.origin || "";
currentNextPath = meta.nextPath || "/monitor";
+ currentViewMode = isHubEmbed(currentEmbedKind) ? "hub" : "service";
+ instanceNavCtx = null;
+ hubReturnState = null;
+ toggleInstanceBackBtn(false);
nameEl.textContent = name || "";
dashboard.hidden = true;
frameStack.hidden = false;
@@ -307,12 +523,33 @@
}
function reloadUrl() {
+ if (currentViewMode === "hub-instance") {
+ refreshInstanceViaProxy(function (ok, url, err) {
+ if (ok && url) applyIframeUrl(url);
+ else if (err) window.alert(err);
+ });
+ return;
+ }
var u = currentOpenUrl || currentBaseUrl;
if (!u) return;
frame.src = buildCacheBustUrl(u, false);
}
function forceReloadUrl() {
+ if (currentViewMode === "hub-instance") {
+ refreshInstanceViaProxy(function (ok, url, err) {
+ if (!ok || !url) {
+ if (err) window.alert(err);
+ return;
+ }
+ frame.src = "about:blank";
+ frame.onload = function () {
+ frame.onload = null;
+ frame.src = buildCacheBustUrl(url, true);
+ };
+ });
+ return;
+ }
var u = currentOpenUrl || currentBaseUrl;
if (!u) return;
frame.src = "about:blank";
@@ -328,11 +565,15 @@
currentEmbedKind = "";
currentServiceId = "";
currentOrigin = "";
+ currentViewMode = "service";
+ instanceNavCtx = null;
+ hubReturnState = null;
frame.src = "about:blank";
frame.hidden = true;
frameStack.hidden = true;
dashboard.hidden = false;
toggleHubLoginBtn(false);
+ toggleInstanceSsoBtn(false);
setActive(null);
}
@@ -368,7 +609,24 @@
btnHubLogin.disabled = true;
hubLoginViaProxy(function (ok, err) {
btnHubLogin.disabled = false;
- if (!ok && err) window.alert(err);
+ if (!ok && err) {
+ window.alert("中控登录失败:\n" + err + "\n\n请检查 LocalNav .env 的 NAV_HUB_USERNAME / NAV_HUB_PASSWORD 是否与云端 hub .env 一致。");
+ }
+ });
+ });
+ }
+
+ if (btnInstanceSso) {
+ btnInstanceSso.addEventListener("click", function () {
+ btnInstanceSso.disabled = true;
+ refreshInstanceViaProxy(function (ok, url, err) {
+ btnInstanceSso.disabled = false;
+ if (ok && url) {
+ applyIframeUrl(url);
+ syncHubInstanceBackBtn();
+ return;
+ }
+ if (err) window.alert("实例免密失败:\n" + err);
});
});
}
@@ -384,6 +642,12 @@
btnBack.addEventListener("click", function () {
showDashboard();
});
+
+ if (btnBackHub) {
+ btnBackHub.addEventListener("click", function () {
+ returnToHubMonitor();
+ });
+ }
})();
{% endblock %}
diff --git a/部署与使用说明.md b/部署与使用说明.md
index 95c25d2..8ca5f9b 100644
--- a/部署与使用说明.md
+++ b/部署与使用说明.md
@@ -264,7 +264,17 @@ HUB_EMBED_ORIGINS=http://192.168.8.6:5070
将 `192.168.8.6:5070` 换成你本机访问 LocalNav 的完整 Origin(含协议与端口)。多台电脑可逗号分隔。
-**四实例(币安/Gate/OKX)**:建议仍从中控内点「打开实例」(SSO 免密);若在 LocalNav 直接嵌实例 URL,需在 iframe 内各自登录,或同样存在跨站 Cookie 限制。
+**四实例(币安/Gate/OKX)**:从中控点「实例 / 策略交易 / 复盘」时,**最新版**会由中控 `postMessage` 通知本地导航,在**同一层 iframe** 打开实例 SSO 链接(避免「导航 → 中控 → 实例」三层嵌套导致 Cookie 失效、反复要密码)。工具栏会出现 **「← 中控」** 返回监控区;刷新会由本地导航服务端代签新的 SSO 链接(须已配置 `NAV_HUB_USERNAME` / `NAV_HUB_PASSWORD`)。
+
+四实例 `.env` 建议:
+
+```env
+APP_COOKIE_SECURE=true
+APP_ALLOW_HUB_EMBED=true
+HUB_EMBED_PARENT_ORIGINS=https://你的中控域名,http://192.168.x.x:5070
+```
+
+(`5070` 换成本地导航实际地址;与中控相同的 `HUB_BRIDGE_TOKEN` 必填。)
---