Files
crypto_monitor/manual_trading_hub/static/quotes.js
T
dekun b7f3a5c289 Add top-nav Quotes blog feed page.
Surface recent review quotes grouped by trading day with expand, day PnL summary, and AI review jump.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 13:54:24 +08:00

285 lines
7.9 KiB
JavaScript

/**
* 语录博客流:按交易日分组 · 截断展开 · 当日盈亏摘要 · AI 复盘跳转.
*/
(function () {
const page = document.getElementById("page-quotes");
if (!page) return;
const elFeed = document.getElementById("quotes-feed");
const elStatus = document.getElementById("quotes-status");
const elBtnRefresh = document.getElementById("quotes-btn-refresh");
const elLinkArchive = document.getElementById("quotes-link-archive");
const RECENT_LIMIT = 20;
const PREVIEW_LEN = 140;
const ARCHIVE_QUOTE_AI_KEY = "hub_archive_quote_ai";
let quotes = [];
let dayStats = {};
let expanded = {};
let inited = false;
let loading = false;
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
async function apiFetch(url, opts) {
return fetch(url, Object.assign({ credentials: "same-origin" }, opts || {}));
}
function setStatus(text) {
if (elStatus) elStatus.textContent = text || "";
}
function findQuote(id) {
return (
quotes.find(function (q) {
return String(q.id) === String(id);
}) || null
);
}
function fmtPnl(v) {
const n = Number(v);
if (!Number.isFinite(n)) return "—";
return (n >= 0 ? "+" : "") + n.toFixed(2) + "U";
}
function pnlClass(v) {
const n = Number(v);
if (!Number.isFinite(n) || n === 0) return "";
return n > 0 ? "pnl-pos" : "pnl-neg";
}
function fmtWinRate(v) {
const n = Number(v);
if (!Number.isFinite(n)) return "—";
return n.toFixed(1) + "%";
}
function daySummaryHtml(day, st) {
if (!st) {
return '<span class="quotes-day-summary muted">当日统计加载中…</span>';
}
const openN = Number(st.open_count) || 0;
const pnl = st.pnl_total;
return (
'<span class="quotes-day-summary">' +
openN +
" 笔 · 盈亏 <span class=\"" +
pnlClass(pnl) +
'">' +
esc(fmtPnl(pnl)) +
"</span> · 胜率 " +
esc(fmtWinRate(st.win_rate)) +
"</span>"
);
}
function previewText(raw) {
const text = String(raw || "").trim();
if (text.length <= PREVIEW_LEN) return { text: text, truncated: false };
return { text: text.slice(0, PREVIEW_LEN).trim() + "…", truncated: true };
}
function groupByDay(rows) {
const map = {};
const order = [];
rows.forEach(function (q) {
const day = String(q.quote_date || "").slice(0, 10) || "—";
if (!map[day]) {
map[day] = [];
order.push(day);
}
map[day].push(q);
});
return { map: map, order: order };
}
function renderFeed() {
if (!elFeed) return;
if (!quotes.length) {
elFeed.innerHTML =
'<p class="quotes-empty">暂无复盘语录.可在「内照明心 → 复盘语录」中添加.</p>';
return;
}
const grouped = groupByDay(quotes);
elFeed.innerHTML = grouped.order
.map(function (day) {
const list = grouped.map[day] || [];
const cards = list
.map(function (q) {
const id = String(q.id);
const full = String(q.content || "").trim();
const isOpen = !!expanded[id];
const prev = previewText(full);
const showExpand = prev.truncated;
const body = isOpen || !showExpand ? full : prev.text;
return (
'<article class="quotes-card' +
(isOpen ? " is-expanded" : "") +
'" data-id="' +
esc(id) +
'">' +
'<div class="quotes-card-body">' +
esc(body) +
"</div>" +
'<div class="quotes-card-actions">' +
(showExpand
? '<button type="button" class="ghost quotes-expand-btn" data-id="' +
esc(id) +
'">' +
(isOpen ? "收起" : "展开") +
"</button>"
: "") +
'<button type="button" class="ghost quotes-ai-btn" data-id="' +
esc(id) +
'">AI 复盘</button>' +
"</div></article>"
);
})
.join("");
return (
'<section class="quotes-day-group" data-day="' +
esc(day) +
'">' +
'<header class="quotes-day-head">' +
'<h2 class="quotes-day-title">' +
esc(day) +
"</h2>" +
daySummaryHtml(day, dayStats[day]) +
"</header>" +
'<div class="quotes-day-cards">' +
cards +
"</div></section>"
);
})
.join("");
elFeed.querySelectorAll(".quotes-expand-btn").forEach(function (btn) {
btn.addEventListener("click", function () {
const id = btn.getAttribute("data-id");
expanded[id] = !expanded[id];
renderFeed();
});
});
elFeed.querySelectorAll(".quotes-ai-btn").forEach(function (btn) {
btn.addEventListener("click", function () {
startQuoteAiChat(btn.getAttribute("data-id"));
});
});
}
function startQuoteAiChat(quoteId) {
const q = findQuote(quoteId);
const content = q && String(q.content || "").trim();
if (!q || !content) {
setStatus("语录内容为空,无法发起 AI 对话");
return;
}
try {
sessionStorage.setItem(
ARCHIVE_QUOTE_AI_KEY,
JSON.stringify({
quote_date: q.quote_date || "",
content: content,
})
);
} catch (_) {
setStatus("无法保存跳转数据");
return;
}
if (typeof window.hubNavigateTo === "function") {
window.hubNavigateTo("/ai");
return;
}
location.href = "/ai";
}
async function loadDayStats(days) {
const uniq = [];
const seen = {};
(days || []).forEach(function (d) {
const day = String(d || "").slice(0, 10);
if (!day || day === "—" || seen[day]) return;
seen[day] = true;
uniq.push(day);
});
await Promise.all(
uniq.map(async function (day) {
if (dayStats[day]) return;
try {
const q = new URLSearchParams();
q.set("period", "today");
q.set("trading_day", day);
const r = await apiFetch("/api/archive/daily-trades?" + q.toString());
const j = await r.json();
if (r.ok) {
dayStats[day] = j.stats || { open_count: 0, pnl_total: 0, win_rate: null };
} else {
dayStats[day] = { open_count: 0, pnl_total: 0, win_rate: null };
}
} catch (_) {
dayStats[day] = { open_count: 0, pnl_total: 0, win_rate: null };
}
})
);
}
async function loadQuotes() {
if (loading) return;
loading = true;
setStatus("加载语录…");
try {
const r = await apiFetch("/api/archive/quotes");
const j = await r.json();
if (!r.ok) {
setStatus(j.detail || "加载失败");
return;
}
quotes = (j.quotes || []).slice(0, RECENT_LIMIT);
const days = quotes.map(function (q) {
return q.quote_date;
});
renderFeed();
await loadDayStats(days);
renderFeed();
setStatus("最近 " + quotes.length + " 条 · " + new Date().toLocaleTimeString());
} catch (e) {
setStatus(String(e && e.message ? e.message : e) || "加载失败");
} finally {
loading = false;
}
}
function bindEvents() {
if (elBtnRefresh) elBtnRefresh.addEventListener("click", loadQuotes);
if (elLinkArchive) {
elLinkArchive.addEventListener("click", function (ev) {
if (typeof window.hubNavigateTo === "function") {
ev.preventDefault();
window.hubNavigateTo("/archive");
}
});
}
}
async function init() {
if (!page || page.classList.contains("hidden")) return;
if (!inited) {
bindEvents();
inited = true;
}
await loadQuotes();
}
function destroy() {}
window.hubQuotesPage = { init: init, destroy: destroy };
})();