b7f3a5c289
Surface recent review quotes grouped by trading day with expand, day PnL summary, and AI review jump. Co-authored-by: Cursor <cursoragent@cursor.com>
285 lines
7.9 KiB
JavaScript
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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
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 };
|
|
})();
|