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:
dekun
2026-07-18 17:29:18 +08:00
parent 778a215ad7
commit 82c910fbf8
3 changed files with 282 additions and 52 deletions
+115 -5
View File
@@ -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);
}
}
+32 -21
View File
@@ -15,7 +15,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
<link rel="stylesheet" href="/assets/app.css?v=20260717-archive-cal-chart" />
<link rel="stylesheet" href="/assets/app.css?v=20260718-strategy-toc" />
<link rel="stylesheet" href="/assets/trade_stats_calendar.css?v=4" />
<link rel="stylesheet" href="/assets/account_risk_badge.css?v=4" />
<script src="/assets/account_risk_badge.js?v=4"></script>
@@ -940,35 +940,46 @@
<div class="page-head strategy-page-head">
<div>
<h1><span class="head-tag">STR</span> 策略说明</h1>
<p class="page-desc">三所策略文档 · 开仓前检查清单 · 可打印 / 下载</p>
<p class="page-desc">策略正文(带目录) · 执行清单(打印对照) · 三所切换</p>
</div>
<div class="strategy-page-actions no-print">
<button type="button" id="strategy-btn-download" class="primary">下载 HTML</button>
<button type="button" id="strategy-btn-download" class="ghost">下载 HTML</button>
<button type="button" id="strategy-btn-print-doc" class="ghost">打印正文</button>
<button type="button" id="strategy-btn-print-checklist" class="primary">打印清单</button>
</div>
</div>
<div class="strategy-toolbar no-print">
<div id="strategy-tabs" class="strategy-tabs" role="tablist" aria-label="交易所策略"></div>
<div id="strategy-view-tabs" class="strategy-view-tabs" role="tablist" aria-label="策略视图">
<button type="button" class="strategy-view-tab is-active" data-view="doc" role="tab" aria-selected="true">策略正文</button>
<button type="button" class="strategy-view-tab" data-view="checklist" role="tab" aria-selected="false">执行清单</button>
</div>
<span id="strategy-load-status" class="toolbar-meta"></span>
</div>
<div id="strategy-print-root" class="strategy-print-root">
<div class="strategy-layout">
<section class="card strategy-doc-card">
<div class="strategy-col-head">
<h3 class="strategy-col-title">策略说明</h3>
<button type="button" id="strategy-btn-print-doc" class="ghost strategy-col-print">打印</button>
<section id="strategy-panel-doc" class="card strategy-doc-panel" data-strategy-view="doc">
<div class="strategy-blog-layout">
<div class="strategy-blog-main">
<div class="strategy-col-head">
<h3 class="strategy-col-title">策略正文</h3>
</div>
<div id="strategy-doc-body" class="strategy-doc-body prose"></div>
<p id="strategy-doc-source" class="strategy-doc-source"></p>
</div>
<div id="strategy-doc-body" class="strategy-doc-body prose"></div>
<p id="strategy-doc-source" class="strategy-doc-source"></p>
</section>
<section class="card strategy-checklist-card">
<div class="strategy-col-head">
<h3 id="strategy-checklist-title" class="strategy-col-title">开仓检查清单</h3>
<button type="button" id="strategy-btn-print-checklist" class="ghost strategy-col-print">打印</button>
</div>
<div id="strategy-checklist-body" class="strategy-checklist-body"></div>
<ul id="strategy-checklist-footnotes" class="strategy-checklist-footnotes"></ul>
</section>
</div>
<aside class="strategy-blog-toc card strategy-toc-card no-print">
<h3 class="strategy-toc-title">目录</h3>
<nav id="strategy-doc-toc" class="strategy-toc-nav" aria-label="策略目录"></nav>
</aside>
</div>
</section>
<section id="strategy-panel-checklist" class="card strategy-checklist-card hidden" data-strategy-view="checklist">
<div class="strategy-col-head">
<h3 id="strategy-checklist-title" class="strategy-col-title">开仓检查清单</h3>
<button type="button" id="strategy-btn-print-checklist-inline" class="ghost strategy-col-print">打印</button>
</div>
<div id="strategy-checklist-body" class="strategy-checklist-body"></div>
<ul id="strategy-checklist-footnotes" class="strategy-checklist-footnotes"></ul>
</section>
</div>
</div>
@@ -1374,7 +1385,7 @@
<script src="/assets/quotes.js?v=20260717-quotes-feed"></script>
<script src="/assets/funds.js?v=20260717-funds-scroll-fix"></script>
<script src="/assets/dashboard.js?v=20260717-dash-kpi-merge"></script>
<script src="/assets/strategy.js?v=4"></script>
<script src="/assets/strategy.js?v=6"></script>
<script src="/assets/help.js?v=1"></script>
<script src="/assets/logs.js?v=1"></script>
<script src="/assets/ai_review_render.js?v=3"></script>
+135 -26
View File
@@ -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, "&quot;");
}
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 };