From 82c910fbf82dd4d2ad4efc02e9b80bea4491bcbe Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 18 Jul 2026 17:29:18 +0800 Subject: [PATCH] Add blog-style TOC to strategy doc; checklist as separate tab. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strategy page uses 策略正文 (MD + sticky h2/h3 TOC) and 执行清单 tabs so full playbook detail stays readable with jump navigation. Co-authored-by: Cursor --- manual_trading_hub/static/app.css | 120 ++++++++++++++++++- manual_trading_hub/static/index.html | 53 +++++---- manual_trading_hub/static/strategy.js | 161 +++++++++++++++++++++----- 3 files changed, 282 insertions(+), 52 deletions(-) diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index a070160..9e8affa 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -9659,6 +9659,26 @@ body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="roll"] [data-c flex-wrap: wrap; margin-bottom: 12px; } +.strategy-view-tabs { + display: flex; + gap: 6px; + flex-wrap: wrap; +} +.strategy-view-tab { + padding: 6px 14px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--surface-2); + color: var(--text-soft); + cursor: pointer; + font-size: 0.88rem; +} +.strategy-view-tab.is-active { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--text); + font-weight: 600; +} .strategy-tabs { display: flex; gap: 8px; @@ -9678,6 +9698,86 @@ body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="roll"] [data-c border-color: var(--accent); color: var(--text); } +.strategy-doc-panel { + padding: 18px 20px 20px; + min-width: 0; +} +.strategy-blog-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(200px, 260px); + gap: 18px; + align-items: start; +} +.strategy-blog-main { + min-width: 0; + display: flex; + flex-direction: column; +} +.strategy-toc-card { + padding: 14px 12px 16px; + position: sticky; + top: 72px; + max-height: calc(100vh - 96px); + overflow: auto; +} +.strategy-toc-title { + margin: 0 0 12px; + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--text-soft); + text-transform: uppercase; +} +.strategy-toc-nav { + display: flex; + flex-direction: column; + gap: 2px; + border-left: 2px solid var(--border-soft); + padding-left: 2px; +} +.strategy-toc-item { + display: flex; + align-items: flex-start; + gap: 8px; + width: 100%; + text-align: left; + padding: 7px 10px; + border: none; + border-radius: 0 8px 8px 0; + background: transparent; + color: var(--text-soft); + font-size: 0.84rem; + line-height: 1.35; + cursor: pointer; + text-decoration: none; + border-left: 2px solid transparent; + margin-left: -2px; +} +.strategy-toc-item.level-3 { + padding-left: 18px; + font-size: 0.8rem; +} +.strategy-toc-item:hover { + background: var(--surface-2, rgba(255, 255, 255, 0.04)); + color: var(--text); +} +.strategy-toc-item.is-active { + background: var(--accent-soft, rgba(0, 212, 255, 0.12)); + color: var(--accent); + font-weight: 600; + border-left-color: var(--accent); +} +.strategy-toc-num { + flex: 0 0 auto; + min-width: 1.6em; + opacity: 0.85; + font-variant-numeric: tabular-nums; +} +.strategy-toc-text { + flex: 1 1 auto; + min-width: 0; + word-break: break-word; +} .strategy-layout { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr); @@ -9717,7 +9817,7 @@ body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="roll"] [data-c .strategy-doc-body { flex: 1 1 auto; min-height: 0; - max-height: 100%; + max-height: min(70vh, 820px); font-size: 0.88rem; line-height: 1.55; color: var(--text); @@ -9727,16 +9827,18 @@ body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="roll"] [data-c scrollbar-gutter: stable; } .strategy-doc-body h2 { - font-size: 1rem; - margin: 0.6em 0 0.5em; + font-size: 1.05rem; + margin: 1.1em 0 0.55em; color: var(--text); + scroll-margin-top: 12px; } .strategy-doc-body h2:first-child { margin-top: 0; } .strategy-doc-body h3 { - font-size: 0.92rem; + font-size: 0.94rem; margin: 1em 0 0.4em; + scroll-margin-top: 12px; } .strategy-doc-body table { width: 100%; @@ -9818,12 +9920,20 @@ body:not(.hub-phone) #page-calculator .calc-layout[data-calc-tab="roll"] [data-c .strategy-layout { grid-template-columns: 1fr; } + .strategy-blog-layout { + grid-template-columns: 1fr; + } + .strategy-toc-card { + position: static; + max-height: none; + order: -1; + } .strategy-doc-card, .strategy-checklist-card { align-self: stretch; } .strategy-doc-body { - max-height: min(50vh, 520px); + max-height: min(60vh, 640px); } } diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index c7aeac5..7e0c422 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -15,7 +15,7 @@ - + @@ -940,35 +940,46 @@

STR 策略说明

-

三所策略文档 · 开仓前检查清单 · 可打印 / 下载

+

策略正文(带目录) · 执行清单(打印对照) · 三所切换

- + + +
+
+ + +
-
-
-
-

策略说明

- +
+
+
+
+

策略正文

+
+
+

-
-

-
-
-
-

开仓检查清单

- -
-
-
    -
    -
    + +
    + +
    @@ -1374,7 +1385,7 @@ - + diff --git a/manual_trading_hub/static/strategy.js b/manual_trading_hub/static/strategy.js index d81ce4b..9008788 100644 --- a/manual_trading_hub/static/strategy.js +++ b/manual_trading_hub/static/strategy.js @@ -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 = '

    暂无目录

    '; + 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) => + `` + + `${esc(it.num)}` + + `${esc(it.text)}` + ) + .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) => `
  • ${esc(n)}
  • `).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 };