中控
This commit is contained in:
@@ -30,6 +30,9 @@ APP_AUTH_DISABLED=true
|
|||||||
# 中控请求本实例 /api/hub/* 时携带请求头 X-Hub-Token,须与中控启动环境变量 HUB_BRIDGE_TOKEN 一致
|
# 中控请求本实例 /api/hub/* 时携带请求头 X-Hub-Token,须与中控启动环境变量 HUB_BRIDGE_TOKEN 一致
|
||||||
# 未设置且 APP_AUTH_DISABLED=false 时,仅网页登录后可访问;本机联调可保持 APP_AUTH_DISABLED=true
|
# 未设置且 APP_AUTH_DISABLED=false 时,仅网页登录后可访问;本机联调可保持 APP_AUTH_DISABLED=true
|
||||||
# HUB_BRIDGE_TOKEN=your-long-random-token
|
# HUB_BRIDGE_TOKEN=your-long-random-token
|
||||||
|
# 允许复盘中控 iframe 内嵌本实例(与 hub 域名一致;默认已开启)
|
||||||
|
# APP_ALLOW_HUB_EMBED=true
|
||||||
|
# HUB_EMBED_PARENT_ORIGINS=https://hub.example.com
|
||||||
# Flask 会话密钥(必须替换为长随机字符串)
|
# Flask 会话密钥(必须替换为长随机字符串)
|
||||||
FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET
|
FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET
|
||||||
|
|
||||||
|
|||||||
@@ -108,9 +108,38 @@ def install_on_app(
|
|||||||
"meta_fn": meta_fn,
|
"meta_fn": meta_fn,
|
||||||
"views": views,
|
"views": views,
|
||||||
}
|
}
|
||||||
|
install_hub_embed_headers(app)
|
||||||
register_hub_routes(app)
|
register_hub_routes(app)
|
||||||
|
|
||||||
|
|
||||||
|
def install_hub_embed_headers(app):
|
||||||
|
"""允许复盘中控 iframe 内嵌打开本实例(须与 hub 的 HUB_EMBED_ORIGINS 或域名一致)。"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
allowed = (os.getenv("APP_ALLOW_HUB_EMBED") or "true").strip().lower() in (
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
"on",
|
||||||
|
)
|
||||||
|
if not allowed:
|
||||||
|
return
|
||||||
|
origins = (
|
||||||
|
(os.getenv("HUB_EMBED_PARENT_ORIGINS") or os.getenv("HUB_EMBED_ORIGINS") or "*")
|
||||||
|
.strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def _hub_embed_frame_headers(response):
|
||||||
|
if origins == "*":
|
||||||
|
response.headers["Content-Security-Policy"] = "frame-ancestors *"
|
||||||
|
else:
|
||||||
|
response.headers["Content-Security-Policy"] = (
|
||||||
|
f"frame-ancestors 'self' {origins}"
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def register_hub_routes(app):
|
def register_hub_routes(app):
|
||||||
auth_disabled = False
|
auth_disabled = False
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -39,7 +39,11 @@ HUB_TRUST_LAN=true
|
|||||||
# 本地导航 / 门户 iframe 嵌入中控(默认 true)
|
# 本地导航 / 门户 iframe 嵌入中控(默认 true)
|
||||||
# HUB_ALLOW_EMBED=true
|
# HUB_ALLOW_EMBED=true
|
||||||
# 限制可嵌入的父页来源(逗号分隔);默认 * 不限制
|
# 限制可嵌入的父页来源(逗号分隔);默认 * 不限制
|
||||||
# HUB_EMBED_ORIGINS=http://192.168.8.6:5070
|
# HUB_EMBED_ORIGINS=http://192.168.8.6:5070,https://hub.example.com
|
||||||
|
|
||||||
|
# 四实例允许被中控 iframe 内嵌(各 crypto_monitor_*/.env,与 hub 同步部署)
|
||||||
|
# APP_ALLOW_HUB_EMBED=true
|
||||||
|
# HUB_EMBED_PARENT_ORIGINS=https://hub.example.com
|
||||||
|
|
||||||
# 浏览器打开的实例/复盘链接(hub_settings 里 flask_url 为 127.0.0.1 时替换为对外地址)
|
# 浏览器打开的实例/复盘链接(hub_settings 里 flask_url 为 127.0.0.1 时替换为对外地址)
|
||||||
# 局域网:填内网 IP,见《局域网与反代部署说明.md》
|
# 局域网:填内网 IP,见《局域网与反代部署说明.md》
|
||||||
|
|||||||
@@ -505,6 +505,55 @@ body.hub-fullscreen-open {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.hub-instance-frame-open {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-frame-shell {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg, #0a0e14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-frame-shell.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-frame-toolbar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-soft, #2a3150);
|
||||||
|
background: rgba(10, 14, 20, 0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-frame-title {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text, #dbe4ff);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-frame-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-frame {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
background: #0f1216;
|
||||||
|
}
|
||||||
|
|
||||||
.exchange-fullscreen {
|
.exchange-fullscreen {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -17,22 +17,65 @@
|
|||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openInstanceInBrowser(exchangeId, nextPath) {
|
let instanceFrameUrl = "";
|
||||||
|
|
||||||
|
async function openInstance(exchangeId, nextPath, opts) {
|
||||||
|
const options = opts || {};
|
||||||
|
const newTab = !!options.newTab;
|
||||||
const next = nextPath || "/";
|
const next = nextPath || "/";
|
||||||
try {
|
try {
|
||||||
const q = new URLSearchParams({ exchange_id: String(exchangeId), next });
|
const q = new URLSearchParams({ exchange_id: String(exchangeId), next });
|
||||||
const r = await apiFetch("/api/instance/open-url?" + q.toString());
|
const r = await apiFetch("/api/instance/open-url?" + q.toString());
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
if (j.ok && j.url) {
|
if (!j.ok || !j.url) {
|
||||||
|
showToast(j.detail || "无法生成打开链接", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newTab) {
|
||||||
window.open(j.url, "_blank", "noopener");
|
window.open(j.url, "_blank", "noopener");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showToast(j.detail || "无法生成打开链接", true);
|
const row = lastMonitorRows.find((x) => String(x.id) === String(exchangeId));
|
||||||
|
openInstanceFrame(j.url, row ? row.name : exchangeId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(String(e), true);
|
showToast(String(e), true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openInstanceFrame(url, title) {
|
||||||
|
const shell = document.getElementById("instance-frame-shell");
|
||||||
|
const frame = document.getElementById("instance-frame");
|
||||||
|
const titleEl = document.getElementById("instance-frame-title");
|
||||||
|
if (!shell || !frame) {
|
||||||
|
window.open(url, "_blank", "noopener");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeExchangeFullscreen();
|
||||||
|
instanceFrameUrl = url;
|
||||||
|
if (titleEl) titleEl.textContent = title || "实例";
|
||||||
|
frame.src = url;
|
||||||
|
shell.classList.remove("hidden");
|
||||||
|
shell.setAttribute("aria-hidden", "false");
|
||||||
|
document.body.classList.add("hub-instance-frame-open");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeInstanceFrame() {
|
||||||
|
const shell = document.getElementById("instance-frame-shell");
|
||||||
|
const frame = document.getElementById("instance-frame");
|
||||||
|
instanceFrameUrl = "";
|
||||||
|
if (frame) frame.src = "about:blank";
|
||||||
|
if (shell) {
|
||||||
|
shell.classList.add("hidden");
|
||||||
|
shell.setAttribute("aria-hidden", "true");
|
||||||
|
}
|
||||||
|
document.body.classList.remove("hub-instance-frame-open");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated use openInstance */
|
||||||
|
async function openInstanceInBrowser(exchangeId, nextPath) {
|
||||||
|
return openInstance(exchangeId, nextPath, { newTab: false });
|
||||||
|
}
|
||||||
|
|
||||||
async function initAuth() {
|
async function initAuth() {
|
||||||
try {
|
try {
|
||||||
const r = await fetch("/api/auth/status");
|
const r = await fetch("/api/auth/status");
|
||||||
@@ -379,7 +422,9 @@
|
|||||||
btn.onclick = (ev) => {
|
btn.onclick = (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
openInstanceInBrowser(btn.dataset.exId, btn.dataset.next || "/");
|
openInstance(btn.dataset.exId, btn.dataset.next || "/", {
|
||||||
|
newTab: ev.ctrlKey || ev.metaKey,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
box.querySelectorAll(".btn-close-ex").forEach((btn) => {
|
box.querySelectorAll(".btn-close-ex").forEach((btn) => {
|
||||||
@@ -928,6 +973,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initInstanceFrame() {
|
||||||
|
const back = document.getElementById("instance-frame-back");
|
||||||
|
const refresh = document.getElementById("instance-frame-refresh");
|
||||||
|
const newTab = document.getElementById("instance-frame-newtab");
|
||||||
|
const frame = document.getElementById("instance-frame");
|
||||||
|
if (back) back.onclick = () => closeInstanceFrame();
|
||||||
|
if (refresh && frame) {
|
||||||
|
refresh.onclick = () => {
|
||||||
|
if (instanceFrameUrl) frame.src = instanceFrameUrl;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (newTab) {
|
||||||
|
newTab.onclick = () => {
|
||||||
|
if (instanceFrameUrl) window.open(instanceFrameUrl, "_blank", "noopener");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function initFullscreen() {
|
function initFullscreen() {
|
||||||
const backdrop = document.getElementById("exchange-fullscreen-backdrop");
|
const backdrop = document.getElementById("exchange-fullscreen-backdrop");
|
||||||
if (backdrop) {
|
if (backdrop) {
|
||||||
@@ -953,6 +1016,11 @@
|
|||||||
document.addEventListener("keydown", (ev) => {
|
document.addEventListener("keydown", (ev) => {
|
||||||
if (ev.key === "Escape") {
|
if (ev.key === "Escape") {
|
||||||
closeTpslModal();
|
closeTpslModal();
|
||||||
|
const shell = document.getElementById("instance-frame-shell");
|
||||||
|
if (shell && !shell.classList.contains("hidden")) {
|
||||||
|
closeInstanceFrame();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (expandedExchangeId) {
|
if (expandedExchangeId) {
|
||||||
closeExchangeFullscreen();
|
closeExchangeFullscreen();
|
||||||
renderMonitorGrid(lastMonitorRows);
|
renderMonitorGrid(lastMonitorRows);
|
||||||
@@ -1261,6 +1329,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
initTpslModal();
|
initTpslModal();
|
||||||
|
initInstanceFrame();
|
||||||
initFullscreen();
|
initFullscreen();
|
||||||
initMobileLayout();
|
initMobileLayout();
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
|
||||||
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
|
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
|
||||||
<link rel="stylesheet" href="/assets/app.css?v=20260526-hub-key3col" />
|
<link rel="stylesheet" href="/assets/app.css?v=20260530-hub-iframe" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-bg" aria-hidden="true"></div>
|
<div class="app-bg" aria-hidden="true"></div>
|
||||||
@@ -41,8 +41,7 @@
|
|||||||
<div class="hint-body">
|
<div class="hint-body">
|
||||||
持仓与余额来自子代理;关键位、机器人单、趋势计划来自各实例 Flask(须 PM2 运行 crypto_*)。<br />
|
持仓与余额来自子代理;关键位、机器人单、趋势计划来自各实例 Flask(须 PM2 运行 crypto_*)。<br />
|
||||||
人工下单、添加关键位、趋势回调请在各实例网页操作;中控可监控、单仓平仓与账户全平。<br />
|
人工下单、添加关键位、趋势回调请在各实例网页操作;中控可监控、单仓平仓与账户全平。<br />
|
||||||
「交易复盘」在新标签打开该实例 /records。其它电脑访问中控时,请在 hub 的 <code>.env</code> 设置
|
点「实例 / 策略交易 / 复盘」在<strong>本页内嵌</strong>打开(SSO 免密);按住 Ctrl 点击可在新标签打开。
|
||||||
<code>HUB_PUBLIC_ORIGIN=http://服务器内网IP</code>。
|
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
@@ -57,6 +56,18 @@
|
|||||||
<div id="monitor-grid" class="grid-monitor"></div>
|
<div id="monitor-grid" class="grid-monitor"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="instance-frame-shell" class="instance-frame-shell hidden" aria-hidden="true">
|
||||||
|
<div class="instance-frame-toolbar">
|
||||||
|
<button type="button" id="instance-frame-back" class="ghost">← 返回监控</button>
|
||||||
|
<span id="instance-frame-title" class="instance-frame-title"></span>
|
||||||
|
<div class="instance-frame-actions">
|
||||||
|
<button type="button" id="instance-frame-refresh" class="ghost">刷新</button>
|
||||||
|
<button type="button" id="instance-frame-newtab" class="ghost">新标签打开</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<iframe id="instance-frame" class="instance-frame" title="交易所实例"></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="exchange-fullscreen" class="exchange-fullscreen hidden" aria-hidden="true">
|
<div id="exchange-fullscreen" class="exchange-fullscreen hidden" aria-hidden="true">
|
||||||
<button type="button" id="exchange-fullscreen-backdrop" class="exchange-fullscreen-backdrop" aria-label="关闭全屏"></button>
|
<button type="button" id="exchange-fullscreen-backdrop" class="exchange-fullscreen-backdrop" aria-label="关闭全屏"></button>
|
||||||
<div class="exchange-fullscreen-panel">
|
<div class="exchange-fullscreen-panel">
|
||||||
@@ -109,6 +120,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="toast"></div>
|
<div id="toast"></div>
|
||||||
<script src="/assets/app.js?v=20260526-hub-pnl2"></script>
|
<script src="/assets/app.js?v=20260530-hub-iframe"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user