修复
This commit is contained in:
@@ -34,3 +34,10 @@
|
|||||||
# NAV_SESSION_COOKIE_SECURE=auto
|
# NAV_SESSION_COOKIE_SECURE=auto
|
||||||
# CSRF 校验仍失败时,可填前端访问的完整 Origin,多个用英文逗号分隔,例如:
|
# CSRF 校验仍失败时,可填前端访问的完整 Origin,多个用英文逗号分隔,例如:
|
||||||
# NAV_CSRF_TRUSTED_ORIGINS=https://nav.example.com
|
# NAV_CSRF_TRUSTED_ORIGINS=https://nav.example.com
|
||||||
|
|
||||||
|
# ---------- 云端复盘中控 iframe 嵌入(与 manual_trading_hub 配合)----------
|
||||||
|
# 本地导航代登录中控(服务端请求云端 /api/auth/login,再打开 /embed-auth)
|
||||||
|
# NAV_HUB_USERNAME=admin
|
||||||
|
# NAV_HUB_PASSWORD=你的中控密码
|
||||||
|
# 打开标记为「复盘中控」的服务时自动代登录(1=开启)
|
||||||
|
# NAV_HUB_AUTO_LOGIN=1
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from flask import Flask, flash, redirect, render_template, request, url_for
|
from flask import Flask, flash, jsonify, redirect, render_template, request, url_for
|
||||||
from flask_login import LoginManager, current_user, login_required, login_user, logout_user
|
from flask_login import LoginManager, current_user, login_required, login_user, logout_user
|
||||||
from flask_wtf.csrf import CSRFProtect
|
from flask_wtf.csrf import CSRFProtect
|
||||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
@@ -159,7 +163,79 @@ def create_app() -> Flask:
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
grouped.append((g, svcs))
|
grouped.append((g, svcs))
|
||||||
return render_template("index.html", grouped=grouped)
|
return render_template(
|
||||||
|
"index.html",
|
||||||
|
grouped=grouped,
|
||||||
|
hub_auto_login=os.environ.get("NAV_HUB_AUTO_LOGIN", "").strip() == "1",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/api/embed/hub-login", methods=["POST"])
|
||||||
|
@csrf.exempt
|
||||||
|
@login_required
|
||||||
|
def api_embed_hub_login():
|
||||||
|
"""
|
||||||
|
本地导航代登录云端中控:服务端请求 hub /api/auth/login,返回 embed-auth URL。
|
||||||
|
避免浏览器在跨站 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
|
||||||
|
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
|
||||||
|
|
||||||
|
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("/admin/groups")
|
@app.route("/admin/groups")
|
||||||
@@ -261,6 +337,7 @@ def create_app() -> Flask:
|
|||||||
path=path,
|
path=path,
|
||||||
sort_order=form.sort_order.data or 0,
|
sort_order=form.sort_order.data or 0,
|
||||||
group_id=form.group_id.data,
|
group_id=form.group_id.data,
|
||||||
|
embed_kind=(form.embed_kind.data or "").strip(),
|
||||||
)
|
)
|
||||||
db.session.add(s)
|
db.session.add(s)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -296,6 +373,7 @@ def create_app() -> Flask:
|
|||||||
s.path = (form.path.data or "").strip() or "/"
|
s.path = (form.path.data or "").strip() or "/"
|
||||||
s.sort_order = form.sort_order.data or 0
|
s.sort_order = form.sort_order.data or 0
|
||||||
s.group_id = form.group_id.data
|
s.group_id = form.group_id.data
|
||||||
|
s.embed_kind = (form.embed_kind.data or "").strip()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash("服务已更新", "success")
|
flash("服务已更新", "success")
|
||||||
return redirect(url_for("admin_services"))
|
return redirect(url_for("admin_services"))
|
||||||
@@ -352,6 +430,16 @@ def _migrate_schema() -> None:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
print("[nav] 已为 services 表添加 scheme 列(默认 http)。", flush=True)
|
print("[nav] 已为 services 表添加 scheme 列(默认 http)。", flush=True)
|
||||||
|
cols = {c["name"] for c in insp.get_columns("services")}
|
||||||
|
if "embed_kind" not in cols:
|
||||||
|
with db.engine.begin() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE services ADD COLUMN embed_kind VARCHAR(16) "
|
||||||
|
"NOT NULL DEFAULT ''"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
print("[nav] 已为 services 表添加 embed_kind 列。", flush=True)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"[nav] 数据库结构迁移跳过或失败: {exc}", flush=True)
|
print(f"[nav] 数据库结构迁移跳过或失败: {exc}", flush=True)
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,15 @@ class ServiceForm(FlaskForm):
|
|||||||
validators=[Optional(), NumberRange(min=-10**6, max=10**6)],
|
validators=[Optional(), NumberRange(min=-10**6, max=10**6)],
|
||||||
default=0,
|
default=0,
|
||||||
)
|
)
|
||||||
|
embed_kind = SelectField(
|
||||||
|
"嵌入类型",
|
||||||
|
choices=[
|
||||||
|
("", "普通(直接打开路径)"),
|
||||||
|
("hub", "复盘中控(云端 hub,需 embed-auth 登录)"),
|
||||||
|
],
|
||||||
|
default="",
|
||||||
|
validators=[Optional()],
|
||||||
|
)
|
||||||
submit = SubmitField("保存")
|
submit = SubmitField("保存")
|
||||||
|
|
||||||
def validate_path(self, field):
|
def validate_path(self, field):
|
||||||
|
|||||||
Binary file not shown.
@@ -47,12 +47,33 @@ class Service(db.Model):
|
|||||||
group_id = db.Column(
|
group_id = db.Column(
|
||||||
db.Integer, db.ForeignKey("service_groups.id"), nullable=False, index=True
|
db.Integer, db.ForeignKey("service_groups.id"), nullable=False, index=True
|
||||||
)
|
)
|
||||||
|
# hub=复盘中控(iframe 嵌入需 /embed-auth);留空=普通内嵌
|
||||||
|
embed_kind = db.Column(db.String(16), nullable=False, default="", server_default="")
|
||||||
|
|
||||||
|
def build_origin(self) -> str:
|
||||||
|
proto = (self.scheme or "http").strip().lower()
|
||||||
|
if proto not in ("http", "https"):
|
||||||
|
proto = "http"
|
||||||
|
return f"{proto}://{self.host}:{self.port}"
|
||||||
|
|
||||||
def build_url(self) -> str:
|
def build_url(self) -> str:
|
||||||
p = (self.path or "/").strip()
|
p = (self.path or "/").strip()
|
||||||
if not p.startswith("/"):
|
if not p.startswith("/"):
|
||||||
p = "/" + p
|
p = "/" + p
|
||||||
proto = (self.scheme or "http").strip().lower()
|
return f"{self.build_origin()}{p}"
|
||||||
if proto not in ("http", "https"):
|
|
||||||
proto = "http"
|
def build_open_url(self) -> str:
|
||||||
return f"{proto}://{self.host}:{self.port}{p}"
|
"""导航 iframe 首次打开的地址(中控走 login?embed=1 以便写入会话)。"""
|
||||||
|
kind = (self.embed_kind or "").strip().lower()
|
||||||
|
next_path = (self.path or "/monitor").strip() or "/monitor"
|
||||||
|
if not next_path.startswith("/"):
|
||||||
|
next_path = "/" + next_path
|
||||||
|
if kind == "hub":
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
q = urlencode({"embed": "1", "next": next_path})
|
||||||
|
return f"{self.build_origin()}/login?{q}"
|
||||||
|
return self.build_url()
|
||||||
|
|
||||||
|
def is_hub_embed(self) -> bool:
|
||||||
|
return (self.embed_kind or "").strip().lower() == "hub"
|
||||||
|
|||||||
@@ -70,6 +70,16 @@
|
|||||||
<div class="errors">{{ form.sort_order.errors[0] }}</div>
|
<div class="errors">{{ form.sort_order.errors[0] }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
{{ form.embed_kind.label }}
|
||||||
|
{{ form.embed_kind() }}
|
||||||
|
<p class="hint" style="margin:0.35rem 0 0">
|
||||||
|
选「复盘中控」时:路径填 <code>/monitor</code>;本地 iframe 打开会先走中控登录页或代登录。
|
||||||
|
</p>
|
||||||
|
{% if form.embed_kind.errors %}
|
||||||
|
<div class="errors">{{ form.embed_kind.errors[0] }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
<div class="toolbar" style="margin-top: 1.25rem">
|
<div class="toolbar" style="margin-top: 1.25rem">
|
||||||
{{ form.submit(class="btn btn-primary", style="width: auto") }}
|
{{ form.submit(class="btn btn-primary", style="width: auto") }}
|
||||||
<a class="btn btn-secondary" href="{{ url_for('admin_services') }}">取消</a>
|
<a class="btn btn-secondary" href="{{ url_for('admin_services') }}">取消</a>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for s in services %}
|
{% for s in services %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ s.name }}</td>
|
<td>{{ s.name }}{% if s.embed_kind == 'hub' %} <span class="hint">(中控)</span>{% endif %}</td>
|
||||||
<td><code style="font-size: 0.82rem">{{ s.build_url() }}</code></td>
|
<td><code style="font-size: 0.82rem">{{ s.build_url() }}</code></td>
|
||||||
<td>{{ s.group.name if s.group else '—' }}</td>
|
<td>{{ s.group.name if s.group else '—' }}</td>
|
||||||
<td>{{ s.sort_order }}</td>
|
<td>{{ s.sort_order }}</td>
|
||||||
|
|||||||
+126
-9
@@ -41,7 +41,12 @@
|
|||||||
href="#"
|
href="#"
|
||||||
class="nav-link"
|
class="nav-link"
|
||||||
role="button"
|
role="button"
|
||||||
data-url="{{ svc.build_url()|e }}"
|
data-url="{{ svc.build_open_url()|e }}"
|
||||||
|
data-base-url="{{ svc.build_url()|e }}"
|
||||||
|
data-origin="{{ svc.build_origin()|e }}"
|
||||||
|
data-next-path="{{ (svc.path or '/monitor')|e }}"
|
||||||
|
data-embed-kind="{{ (svc.embed_kind or '')|e }}"
|
||||||
|
data-service-id="{{ svc.id }}"
|
||||||
data-name="{{ svc.name | e }}"
|
data-name="{{ svc.name | e }}"
|
||||||
>{{ svc.name }}</a
|
>{{ svc.name }}</a
|
||||||
>
|
>
|
||||||
@@ -75,7 +80,12 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="service-card"
|
class="service-card"
|
||||||
data-url="{{ svc.build_url()|e }}"
|
data-url="{{ svc.build_open_url()|e }}"
|
||||||
|
data-base-url="{{ svc.build_url()|e }}"
|
||||||
|
data-origin="{{ svc.build_origin()|e }}"
|
||||||
|
data-next-path="{{ (svc.path or '/monitor')|e }}"
|
||||||
|
data-embed-kind="{{ (svc.embed_kind or '')|e }}"
|
||||||
|
data-service-id="{{ svc.id }}"
|
||||||
data-name="{{ svc.name | e }}"
|
data-name="{{ svc.name | e }}"
|
||||||
>
|
>
|
||||||
<span class="service-card-title">{{ svc.name }}</span>
|
<span class="service-card-title">{{ svc.name }}</span>
|
||||||
@@ -99,6 +109,15 @@
|
|||||||
<span class="frame-title" id="current-service-name"></span>
|
<span class="frame-title" id="current-service-name"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="frame-toolbar-actions">
|
<div class="frame-toolbar-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary btn-toolbar-refresh"
|
||||||
|
id="frame-hub-login"
|
||||||
|
title="通过本地导航代登录云端中控(需配置 NAV_HUB_USERNAME / NAV_HUB_PASSWORD)"
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
中控登录
|
||||||
|
</button>
|
||||||
<button type="button" class="btn btn-secondary btn-toolbar-refresh" id="frame-refresh">
|
<button type="button" class="btn btn-secondary btn-toolbar-refresh" id="frame-refresh">
|
||||||
刷新
|
刷新
|
||||||
</button>
|
</button>
|
||||||
@@ -121,6 +140,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
var hubAutoLogin = {{ 'true' if hub_auto_login else 'false' }};
|
||||||
var layoutMain = document.getElementById("layout-main");
|
var layoutMain = document.getElementById("layout-main");
|
||||||
var btnSidebarCollapse = document.getElementById("sidebar-collapse");
|
var btnSidebarCollapse = document.getElementById("sidebar-collapse");
|
||||||
var btnSidebarExpand = document.getElementById("sidebar-expand");
|
var btnSidebarExpand = document.getElementById("sidebar-expand");
|
||||||
@@ -161,7 +181,64 @@
|
|||||||
var btnRefresh = document.getElementById("frame-refresh");
|
var btnRefresh = document.getElementById("frame-refresh");
|
||||||
var btnForceRefresh = document.getElementById("frame-force-refresh");
|
var btnForceRefresh = document.getElementById("frame-force-refresh");
|
||||||
var btnBack = document.getElementById("frame-back-overview");
|
var btnBack = document.getElementById("frame-back-overview");
|
||||||
|
var btnHubLogin = document.getElementById("frame-hub-login");
|
||||||
var currentBaseUrl = "";
|
var currentBaseUrl = "";
|
||||||
|
var currentOpenUrl = "";
|
||||||
|
var currentEmbedKind = "";
|
||||||
|
var currentServiceId = "";
|
||||||
|
var currentOrigin = "";
|
||||||
|
var currentNextPath = "/monitor";
|
||||||
|
|
||||||
|
function isHubEmbed(kind) {
|
||||||
|
return (kind || "").toLowerCase() === "hub";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleHubLoginBtn(show) {
|
||||||
|
if (btnHubLogin) btnHubLogin.hidden = !show;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyIframeUrl(url) {
|
||||||
|
if (!url) return;
|
||||||
|
frame.src = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hubLoginViaProxy(done) {
|
||||||
|
if (!currentServiceId && !currentOrigin) {
|
||||||
|
if (done) done(false, "未选择中控服务");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var body = { service_id: parseInt(currentServiceId, 10) || undefined, next: currentNextPath };
|
||||||
|
fetch("/api/embed/hub-login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "Accept": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
.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.embed_auth_url) {
|
||||||
|
currentBaseUrl = res.j.embed_auth_url.split("?")[0].replace(/\/embed-auth$/, "") + (currentNextPath || "/monitor");
|
||||||
|
applyIframeUrl(res.j.embed_auth_url);
|
||||||
|
if (done) done(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (done) done(false, (res.j && res.j.detail) || "中控登录失败");
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
if (done) done(false, String(e));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function setActive(el) {
|
function setActive(el) {
|
||||||
links.forEach(function (a) {
|
links.forEach(function (a) {
|
||||||
@@ -178,14 +255,29 @@
|
|||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openService(url, name, preferredNav) {
|
function openService(url, name, preferredNav, meta) {
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
currentBaseUrl = url;
|
meta = meta || {};
|
||||||
|
currentOpenUrl = url;
|
||||||
|
currentBaseUrl = meta.baseUrl || url;
|
||||||
|
currentEmbedKind = meta.embedKind || "";
|
||||||
|
currentServiceId = meta.serviceId || "";
|
||||||
|
currentOrigin = meta.origin || "";
|
||||||
|
currentNextPath = meta.nextPath || "/monitor";
|
||||||
nameEl.textContent = name || "";
|
nameEl.textContent = name || "";
|
||||||
dashboard.hidden = true;
|
dashboard.hidden = true;
|
||||||
frameStack.hidden = false;
|
frameStack.hidden = false;
|
||||||
frame.hidden = false;
|
frame.hidden = false;
|
||||||
frame.src = url;
|
toggleHubLoginBtn(isHubEmbed(currentEmbedKind));
|
||||||
|
if (isHubEmbed(currentEmbedKind) && hubAutoLogin) {
|
||||||
|
hubLoginViaProxy(function (ok, err) {
|
||||||
|
if (!ok) applyIframeUrl(url);
|
||||||
|
});
|
||||||
|
var nav = preferredNav || findNavLink(url);
|
||||||
|
setActive(nav);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyIframeUrl(url);
|
||||||
var nav = preferredNav || findNavLink(url);
|
var nav = preferredNav || findNavLink(url);
|
||||||
setActive(nav);
|
setActive(nav);
|
||||||
}
|
}
|
||||||
@@ -215,13 +307,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function reloadUrl() {
|
function reloadUrl() {
|
||||||
var u = currentBaseUrl;
|
var u = currentOpenUrl || currentBaseUrl;
|
||||||
if (!u) return;
|
if (!u) return;
|
||||||
frame.src = buildCacheBustUrl(u, false);
|
frame.src = buildCacheBustUrl(u, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function forceReloadUrl() {
|
function forceReloadUrl() {
|
||||||
var u = currentBaseUrl;
|
var u = currentOpenUrl || currentBaseUrl;
|
||||||
if (!u) return;
|
if (!u) return;
|
||||||
frame.src = "about:blank";
|
frame.src = "about:blank";
|
||||||
frame.onload = function () {
|
frame.onload = function () {
|
||||||
@@ -232,19 +324,34 @@
|
|||||||
|
|
||||||
function showDashboard() {
|
function showDashboard() {
|
||||||
currentBaseUrl = "";
|
currentBaseUrl = "";
|
||||||
|
currentOpenUrl = "";
|
||||||
|
currentEmbedKind = "";
|
||||||
|
currentServiceId = "";
|
||||||
|
currentOrigin = "";
|
||||||
frame.src = "about:blank";
|
frame.src = "about:blank";
|
||||||
frame.hidden = true;
|
frame.hidden = true;
|
||||||
frameStack.hidden = true;
|
frameStack.hidden = true;
|
||||||
dashboard.hidden = false;
|
dashboard.hidden = false;
|
||||||
|
toggleHubLoginBtn(false);
|
||||||
setActive(null);
|
setActive(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readServiceMeta(el) {
|
||||||
|
return {
|
||||||
|
baseUrl: el.getAttribute("data-base-url") || el.getAttribute("data-url") || "",
|
||||||
|
embedKind: el.getAttribute("data-embed-kind") || "",
|
||||||
|
serviceId: el.getAttribute("data-service-id") || "",
|
||||||
|
origin: el.getAttribute("data-origin") || "",
|
||||||
|
nextPath: el.getAttribute("data-next-path") || "/monitor",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
links.forEach(function (a) {
|
links.forEach(function (a) {
|
||||||
a.addEventListener("click", function (e) {
|
a.addEventListener("click", function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
var url = a.getAttribute("data-url");
|
var url = a.getAttribute("data-url");
|
||||||
var name = a.getAttribute("data-name") || "";
|
var name = a.getAttribute("data-name") || "";
|
||||||
openService(url, name, a);
|
openService(url, name, a, readServiceMeta(a));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -252,10 +359,20 @@
|
|||||||
btn.addEventListener("click", function () {
|
btn.addEventListener("click", function () {
|
||||||
var url = btn.getAttribute("data-url");
|
var url = btn.getAttribute("data-url");
|
||||||
var name = btn.getAttribute("data-name") || "";
|
var name = btn.getAttribute("data-name") || "";
|
||||||
openService(url, name, null);
|
openService(url, name, null, readServiceMeta(btn));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (btnHubLogin) {
|
||||||
|
btnHubLogin.addEventListener("click", function () {
|
||||||
|
btnHubLogin.disabled = true;
|
||||||
|
hubLoginViaProxy(function (ok, err) {
|
||||||
|
btnHubLogin.disabled = false;
|
||||||
|
if (!ok && err) window.alert(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
btnRefresh.addEventListener("click", function () {
|
btnRefresh.addEventListener("click", function () {
|
||||||
reloadUrl();
|
reloadUrl();
|
||||||
});
|
});
|
||||||
|
|||||||
+30
@@ -236,6 +236,36 @@ http://<本机局域网IP>:5070
|
|||||||
|
|
||||||
部分网站(尤其银行、部分管理面板)通过 **`X-Frame-Options`** 或 **`Content-Security-Policy`** 禁止被嵌入 iframe,此时右侧区域可能为空白或浏览器控制台报错。这属于 **目标站点安全策略**,与本导航站实现无关。若必须统一入口,只能由目标服务侧放开嵌入策略,或改为新窗口打开(需改代码,非当前默认行为)。
|
部分网站(尤其银行、部分管理面板)通过 **`X-Frame-Options`** 或 **`Content-Security-Policy`** 禁止被嵌入 iframe,此时右侧区域可能为空白或浏览器控制台报错。这属于 **目标站点安全策略**,与本导航站实现无关。若必须统一入口,只能由目标服务侧放开嵌入策略,或改为新窗口打开(需改代码,非当前默认行为)。
|
||||||
|
|
||||||
|
### 8.6 云端「复盘中控」iframe 嵌入(LocalNav + manual_trading_hub)
|
||||||
|
|
||||||
|
本地导航(如 `http://192.168.x.x:5070`)嵌入 **云端中控**(`https://你的域名:5100`)时,浏览器会把中控 Cookie 视为**跨站第三方**,直接在 iframe 里登录常会「成功但进不去」。
|
||||||
|
|
||||||
|
**本地导航侧(本仓库)**
|
||||||
|
|
||||||
|
1. 「服务管理」→ 编辑中控条目 → **嵌入类型** 选 **「复盘中控」**,协议 HTTPS,路径填 `/monitor`。
|
||||||
|
2. `.env` 配置(与云端中控账号一致):
|
||||||
|
```env
|
||||||
|
NAV_HUB_USERNAME=admin
|
||||||
|
NAV_HUB_PASSWORD=你的中控密码
|
||||||
|
NAV_HUB_AUTO_LOGIN=1
|
||||||
|
```
|
||||||
|
3. 重启 LocalNav。打开中控时会由**本地服务端**代登录,iframe 再打开 `/embed-auth?token=...` 写入会话。
|
||||||
|
4. 也可在内嵌工具栏点 **「中控登录」** 手动触发。
|
||||||
|
|
||||||
|
**云端中控侧(`crypto_monitor/manual_trading_hub`)**
|
||||||
|
|
||||||
|
部署最新代码并重启 hub,`.env` 增加:
|
||||||
|
|
||||||
|
```env
|
||||||
|
HUB_ALLOW_PUBLIC=true
|
||||||
|
HUB_ALLOW_EMBED=true
|
||||||
|
HUB_EMBED_ORIGINS=http://192.168.8.6:5070
|
||||||
|
```
|
||||||
|
|
||||||
|
将 `192.168.8.6:5070` 换成你本机访问 LocalNav 的完整 Origin(含协议与端口)。多台电脑可逗号分隔。
|
||||||
|
|
||||||
|
**四实例(币安/Gate/OKX)**:建议仍从中控内点「打开实例」(SSO 免密);若在 LocalNav 直接嵌实例 URL,需在 iframe 内各自登录,或同样存在跨站 Cookie 限制。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 九、部署指南(以 Ubuntu 为例)
|
## 九、部署指南(以 Ubuntu 为例)
|
||||||
|
|||||||
Reference in New Issue
Block a user