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>
This commit is contained in:
@@ -1,28 +1,32 @@
|
||||
/**
|
||||
* 策略说明:三所 MD + 开仓检查清单 JSON.
|
||||
* 策略说明:策略正文(博客目录 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 docCard = page.querySelector(".strategy-doc-card");
|
||||
const checklistCard = page.querySelector(".strategy-checklist-card");
|
||||
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 heightSyncRaf = 0;
|
||||
let scrollSpyObs = null;
|
||||
|
||||
async function apiFetch(url, opts) {
|
||||
const r = await fetch(url, { credentials: "same-origin", ...(opts || {}) });
|
||||
@@ -44,23 +48,31 @@
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function syncDocCardHeight() {
|
||||
if (!docCard || !checklistCard || window.matchMedia("(max-width: 960px)").matches) {
|
||||
if (docCard) docCard.style.height = "";
|
||||
return;
|
||||
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");
|
||||
});
|
||||
}
|
||||
docCard.style.height = `${checklistCard.offsetHeight}px`;
|
||||
}
|
||||
|
||||
function scheduleHeightSync() {
|
||||
if (heightSyncRaf) cancelAnimationFrame(heightSyncRaf);
|
||||
heightSyncRaf = requestAnimationFrame(() => {
|
||||
heightSyncRaf = 0;
|
||||
syncDocCardHeight();
|
||||
});
|
||||
}
|
||||
|
||||
function renderTabs() {
|
||||
function renderExchangeTabs() {
|
||||
if (!tabsEl) return;
|
||||
tabsEl.innerHTML = tabsMeta
|
||||
.map(
|
||||
@@ -73,12 +85,102 @@
|
||||
const key = btn.getAttribute("data-key");
|
||||
if (!key || key === activeKey) return;
|
||||
activeKey = key;
|
||||
renderTabs();
|
||||
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 || "开仓检查清单";
|
||||
@@ -102,7 +204,6 @@
|
||||
footnotesEl.innerHTML = notes.map((n) => `<li>${esc(n)}</li>`).join("");
|
||||
footnotesEl.classList.toggle("hidden", !notes.length);
|
||||
}
|
||||
scheduleHeightSync();
|
||||
}
|
||||
|
||||
function renderPayload(data) {
|
||||
@@ -111,8 +212,8 @@
|
||||
const ver = data.version ? ` · ${data.version}` : "";
|
||||
docSource.textContent = `文档:${data.md_source || ""}${ver}`;
|
||||
}
|
||||
buildDocToc();
|
||||
renderChecklist(data.checklist);
|
||||
scheduleHeightSync();
|
||||
}
|
||||
|
||||
async function loadExchange(key) {
|
||||
@@ -128,8 +229,8 @@
|
||||
} catch (e) {
|
||||
if (statusEl) statusEl.textContent = String(e);
|
||||
if (docBody) docBody.innerHTML = "";
|
||||
if (docToc) docToc.innerHTML = "";
|
||||
if (checklistBody) checklistBody.innerHTML = "";
|
||||
scheduleHeightSync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,13 +240,12 @@
|
||||
if (tabsMeta.length && !tabsMeta.some((t) => t.key === activeKey)) {
|
||||
activeKey = tabsMeta[0].key;
|
||||
}
|
||||
renderTabs();
|
||||
renderExchangeTabs();
|
||||
}
|
||||
|
||||
async function printSection(mode) {
|
||||
const part = mode === "checklist" ? "checklist" : "doc";
|
||||
const url = `/api/strategy/${encodeURIComponent(activeKey)}/print?part=${encodeURIComponent(part)}`;
|
||||
// 同步打开空白页保留用户手势;再 fetch 写入,避免 noopener 丢句柄 / 短页误关窗
|
||||
const w = window.open("about:blank", "_blank");
|
||||
if (!w) {
|
||||
if (statusEl) statusEl.textContent = "请允许弹出窗口以打开打印预览";
|
||||
@@ -183,18 +283,24 @@
|
||||
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`;
|
||||
});
|
||||
}
|
||||
window.addEventListener("resize", scheduleHeightSync);
|
||||
}
|
||||
|
||||
async function init() {
|
||||
bindActions();
|
||||
setView(activeView);
|
||||
try {
|
||||
await loadMeta();
|
||||
await loadExchange(activeKey);
|
||||
@@ -204,7 +310,10 @@
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
window.removeEventListener("resize", scheduleHeightSync);
|
||||
if (scrollSpyObs) {
|
||||
scrollSpyObs.disconnect();
|
||||
scrollSpyObs = null;
|
||||
}
|
||||
}
|
||||
|
||||
window.hubStrategyPage = { init, destroy };
|
||||
|
||||
Reference in New Issue
Block a user