Files
crypto_monitor/manual_trading_hub/static/strategy.js
T
dekun 82c910fbf8 Add blog-style TOC to strategy doc; checklist as separate tab.
Strategy page uses 策略正文 (MD + sticky h2/h3 TOC) and 执行清单 tabs so full playbook detail stays readable with jump navigation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 17:29:18 +08:00

321 lines
11 KiB
JavaScript

/**
* 策略说明:策略正文(博客目录 h2+h3) + 执行清单 Tab.
*/
(function () {
const page = document.getElementById("page-strategy");
if (!page) return;
const tabsEl = document.getElementById("strategy-tabs");
const viewTabsEl = document.getElementById("strategy-view-tabs");
const statusEl = document.getElementById("strategy-load-status");
const docBody = document.getElementById("strategy-doc-body");
const docSource = document.getElementById("strategy-doc-source");
const docToc = document.getElementById("strategy-doc-toc");
const panelDoc = document.getElementById("strategy-panel-doc");
const panelChecklist = document.getElementById("strategy-panel-checklist");
const checklistTitle = document.getElementById("strategy-checklist-title");
const checklistBody = document.getElementById("strategy-checklist-body");
const footnotesEl = document.getElementById("strategy-checklist-footnotes");
const btnPrintDoc = document.getElementById("strategy-btn-print-doc");
const btnPrintChecklist = document.getElementById("strategy-btn-print-checklist");
const btnPrintChecklistInline = document.getElementById("strategy-btn-print-checklist-inline");
const btnDownload = document.getElementById("strategy-btn-download");
let activeKey = "binance";
let activeView = "doc";
let tabsMeta = [];
let cache = {};
let bound = false;
let scrollSpyObs = null;
async function apiFetch(url, opts) {
const r = await fetch(url, { credentials: "same-origin", ...(opts || {}) });
const ct = (r.headers.get("content-type") || "").toLowerCase();
if (ct.includes("application/json")) {
const data = await r.json();
if (!r.ok) throw new Error((data && data.msg) || r.statusText || "请求失败");
return data;
}
if (!r.ok) throw new Error(r.statusText || "请求失败");
return r;
}
function esc(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function slugify(text) {
return String(text || "")
.trim()
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^\w\u4e00-\u9fff\-]+/g, "")
.replace(/\-+/g, "-")
.replace(/^\-|\-$/g, "")
.slice(0, 48);
}
function setView(view) {
activeView = view === "checklist" ? "checklist" : "doc";
if (panelDoc) panelDoc.classList.toggle("hidden", activeView !== "doc");
if (panelChecklist) panelChecklist.classList.toggle("hidden", activeView !== "checklist");
if (viewTabsEl) {
viewTabsEl.querySelectorAll(".strategy-view-tab").forEach((btn) => {
const on = btn.getAttribute("data-view") === activeView;
btn.classList.toggle("is-active", on);
btn.setAttribute("aria-selected", on ? "true" : "false");
});
}
}
function renderExchangeTabs() {
if (!tabsEl) return;
tabsEl.innerHTML = tabsMeta
.map(
(t) =>
`<button type="button" class="strategy-tab${t.key === activeKey ? " is-active" : ""}" role="tab" aria-selected="${t.key === activeKey}" data-key="${esc(t.key)}">${esc(t.label)}</button>`
)
.join("");
tabsEl.querySelectorAll(".strategy-tab").forEach((btn) => {
btn.addEventListener("click", () => {
const key = btn.getAttribute("data-key");
if (!key || key === activeKey) return;
activeKey = key;
renderExchangeTabs();
void loadExchange(key);
});
});
}
function setActiveToc(id) {
if (!docToc) return;
docToc.querySelectorAll(".strategy-toc-item").forEach((a) => {
a.classList.toggle("is-active", a.getAttribute("data-id") === id);
});
}
function bindScrollSpy(heads) {
if (scrollSpyObs) {
scrollSpyObs.disconnect();
scrollSpyObs = null;
}
if (!docBody || !heads.length || typeof IntersectionObserver === "undefined") return;
const visible = new Map();
scrollSpyObs = new IntersectionObserver(
(entries) => {
entries.forEach((en) => {
if (en.isIntersecting) visible.set(en.target.id, en.intersectionRatio);
else visible.delete(en.target.id);
});
let bestId = "";
let bestRatio = -1;
visible.forEach((ratio, id) => {
if (ratio > bestRatio) {
bestRatio = ratio;
bestId = id;
}
});
if (bestId) setActiveToc(bestId);
},
{ root: docBody, rootMargin: "-8% 0px -70% 0px", threshold: [0, 0.25, 0.5, 1] }
);
heads.forEach((h) => scrollSpyObs.observe(h));
}
function buildDocToc() {
if (!docBody || !docToc) return;
const heads = Array.from(docBody.querySelectorAll("h2, h3"));
if (!heads.length) {
docToc.innerHTML = '<p class="strategy-empty">暂无目录</p>';
return;
}
let h2n = 0;
let h3n = 0;
const used = {};
const items = [];
heads.forEach((el) => {
const level = el.tagName === "H2" ? 2 : 3;
if (level === 2) {
h2n += 1;
h3n = 0;
} else {
if (!h2n) h2n = 1;
h3n += 1;
}
const num = level === 2 ? String(h2n) : h2n + "." + h3n;
let base = "st-" + num.replace(/\./g, "-") + "-" + slugify(el.textContent);
if (!base || base === "st-" + num.replace(/\./g, "-") + "-") base = "st-" + num.replace(/\./g, "-");
let id = base;
let n = 2;
while (used[id] || document.getElementById(id)) {
id = base + "-" + n;
n += 1;
}
used[id] = true;
el.id = id;
items.push({ level, num, id, text: (el.textContent || "").trim() });
});
docToc.innerHTML = items
.map(
(it) =>
`<a href="#${esc(it.id)}" class="strategy-toc-item level-${it.level}" data-id="${esc(it.id)}">` +
`<span class="strategy-toc-num">${esc(it.num)}</span>` +
`<span class="strategy-toc-text">${esc(it.text)}</span></a>`
)
.join("");
docToc.querySelectorAll(".strategy-toc-item").forEach((a) => {
a.addEventListener("click", (ev) => {
ev.preventDefault();
const id = a.getAttribute("data-id");
const target = id ? document.getElementById(id) : null;
if (!target || !docBody.contains(target)) return;
setActiveToc(id);
target.scrollIntoView({ behavior: "smooth", block: "start" });
});
});
if (items[0]) setActiveToc(items[0].id);
bindScrollSpy(heads);
}
function renderChecklist(checklist) {
const cl = checklist || {};
const title = cl.title || "开仓检查清单";
if (checklistTitle) checklistTitle.textContent = title;
if (!checklistBody) return;
const groups = cl.groups || [];
if (!groups.length) {
checklistBody.innerHTML = '<p class="strategy-empty">暂无检查清单</p>';
} else {
checklistBody.innerHTML = groups
.map((grp) => {
const items = (grp.items || [])
.map((item) => `<li><span class="strategy-check-box" aria-hidden="true">☐</span>${esc(item)}</li>`)
.join("");
return `<div class="strategy-check-group"><h4>${esc(grp.title || "")}</h4><ul>${items}</ul></div>`;
})
.join("");
}
if (footnotesEl) {
const notes = cl.footnotes || [];
footnotesEl.innerHTML = notes.map((n) => `<li>${esc(n)}</li>`).join("");
footnotesEl.classList.toggle("hidden", !notes.length);
}
}
function renderPayload(data) {
if (docBody) docBody.innerHTML = data.strategy_html || "";
if (docSource) {
const ver = data.version ? ` · ${data.version}` : "";
docSource.textContent = `文档:${data.md_source || ""}${ver}`;
}
buildDocToc();
renderChecklist(data.checklist);
}
async function loadExchange(key) {
if (statusEl) statusEl.textContent = "加载中…";
try {
let data = cache[key];
if (!data) {
data = await apiFetch(`/api/strategy/${encodeURIComponent(key)}`);
cache[key] = data;
}
renderPayload(data);
if (statusEl) statusEl.textContent = "";
} catch (e) {
if (statusEl) statusEl.textContent = String(e);
if (docBody) docBody.innerHTML = "";
if (docToc) docToc.innerHTML = "";
if (checklistBody) checklistBody.innerHTML = "";
}
}
async function loadMeta() {
const meta = await apiFetch("/api/strategy/meta");
tabsMeta = meta.exchanges || [];
if (tabsMeta.length && !tabsMeta.some((t) => t.key === activeKey)) {
activeKey = tabsMeta[0].key;
}
renderExchangeTabs();
}
async function printSection(mode) {
const part = mode === "checklist" ? "checklist" : "doc";
const url = `/api/strategy/${encodeURIComponent(activeKey)}/print?part=${encodeURIComponent(part)}`;
const w = window.open("about:blank", "_blank");
if (!w) {
if (statusEl) statusEl.textContent = "请允许弹出窗口以打开打印预览";
return;
}
try {
w.document.write(
"<!DOCTYPE html><title>打印准备中…</title><body style=\"font-family:sans-serif;padding:24px;color:#222\">正在加载打印内容…</body>"
);
w.document.close();
} catch (_) {}
try {
const r = await fetch(url, { credentials: "same-origin" });
const html = await r.text();
if (!r.ok) {
throw new Error((html && html.slice(0, 120)) || r.statusText || "加载打印页失败");
}
w.document.open();
w.document.write(html);
w.document.close();
if (statusEl) statusEl.textContent = "";
} catch (e) {
const msg = String(e && e.message ? e.message : e);
try {
w.document.open();
w.document.write(
`<!DOCTYPE html><body style="font-family:sans-serif;padding:24px;color:#222">打印页加载失败: ${esc(msg)}</body>`
);
w.document.close();
} catch (_) {}
if (statusEl) statusEl.textContent = msg;
}
}
function bindActions() {
if (bound) return;
bound = true;
if (viewTabsEl) {
viewTabsEl.querySelectorAll(".strategy-view-tab").forEach((btn) => {
btn.addEventListener("click", () => setView(btn.getAttribute("data-view")));
});
}
if (btnPrintDoc) btnPrintDoc.addEventListener("click", () => printSection("doc"));
if (btnPrintChecklist) btnPrintChecklist.addEventListener("click", () => printSection("checklist"));
if (btnPrintChecklistInline) btnPrintChecklistInline.addEventListener("click", () => printSection("checklist"));
if (btnDownload) {
btnDownload.addEventListener("click", () => {
window.location.href = `/api/strategy/${encodeURIComponent(activeKey)}/export`;
});
}
}
async function init() {
bindActions();
setView(activeView);
try {
await loadMeta();
await loadExchange(activeKey);
} catch (e) {
if (statusEl) statusEl.textContent = String(e);
}
}
function destroy() {
if (scrollSpyObs) {
scrollSpyObs.disconnect();
scrollSpyObs = null;
}
}
window.hubStrategyPage = { init, destroy };
})();