refactor: 将共用代码迁入 lib/ 模块化目录
统一 strategy、key_monitor、trade、hub 等共用库到 lib/ 子包,并补充 lib-structure 文档,便于四所与中控维护。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,572 @@
|
||||
/**
|
||||
* 四所实例主题:默认暗色;单独登录用 instance-theme;中控 iframe/SSO 随 hub-theme 联动。
|
||||
*/
|
||||
(function (global) {
|
||||
const STANDALONE_KEY = "instance-theme";
|
||||
const HUB_LINKED_THEME_KEY = "hub-linked-theme";
|
||||
const META = { dark: "#0b0d14", light: "#d8e2ec" };
|
||||
|
||||
function normalize(theme) {
|
||||
return theme === "light" ? "light" : "dark";
|
||||
}
|
||||
|
||||
function isHubLinked() {
|
||||
try {
|
||||
const p = new URLSearchParams(location.search);
|
||||
if (p.get("embed") === "1") return true;
|
||||
const ht = p.get("hub_theme");
|
||||
if (ht === "light" || ht === "dark") return true;
|
||||
} catch (_) {}
|
||||
try {
|
||||
if (window.self !== window.top) return true;
|
||||
} catch (_) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function themeFromUrl() {
|
||||
try {
|
||||
const t = new URLSearchParams(location.search).get("hub_theme");
|
||||
if (t === "light" || t === "dark") return t;
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readLinkedThemeStorage() {
|
||||
try {
|
||||
const t = sessionStorage.getItem(HUB_LINKED_THEME_KEY);
|
||||
if (t === "light" || t === "dark") return t;
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function writeLinkedThemeStorage(theme) {
|
||||
if (!isHubLinked()) return;
|
||||
try {
|
||||
sessionStorage.setItem(HUB_LINKED_THEME_KEY, normalize(theme));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function getStandalone() {
|
||||
try {
|
||||
return normalize(localStorage.getItem(STANDALONE_KEY));
|
||||
} catch (_) {
|
||||
return "dark";
|
||||
}
|
||||
}
|
||||
|
||||
function setStandalone(theme) {
|
||||
try {
|
||||
localStorage.setItem(STANDALONE_KEY, normalize(theme));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
let _linkedTheme = null;
|
||||
let _appliedTheme = null;
|
||||
|
||||
function get() {
|
||||
if (isHubLinked()) {
|
||||
return themeFromUrl() || _linkedTheme || readLinkedThemeStorage() || "dark";
|
||||
}
|
||||
return getStandalone();
|
||||
}
|
||||
|
||||
/** 模板内联暗色 → 亮色(切换时重写 style 属性) */
|
||||
const INLINE_HEX_LIGHT = {
|
||||
"#cfd3ef": "#1a2838",
|
||||
"#8892b0": "#4a6078",
|
||||
"#9aa3c4": "#4a6078",
|
||||
"#8b95a8": "#4a6078",
|
||||
"#8b95b8": "#4a6078",
|
||||
"#6a7598": "#4a6078",
|
||||
"#7d8799": "#4a6078",
|
||||
"#6d7689": "#4a6078",
|
||||
"#dbe4ff": "#142232",
|
||||
"#f0f2ff": "#142232",
|
||||
"#e8ecf4": "#142232",
|
||||
"#c5cce0": "#4a6078",
|
||||
"#b8c4ff": "#142232",
|
||||
"#8fc8ff": "#006e9a",
|
||||
"#6ab8ff": "#006e9a",
|
||||
"#6eb5ff": "#006e9a",
|
||||
"#101522": "#ffffff",
|
||||
"#121726": "#ffffff",
|
||||
"#141423": "#ffffff",
|
||||
"#24243b": "#b8c8d8",
|
||||
"#252a45": "#b8c8d8",
|
||||
"#252538": "#eef3f8",
|
||||
"#1a1a29": "#f6f9fc",
|
||||
"#2e2e45": "#b8c8d8",
|
||||
"#2b2b43": "#d0dae4",
|
||||
"#151a2a": "#eef3f8",
|
||||
"#141a2a": "#ffffff",
|
||||
"#141923": "#ffffff",
|
||||
"#141a2e": "#ffffff",
|
||||
"#0f1424": "#f6f9fc",
|
||||
"#0f1420": "#f6f9fc",
|
||||
"#0f1117": "#d8e2ec",
|
||||
"#1a2034": "#eef3f8",
|
||||
"#1a2030": "#ffffff",
|
||||
"#1f3a5a": "#e8eef5",
|
||||
"#2f2f44": "#dde5ec",
|
||||
"#2a3f6c": "rgba(0,110,154,0.14)",
|
||||
"#304164": "rgba(0,95,140,0.22)",
|
||||
"#2a3150": "#b8c8d8",
|
||||
"#2a3152": "#b8c8d8",
|
||||
"#3a5a8a": "rgba(0,95,140,0.35)",
|
||||
"#2a3348": "#b8c8d8",
|
||||
"#243050": "rgba(0,75,115,0.16)",
|
||||
"#2a3558": "#d0dae4",
|
||||
"#3a4468": "#c8d4e0",
|
||||
"#3a4a66": "#b8c8d8",
|
||||
"#3a3f52": "#dde5ec",
|
||||
"#3d4659": "#b8c8d8",
|
||||
"#1f2740": "#eef3f8",
|
||||
"#1f2a44": "rgba(0,110,154,0.1)",
|
||||
"#1f4a3a": "#e8f5ef",
|
||||
"#2a4a7a": "#e8eef5",
|
||||
"#3a3048": "#eef3f8",
|
||||
"#d4b8ff": "#5b4fc7",
|
||||
"#e6e8ef": "#1a2838",
|
||||
};
|
||||
|
||||
function remapInlineStyle(style, theme) {
|
||||
if (!style) return style;
|
||||
if (theme !== "light") return style;
|
||||
const hadSecondaryBtnBg = /#1f3a5a/i.test(style);
|
||||
let out = style;
|
||||
for (const [from, to] of Object.entries(INLINE_HEX_LIGHT)) {
|
||||
out = out.replace(new RegExp(from.replace("#", "\\#"), "gi"), to);
|
||||
}
|
||||
if (hadSecondaryBtnBg && !/color\s*:/i.test(style)) {
|
||||
out = `${out.replace(/;+\s*$/, "")};color:#006e9a`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function syncInlineStyles(theme, root) {
|
||||
const scope = root || document;
|
||||
scope.querySelectorAll("[style]").forEach((el) => {
|
||||
const raw = el.getAttribute("style");
|
||||
if (!raw) return;
|
||||
if (!el.dataset.instStyleBase) {
|
||||
el.dataset.instStyleBase = raw;
|
||||
}
|
||||
const base = el.dataset.instStyleBase;
|
||||
el.setAttribute("style", theme === "light" ? remapInlineStyle(base, "light") : base);
|
||||
});
|
||||
}
|
||||
|
||||
function mergeHubQueryIntoHref(href, theme) {
|
||||
if (!href || href.startsWith("#") || href.startsWith("javascript:")) return href;
|
||||
try {
|
||||
const u = new URL(href, location.origin);
|
||||
if (u.origin !== location.origin) return href;
|
||||
if (isHubLinked()) {
|
||||
u.searchParams.set("embed", "1");
|
||||
if (theme === "light" || theme === "dark") {
|
||||
u.searchParams.set("hub_theme", theme);
|
||||
}
|
||||
}
|
||||
return u.pathname + u.search + u.hash;
|
||||
} catch (_) {
|
||||
return href;
|
||||
}
|
||||
}
|
||||
|
||||
function patchHubNavLinks(theme) {
|
||||
if (!isHubLinked()) return;
|
||||
const t = normalize(theme || get());
|
||||
document
|
||||
.querySelectorAll(".top-nav a[href], .strategy-subnav a[href]")
|
||||
.forEach((a) => {
|
||||
const href = a.getAttribute("href");
|
||||
if (!href) return;
|
||||
const next = mergeHubQueryIntoHref(href, t);
|
||||
if (next !== href) a.setAttribute("href", next);
|
||||
});
|
||||
}
|
||||
|
||||
function apply(theme, opts) {
|
||||
const options = opts || {};
|
||||
const linked = isHubLinked();
|
||||
const t = normalize(theme);
|
||||
const root = document.documentElement;
|
||||
const unchanged =
|
||||
!options.force &&
|
||||
_appliedTheme === t &&
|
||||
root.getAttribute("data-theme") === t;
|
||||
if (unchanged) {
|
||||
return t;
|
||||
}
|
||||
_appliedTheme = t;
|
||||
if (linked) {
|
||||
_linkedTheme = t;
|
||||
writeLinkedThemeStorage(t);
|
||||
root.setAttribute("data-hub-linked", "1");
|
||||
} else {
|
||||
root.removeAttribute("data-hub-linked");
|
||||
}
|
||||
if (!linked && !options.skipStore) {
|
||||
setStandalone(t);
|
||||
}
|
||||
root.setAttribute("data-theme", t);
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta) meta.setAttribute("content", META[t]);
|
||||
root.style.colorScheme = t;
|
||||
if (document.body) {
|
||||
syncInlineStyles(t);
|
||||
patchHubNavLinks(t);
|
||||
} else {
|
||||
document.addEventListener(
|
||||
"DOMContentLoaded",
|
||||
function onDom() {
|
||||
syncInlineStyles(t);
|
||||
patchHubNavLinks(t);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
syncToggleUI();
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("instance-theme-change", { detail: { theme: t, hubLinked: linked } })
|
||||
);
|
||||
return t;
|
||||
}
|
||||
|
||||
function syncToggleUI(root) {
|
||||
const scope = root || document;
|
||||
const linked = isHubLinked();
|
||||
const toggle = scope.querySelector(".instance-theme-toggle");
|
||||
if (toggle) {
|
||||
toggle.classList.toggle("is-hub-linked", linked);
|
||||
toggle.setAttribute("aria-hidden", linked ? "true" : "false");
|
||||
}
|
||||
if (linked) return;
|
||||
scope.querySelectorAll(".theme-toggle-btn[data-theme-value]").forEach((btn) => {
|
||||
const on = btn.getAttribute("data-theme-value") === getStandalone();
|
||||
btn.classList.toggle("is-active", on);
|
||||
btn.setAttribute("aria-pressed", on ? "true" : "false");
|
||||
});
|
||||
}
|
||||
|
||||
function initToggleUI(root) {
|
||||
const scope = root || document;
|
||||
syncToggleUI(scope);
|
||||
scope.querySelectorAll(".theme-toggle-btn[data-theme-value]").forEach((btn) => {
|
||||
if (btn.dataset.themeBound === "1") return;
|
||||
btn.dataset.themeBound = "1";
|
||||
btn.addEventListener("click", () => {
|
||||
if (isHubLinked()) return;
|
||||
apply(btn.getAttribute("data-theme-value"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initMobileTopNav() {
|
||||
const mq = window.matchMedia("(max-width: 720px)");
|
||||
|
||||
function scrollActiveTab(nav) {
|
||||
const active = nav.querySelector("a.active");
|
||||
if (!active) return;
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
active.scrollIntoView({ inline: "center", block: "nearest", behavior: "instant" });
|
||||
} catch (_) {
|
||||
active.scrollIntoView(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function apply() {
|
||||
if (!mq.matches) return;
|
||||
document.querySelectorAll(".top-nav").forEach(scrollActiveTab);
|
||||
}
|
||||
|
||||
apply();
|
||||
mq.addEventListener("change", apply);
|
||||
window.addEventListener("resize", apply);
|
||||
window.addEventListener("orientationchange", apply);
|
||||
}
|
||||
|
||||
function initFromHubMessage(data) {
|
||||
if (!data || data.type !== "hub-theme-sync") return;
|
||||
if (!isHubLinked()) return;
|
||||
apply(data.theme, { skipStore: true });
|
||||
}
|
||||
|
||||
/** 交易记录页:核对开关与按钮 disabled 保持同步(iframe 软导航/表单恢复后不触发 change) */
|
||||
function syncReviewEditButtons() {
|
||||
const toggle = document.getElementById("review-mode-toggle");
|
||||
if (!toggle) return;
|
||||
const on = !!toggle.checked;
|
||||
document.querySelectorAll(".review-edit-btn").forEach((btn) => {
|
||||
btn.disabled = !on;
|
||||
});
|
||||
}
|
||||
|
||||
function initReviewEditModeSync() {
|
||||
const toggle = document.getElementById("review-mode-toggle");
|
||||
if (!toggle) return;
|
||||
if (toggle.dataset.instReviewModeBound !== "1") {
|
||||
toggle.dataset.instReviewModeBound = "1";
|
||||
toggle.addEventListener("input", () => {
|
||||
if (typeof global.toggleReviewMode === "function") global.toggleReviewMode();
|
||||
else syncReviewEditButtons();
|
||||
});
|
||||
}
|
||||
const run = () => {
|
||||
if (typeof global.toggleReviewMode === "function") global.toggleReviewMode();
|
||||
else syncReviewEditButtons();
|
||||
};
|
||||
run();
|
||||
requestAnimationFrame(run);
|
||||
setTimeout(run, 0);
|
||||
if (!global.__instReviewModePageshowBound) {
|
||||
global.__instReviewModePageshowBound = true;
|
||||
window.addEventListener("pageshow", run);
|
||||
}
|
||||
}
|
||||
|
||||
function notifyParentFrameNavStart() {
|
||||
if (!isHubLinked()) return;
|
||||
try {
|
||||
window.parent.postMessage({ type: "instance-frame-navigating", theme: get() }, "*");
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function notifyParentFrameReady() {
|
||||
if (!isHubLinked()) return;
|
||||
dismissNavOverlay();
|
||||
try {
|
||||
window.parent.postMessage({ type: "instance-frame-ready", theme: get() }, "*");
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function ensureNavOverlay() {
|
||||
const t = normalize(get());
|
||||
const bg = META[t];
|
||||
let el = document.getElementById("inst-nav-overlay");
|
||||
if (!el) {
|
||||
el = document.createElement("div");
|
||||
el.id = "inst-nav-overlay";
|
||||
el.setAttribute("aria-hidden", "true");
|
||||
(document.body || document.documentElement).appendChild(el);
|
||||
}
|
||||
el.style.cssText =
|
||||
"position:fixed;inset:0;z-index:2147483646;background:" +
|
||||
bg +
|
||||
";opacity:1;pointer-events:auto;transition:opacity 80ms ease;";
|
||||
return el;
|
||||
}
|
||||
|
||||
function dismissNavOverlay() {
|
||||
const el = document.getElementById("inst-nav-overlay");
|
||||
if (!el) return;
|
||||
el.style.opacity = "0";
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
el.remove();
|
||||
} catch (_) {}
|
||||
}, 90);
|
||||
}
|
||||
|
||||
function injectNavOverlayIntoHtml(html, theme) {
|
||||
const t = normalize(theme || get());
|
||||
const bg = META[t];
|
||||
let out = html || "";
|
||||
const guard =
|
||||
'<style id="inst-nav-guard">html,body{background:' +
|
||||
bg +
|
||||
"!important;color-scheme:" +
|
||||
t +
|
||||
';}</style>';
|
||||
if (out.includes("</head>")) {
|
||||
out = out.replace("</head>", guard + "</head>");
|
||||
} else {
|
||||
out = guard + out;
|
||||
}
|
||||
out = out.replace(/<html([^>]*)>/i, (m, attrs) => {
|
||||
if (/data-theme=/i.test(attrs)) {
|
||||
return m.replace(/data-theme="[^"]*"/i, 'data-theme="' + t + '"');
|
||||
}
|
||||
return "<html" + attrs + ' data-theme="' + t + '">';
|
||||
});
|
||||
const overlay =
|
||||
'<div id="inst-nav-overlay" aria-hidden="true" style="position:fixed;inset:0;z-index:2147483646;background:' +
|
||||
bg +
|
||||
';opacity:1;pointer-events:auto"></div>';
|
||||
if (/<body[^>]*>/i.test(out)) {
|
||||
out = out.replace(/<body([^>]*)>/i, "<body$1>" + overlay);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 中控 iframe:fetch 换页 + 页内遮罩,避免整页卸载与中控侧长时间空白。 */
|
||||
function initHubEmbedInFrameNav() {
|
||||
if (!isHubLinked()) return;
|
||||
if (document.body && document.body.getAttribute("data-embed-shell") === "1") return;
|
||||
|
||||
let navToken = 0;
|
||||
|
||||
function isSoftNavLink(a) {
|
||||
if (!a || !a.getAttribute) return false;
|
||||
if (a.hasAttribute("download") || a.target === "_blank") return false;
|
||||
return !!a.closest(".top-nav, .strategy-subnav");
|
||||
}
|
||||
|
||||
function softNavFetch(href) {
|
||||
return fetch(href, {
|
||||
credentials: "same-origin",
|
||||
headers: { "X-Instance-Soft-Nav": "1" },
|
||||
});
|
||||
}
|
||||
|
||||
async function navigateInFrame(href, opts) {
|
||||
const token = ++navToken;
|
||||
notifyParentFrameNavStart();
|
||||
ensureNavOverlay();
|
||||
try {
|
||||
const r = await softNavFetch(href);
|
||||
if (token !== navToken) return;
|
||||
if (!r.ok) {
|
||||
location.assign(href);
|
||||
return;
|
||||
}
|
||||
let html = await r.text();
|
||||
if (token !== navToken) return;
|
||||
html = injectNavOverlayIntoHtml(html, get());
|
||||
let path = href;
|
||||
try {
|
||||
const u = new URL(href, location.href);
|
||||
path = u.pathname + u.search + u.hash;
|
||||
} catch (_) {}
|
||||
if (opts && opts.replace) history.replaceState(null, "", path);
|
||||
else history.pushState(null, "", path);
|
||||
document.open();
|
||||
document.write(html);
|
||||
document.close();
|
||||
} catch (_) {
|
||||
if (token === navToken) location.assign(href);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(ev) => {
|
||||
const a = ev.target.closest("a[href]");
|
||||
if (!a || !isSoftNavLink(a) || ev.defaultPrevented) return;
|
||||
if (ev.button !== 0 || ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return;
|
||||
const rawHref = a.getAttribute("href");
|
||||
if (!rawHref || rawHref.startsWith("#") || rawHref.startsWith("javascript:")) return;
|
||||
let target;
|
||||
try {
|
||||
target = new URL(rawHref, location.href);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (target.origin !== location.origin) return;
|
||||
const nextHref = target.pathname + target.search + target.hash;
|
||||
if (target.pathname === location.pathname && target.search === location.search) return;
|
||||
ev.preventDefault();
|
||||
void navigateInFrame(nextHref);
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
window.addEventListener("popstate", () => {
|
||||
void navigateInFrame(location.pathname + location.search + location.hash, { replace: true });
|
||||
});
|
||||
}
|
||||
|
||||
function purgeLegacySoftNavCache() {
|
||||
try {
|
||||
for (let i = localStorage.length - 1; i >= 0; i -= 1) {
|
||||
const key = localStorage.key(i);
|
||||
if (!key) continue;
|
||||
if (
|
||||
key.startsWith("inst-pc:") ||
|
||||
key === "inst-page-cache-index" ||
|
||||
key === "inst-page-cache-days"
|
||||
) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
sessionStorage.removeItem("inst-soft-nav");
|
||||
sessionStorage.removeItem("inst-cache-revalidate");
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function boot() {
|
||||
purgeLegacySoftNavCache();
|
||||
if (isHubLinked()) {
|
||||
apply(get(), { skipStore: true });
|
||||
window.addEventListener("message", (ev) => initFromHubMessage(ev.data));
|
||||
initHubEmbedInFrameNav();
|
||||
try {
|
||||
window.parent.postMessage({ type: "instance-theme-ready" }, "*");
|
||||
} catch (_) {}
|
||||
} else {
|
||||
apply(getStandalone());
|
||||
}
|
||||
|
||||
function observeDynamicLists() {
|
||||
["journal-list", "review-list"].forEach((id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el || el.dataset.instThemeObserved === "1") return;
|
||||
el.dataset.instThemeObserved = "1";
|
||||
new MutationObserver(() => {
|
||||
syncInlineStyles(get());
|
||||
patchHubNavLinks(get());
|
||||
}).observe(el, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const onReady = () => {
|
||||
initToggleUI();
|
||||
initMobileTopNav();
|
||||
initReviewEditModeSync();
|
||||
syncInlineStyles(get());
|
||||
patchHubNavLinks(get());
|
||||
observeDynamicLists();
|
||||
if (isHubLinked()) {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => notifyParentFrameReady());
|
||||
});
|
||||
}
|
||||
};
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", onReady);
|
||||
} else {
|
||||
onReady();
|
||||
}
|
||||
document.addEventListener("instance-theme-change", (ev) => {
|
||||
const t = ev.detail && ev.detail.theme;
|
||||
if (t) {
|
||||
syncInlineStyles(t);
|
||||
patchHubNavLinks(t);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
boot();
|
||||
|
||||
global.InstanceTheme = {
|
||||
STANDALONE_KEY,
|
||||
HUB_LINKED_THEME_KEY,
|
||||
isHubLinked,
|
||||
get,
|
||||
apply,
|
||||
initToggleUI,
|
||||
syncToggleUI,
|
||||
syncInlineStyles,
|
||||
patchHubNavLinks,
|
||||
mergeHubQueryIntoHref,
|
||||
syncReviewEditButtons,
|
||||
initReviewEditModeSync,
|
||||
};
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
Reference in New Issue
Block a user