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 };