修复中控
This commit is contained in:
+2
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
+267
-3
@@ -106,6 +106,15 @@
|
||||
<button type="button" class="btn-frame-back" id="frame-back-overview" title="返回服务总览">
|
||||
总览
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-frame-back"
|
||||
id="frame-back-hub"
|
||||
title="返回复盘中控监控区"
|
||||
hidden
|
||||
>
|
||||
← 返回中控
|
||||
</button>
|
||||
<span class="frame-title" id="current-service-name"></span>
|
||||
</div>
|
||||
<div class="frame-toolbar-actions">
|
||||
@@ -118,6 +127,15 @@
|
||||
>
|
||||
中控登录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-toolbar-refresh"
|
||||
id="frame-instance-sso"
|
||||
title="通过本地导航代签实例 SSO(需 NAV_HUB_USERNAME / NAV_HUB_PASSWORD)"
|
||||
hidden
|
||||
>
|
||||
实例免密
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-toolbar-refresh" id="frame-refresh">
|
||||
刷新
|
||||
</button>
|
||||
@@ -132,7 +150,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="frame-wrap">
|
||||
<iframe id="svc-frame" title="内嵌服务" hidden></iframe>
|
||||
<iframe id="svc-frame" name="svc-frame" title="内嵌服务" hidden></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 || !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();
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+11
-1
@@ -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` 必填。)
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user