Files
LocalNav/templates/index.html
T
2026-05-30 15:19:38 +08:00

654 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}导航 · 本地导航{% endblock %}
{% block body %}
<div class="app-shell">
<header class="topbar">
<h1>本地导航</h1>
<nav>
<span class="user">{{ current_user.username }}</span>
<a href="{{ url_for('admin_groups') }}">分组管理</a>
<a href="{{ url_for('admin_services') }}">服务管理</a>
<a href="{{ url_for('logout') }}">退出</a>
</nav>
</header>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flash-wrap" style="padding-top: 0.5rem">
{% for cat, msg in messages %}
<div class="flash {{ cat }}">{{ msg }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="layout-main" id="layout-main">
<aside class="sidebar" id="sidebar" aria-label="服务分组导航">
<div class="sidebar-toolbar">
<button
type="button"
class="btn-sidebar-toggle"
id="sidebar-collapse"
title="收起侧栏"
aria-label="收起侧栏"
>
</button>
</div>
{% for group, services in grouped %}
<div class="sidebar-section">
<h2>{{ group.name }}</h2>
{% for svc in services %}
<a
href="#"
class="nav-link"
role="button"
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 }}"
>{{ svc.name }}</a
>
{% else %}
<div class="hint" style="padding: 0 1rem">该分组下暂无服务</div>
{% endfor %}
</div>
{% else %}
<div class="hint" style="padding: 1rem">
暂无分组与服务,请到「分组管理」「服务管理」添加。
</div>
{% endfor %}
</aside>
<button
type="button"
class="btn-sidebar-expand"
id="sidebar-expand"
title="展开侧栏"
aria-label="展开侧栏"
hidden
>
</button>
<div class="content-column">
<div class="service-dashboard" id="service-dashboard">
{% for group, services in grouped %}
<section class="dash-section">
<h3 class="dash-section-title">{{ group.name }}</h3>
<div class="service-card-grid">
{% for svc in services %}
<button
type="button"
class="service-card"
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 }}"
>
<span class="service-card-title">{{ svc.name }}</span>
<span class="service-card-group">{{ group.name }}</span>
</button>
{% endfor %}
</div>
</section>
{% else %}
<div class="dash-empty hint">
暂无分组与服务,请到「分组管理」「服务管理」添加。
</div>
{% endfor %}
</div>
<div class="frame-stack" id="frame-stack" hidden>
<div class="frame-toolbar">
<div class="frame-toolbar-left">
<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">
<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-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>
<button
type="button"
class="btn btn-secondary btn-toolbar-refresh"
id="frame-force-refresh"
title="强制刷新(等同 Ctrl+F5,跳过缓存)"
>
强制刷新
</button>
</div>
</div>
<div class="frame-wrap">
<iframe id="svc-frame" name="svc-frame" title="内嵌服务" hidden></iframe>
</div>
</div>
</div>
</div>
</div>
<script>
(function () {
var hubAutoLogin = {{ 'true' if hub_auto_login else 'false' }};
var layoutMain = document.getElementById("layout-main");
var btnSidebarCollapse = document.getElementById("sidebar-collapse");
var btnSidebarExpand = document.getElementById("sidebar-expand");
var sidebarCollapsedKey = "localnav_sidebar_collapsed";
function setSidebarCollapsed(collapsed) {
if (!layoutMain) return;
layoutMain.classList.toggle("sidebar-collapsed", collapsed);
if (btnSidebarExpand) btnSidebarExpand.hidden = !collapsed;
}
if (layoutMain && btnSidebarCollapse && btnSidebarExpand) {
try {
setSidebarCollapsed(localStorage.getItem(sidebarCollapsedKey) === "1");
} catch (e) {
setSidebarCollapsed(false);
}
btnSidebarCollapse.addEventListener("click", function () {
setSidebarCollapsed(true);
try {
localStorage.setItem(sidebarCollapsedKey, "1");
} catch (e) {}
});
btnSidebarExpand.addEventListener("click", function () {
setSidebarCollapsed(false);
try {
localStorage.setItem(sidebarCollapsedKey, "0");
} catch (e) {}
});
}
var frame = document.getElementById("svc-frame");
var dashboard = document.getElementById("service-dashboard");
var frameStack = document.getElementById("frame-stack");
var nameEl = document.getElementById("current-service-name");
var links = document.querySelectorAll(".nav-link[data-url]");
var cards = document.querySelectorAll(".service-card[data-url]");
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;
}
function applyIframeUrl(url) {
if (!url) return;
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, "未选择中控服务");
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) 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");
});
if (el) el.classList.add("active");
}
function findNavLink(url) {
var found = null;
links.forEach(function (a) {
if (a.getAttribute("data-url") === url) found = a;
});
return found;
}
function openService(url, name, preferredNav, meta) {
if (!url) return;
meta = meta || {};
currentOpenUrl = url;
currentBaseUrl = meta.baseUrl || url;
currentEmbedKind = meta.embedKind || "";
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;
frame.hidden = false;
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);
setActive(nav);
}
function buildCacheBustUrl(u, hard) {
var sep = u.indexOf("?") >= 0 ? "&" : "?";
var hash = "";
var base = u;
var hashPos = u.indexOf("#");
if (hashPos >= 0) {
base = u.slice(0, hashPos);
hash = u.slice(hashPos);
}
var ts = Date.now();
if (hard) {
return (
base +
sep +
"_navts=" +
ts +
"&_navnocache=" +
Math.random().toString(36).slice(2) +
hash
);
}
return base + sep + "_navts=" + ts + hash;
}
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";
frame.onload = function () {
frame.onload = null;
frame.src = buildCacheBustUrl(u, true);
};
}
function showDashboard() {
currentBaseUrl = "";
currentOpenUrl = "";
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);
}
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) {
a.addEventListener("click", function (e) {
e.preventDefault();
var url = a.getAttribute("data-url");
var name = a.getAttribute("data-name") || "";
openService(url, name, a, readServiceMeta(a));
});
});
cards.forEach(function (btn) {
btn.addEventListener("click", function () {
var url = btn.getAttribute("data-url");
var name = btn.getAttribute("data-name") || "";
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("中控登录失败:\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);
});
});
}
btnRefresh.addEventListener("click", function () {
reloadUrl();
});
btnForceRefresh.addEventListener("click", function () {
forceReloadUrl();
});
btnBack.addEventListener("click", function () {
showDashboard();
});
if (btnBackHub) {
btnBackHub.addEventListener("click", function () {
returnToHubMonitor();
});
}
})();
</script>
{% endblock %}