const PERIOD_API = { today: "/api/today/top30", yesterday: "/api/yesterday/top30", daybefore: "/api/daybefore/top30", }; const tableState = { today: { items: [], meta: {}, sortKey: "rank", sortDir: "asc" }, yesterday: { items: [], meta: {}, sortKey: "rank", sortDir: "asc" }, daybefore: { items: [], meta: {}, sortKey: "rank", sortDir: "asc" }, }; const PERIOD_LS_PREFIX = "ba_period_"; const PERIOD_TTL_MS = 4 * 60 * 60 * 1000; let statsData = null; let currentView = "today"; let llmPollTimer = null; let llmInterpretMap = {}; let llmRunState = { running: false, batch_id: "", current_symbol: "", done: 0, total: 0, }; let llmSymbolOrder = []; const llmExpandedSymbols = new Set(); const llmSeenDone = new Set(); const SORT_KEYS = { rank: (r) => Number(r.rank) || 0, symbol: (r) => String(r.symbol || ""), quote_volume: (r) => Number(r.quote_volume) || 0, price_change_pct: (r) => Number(r.price_change_pct) || 0, funding_rate: (r) => Number(r.funding_rate_pct) || 0, tags: (r) => { let s = 0; if (r.is_high_volume) s += 2; if (r.is_high_change) s += 1; return s; }, total_quote_volume: (r) => Number(r.total_quote_volume) || 0, avg_change_pct: (r) => Number(r.avg_change_pct) || 0, }; const TABLE_HEADER = ` 排名 合约 日线图 成交额 (USDT) 涨跌幅 资金费率 标记 `; function formatPeriod(start, end) { const fmt = (s) => (s || "").replace("T", " ").slice(0, 16); return `${fmt(start)} ~ ${fmt(end)}`; } function tagText(row) { const t = []; if (row.is_high_volume) t.push("千万+"); if (row.is_high_change) t.push("涨跌5%+"); return t.join(" ") || ""; } function renderTags(row) { const p = []; if (row.is_high_volume) p.push('千万+'); if (row.is_high_change) p.push('涨跌5%+'); return p.length ? p.join("") : "—"; } function pctClass(pct) { if (pct > 0) return "pct-up"; if (pct < 0) return "pct-down"; return ""; } function sortItems(items, key, dir, customKeys) { const getter = (customKeys || SORT_KEYS)[key] || SORT_KEYS.rank; return [...items].sort((a, b) => { const va = getter(a); const vb = getter(b); if (typeof va === "string") { return dir === "asc" ? va.localeCompare(vb) : vb.localeCompare(va); } return dir === "asc" ? va - vb : vb - va; }); } function ensurePeriodTable(periodId) { const wrap = document.getElementById(`${periodId}-table-wrap`); if (!wrap) return null; let table = wrap.querySelector(`table[data-table="${periodId}"]`); if (!table) { wrap.innerHTML = `${TABLE_HEADER}
`; table = wrap.querySelector("table"); bindSortHandlers(table); } return document.getElementById(`${periodId}-body`); } function bindSortHandlers(table) { table.querySelectorAll("th.sortable").forEach((th) => { th.onclick = () => { const tableId = table.dataset.table; const key = th.dataset.sort; if (tableId && key) toggleSort(tableId, key); }; }); } function updateSortHeaders(tableId) { const table = document.querySelector(`table[data-table="${tableId}"]`); if (!table) return; const { sortKey, sortDir } = tableState[tableId] || { sortKey: "rank", sortDir: "asc" }; table.querySelectorAll("th.sortable").forEach((th) => { th.classList.remove("sorted-asc", "sorted-desc"); if (th.dataset.sort === sortKey) { th.classList.add(sortDir === "asc" ? "sorted-asc" : "sorted-desc"); } }); } function renderPeriodTable(periodId) { const tbody = ensurePeriodTable(periodId); if (!tbody) return; const state = tableState[periodId]; const items = sortItems(state.items, state.sortKey, state.sortDir); if (!items.length) { tbody.innerHTML = '暂无数据'; updateSortHeaders(periodId); return; } tbody.innerHTML = items .map((row, idx) => { const hl = row.is_high_volume || row.is_high_change ? " row-highlight" : ""; const pct = row.price_change_pct ?? 0; const rank = state.sortKey === "rank" && state.sortDir === "asc" ? row.rank : idx + 1; return ` ${rank} ${row.symbol}
${row.quote_volume_fmt || row.quote_volume} ${row.price_change_pct_fmt || pct.toFixed(2) + "%"}
${row.funding_rate_fmt || "—"}
${renderTags(row)} `; }) .join(""); updateSortHeaders(periodId); if (currentView === periodId) { enqueueCharts(tbody); if (typeof enqueueFundingCharts === "function") enqueueFundingCharts(tbody); } } function setPeriodData(periodId, data) { tableState[periodId].items = data.items || []; tableState[periodId].meta = { period_start: data.period_start, period_end: data.period_end, updated_at: data.updated_at, }; const pe = document.getElementById(`${periodId}-period`); const ue = document.getElementById(`${periodId}-updated`); if (pe) pe.textContent = formatPeriod(data.period_start, data.period_end); if (ue) ue.textContent = "更新: " + (data.updated_at || "").replace("T", " ").slice(0, 19); renderPeriodTable(periodId); } function loadPeriodFromLS(periodId) { try { const raw = localStorage.getItem(PERIOD_LS_PREFIX + periodId); if (!raw) return null; const obj = JSON.parse(raw); if (!obj?.data || Date.now() - (obj.ts || 0) > PERIOD_TTL_MS) return null; return obj.data; } catch { return null; } } function savePeriodToLS(periodId, data) { try { localStorage.setItem( PERIOD_LS_PREFIX + periodId, JSON.stringify({ ts: Date.now(), data }) ); } catch { /* quota */ } } async function loadPeriod(periodId, force = false) { const tbody = ensurePeriodTable(periodId); if (!force) { const cached = loadPeriodFromLS(periodId); if (cached?.items?.length) { setPeriodData(periodId, cached); if (periodId === "today") { document.getElementById("status").textContent = "今日数据(浏览器缓存)"; } return; } } if (tbody) tbody.innerHTML = '加载中…'; try { const res = await fetch(PERIOD_API[periodId]); const data = await res.json(); savePeriodToLS(periodId, data); setPeriodData(periodId, data); if (periodId === "today") { document.getElementById("status").textContent = force ? "今日数据已手动刷新" : "今日数据已加载"; } } catch (e) { if (tbody) tbody.innerHTML = `${e.message}`; } } function toggleSort(tableId, key) { const s = tableState[tableId]; if (s.sortKey === key) s.sortDir = s.sortDir === "asc" ? "desc" : "asc"; else { s.sortKey = key; s.sortDir = key === "symbol" ? "asc" : "desc"; } renderPeriodTable(tableId); } function resetSort(tableId) { tableState[tableId].sortKey = "rank"; tableState[tableId].sortDir = "asc"; renderPeriodTable(tableId); } function exportPeriodCsv(periodId) { const state = tableState[periodId]; if (!state.items.length) return alert("暂无数据"); const items = sortItems(state.items, state.sortKey, state.sortDir); const header = ["排名", "合约", "成交额", "涨跌幅%", "资金费率%", "标记"]; const rows = items.map((r, i) => [ state.sortKey === "rank" && state.sortDir === "asc" ? r.rank : i + 1, r.symbol, r.quote_volume ?? "", r.price_change_pct ?? "", r.funding_rate_pct ?? "", tagText(r), ]); downloadCsv(`binance-${periodId}`, header, rows, state.meta.period_start); } function downloadCsv(name, header, rows, periodStart) { const esc = (v) => `"${String(v).replace(/"/g, '""')}"`; const csv = [header, ...rows].map((r) => r.map(esc).join(",")).join("\n"); const blob = new Blob(["\ufeff" + csv], { type: "text/csv;charset=utf-8" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = `${name}-${(periodStart || "").slice(0, 10)}.csv`; a.click(); } function renderStatsTable() { const wrap = document.getElementById("stats-table-wrap"); if (!wrap || !statsData) return; const items = statsData.items || []; document.getElementById("stats-criteria").textContent = statsData.criteria || ""; document.getElementById("stats-desc").textContent = statsData.message || ""; const sum = statsData.summary; document.getElementById("stats-summary").textContent = statsData.ok ? `符合条件 ${statsData.count} 个 · 三日交集 ${sum?.intersection ?? 0} 个` : "数据未就绪"; if (!statsData.ok) { wrap.innerHTML = `

${statsData.message || "请等待三个周期数据就绪"}

`; return; } if (!items.length) { wrap.innerHTML = '

暂无符合条件的合约

'; return; } llmSymbolOrder = items.map((r) => r.symbol); wrap.innerHTML = `
合约 今日排名今日涨跌今日成交额 昨日排名昨日涨跌昨日成交额 前日排名前日涨跌前日成交额 三日总成交额 AI解读
`; const body = document.getElementById("stats-body"); body.innerHTML = items .map((row) => { const d = (p) => row[p] || {}; const cell = (p, f) => { const x = d(p); const pct = x.price_change_pct ?? 0; return `${f === "pct" ? x.price_change_pct_fmt || "—" : f === "rank" ? x.rank ?? "—" : x.quote_volume_fmt || "—"}`; }; return ` ${row.symbol} ${cell("today", "rank")}${cell("today", "pct")}${cell("today", "vol")} ${cell("yesterday", "rank")}${cell("yesterday", "pct")}${cell("yesterday", "vol")} ${cell("daybefore", "rank")}${cell("daybefore", "pct")}${cell("daybefore", "vol")} ${formatVol(row.total_quote_volume)} ${buildLlmCellHtml(row.symbol)} `; }) .join(""); bindLlmFoldHandlers(); } function getLlmCellState(symbol) { const llm = llmInterpretMap[symbol]; if (llm?.content) { const failed = String(llm.content).startsWith("[解读失败]"); return { kind: failed ? "failed" : "done", llm }; } if (!llmRunState.running) return { kind: "idle" }; if (symbol === llmRunState.current_symbol) return { kind: "running" }; const idx = llmSymbolOrder.indexOf(symbol); if (idx >= 0 && idx < llmRunState.done) return { kind: "done", llm: null }; return { kind: "pending" }; } function buildLlmCellHtml(symbol) { const st = getLlmCellState(symbol); if (st.kind === "done" && !st.llm) { return `
已完成,点「刷新解读」
`; } if (st.kind === "done" && st.llm) { const t = (st.llm.created_at || "").replace("T", " ").slice(0, 19); const open = llmExpandedSymbols.has(symbol) ? " open" : ""; return `
查看解读 ${t}
${escapeHtml(st.llm.content)}
`; } if (st.kind === "failed" && st.llm) { return `
解读失败
${escapeHtml(st.llm.content)}
`; } if (st.kind === "running") { return `
解读中…
`; } if (st.kind === "pending") { return `
排队等待
`; } return ``; } function bindLlmFoldHandlers() { document.querySelectorAll("details.llm-fold").forEach((el) => { el.ontoggle = () => { const sym = el.dataset.symbol; if (!sym) return; if (el.open) llmExpandedSymbols.add(sym); else llmExpandedSymbols.delete(sym); }; }); } function updateStatsLlmRows() { const body = document.getElementById("stats-body"); if (!body) return; document.querySelectorAll("tr.stats-row[data-symbol]").forEach((tr) => { const sym = tr.dataset.symbol; const td = tr.querySelector("td.llm-col"); if (!td) return; const wasOpen = td.querySelector("details.llm-fold")?.open; td.innerHTML = buildLlmCellHtml(sym); const det = td.querySelector("details.llm-fold"); if (det) { det.ontoggle = () => { if (det.open) llmExpandedSymbols.add(sym); else llmExpandedSymbols.delete(sym); }; if (wasOpen || llmExpandedSymbols.has(sym)) det.open = true; } }); for (const sym of Object.keys(llmInterpretMap)) { if (llmSeenDone.has(sym)) continue; const llm = llmInterpretMap[sym]; if (llm?.content && !String(llm.content).startsWith("[解读失败]")) { llmSeenDone.add(sym); const det = document.querySelector(`details.llm-fold[data-symbol="${sym}"]`); if (det) { det.open = true; llmExpandedSymbols.add(sym); } } } } function escapeHtml(s) { return String(s || "") .replace(/&/g, "&") .replace(//g, ">") .replace(/\n/g, "
"); } function formatVol(v) { if (v >= 1e8) return (v / 1e8).toFixed(2) + "亿"; if (v >= 1e4) return (v / 1e4).toFixed(2) + "万"; return String(Math.round(v)); } function applyLlmPayload(data) { if (data.batch_id) llmRunState.batch_id = data.batch_id; if (data.running != null) { llmRunState.running = !!data.running; llmRunState.batch_id = data.batch_id || llmRunState.batch_id; llmRunState.current_symbol = data.current_symbol || ""; llmRunState.done = data.done ?? llmRunState.done; llmRunState.total = data.total ?? llmRunState.total; } const nextMap = { ...llmInterpretMap }; for (const item of data.items || []) { if (item.symbol) nextMap[item.symbol] = item; } llmInterpretMap = nextMap; if (document.getElementById("stats-body")) { updateStatsLlmRows(); } else if (statsData?.ok) { renderStatsTable(); } } async function loadLlmInterpretations() { const q = llmRunState.batch_id ? `?batch_id=${encodeURIComponent(llmRunState.batch_id)}` : ""; const res = await fetch(`/api/llm/interpretations${q}`); if (!res.ok) throw new Error("加载解读失败"); const data = await res.json(); applyLlmPayload(data); return data; } function updateLlmStatusText(st) { const label = document.getElementById("llm-model-label"); const text = document.getElementById("llm-status-text"); if (label) label.textContent = st.enabled ? st.model : "未配置"; if (!text) return; if (st.running) { text.textContent = `解读中 ${st.done}/${st.total} · 当前 ${st.current_symbol || "—"}`; } else { text.textContent = st.enabled ? `就绪 · 已完成 ${Object.keys(llmInterpretMap).length} 条 · 批次 ${st.batch_id || dataBatchId(st)}` : "请在 .env 配置 LLM_API_KEY"; } } function dataBatchId(st) { const keys = Object.keys(llmInterpretMap); return keys.length ? llmInterpretMap[keys[0]]?.batch_id || "—" : "—"; } async function refreshLlmStatus() { const res = await fetch("/api/llm/status"); if (!res.ok) throw new Error("状态获取失败"); const st = await res.json(); llmRunState.running = !!st.running; llmRunState.batch_id = st.batch_id || llmRunState.batch_id; llmRunState.current_symbol = st.current_symbol || ""; llmRunState.done = st.done ?? 0; llmRunState.total = st.total ?? 0; updateLlmStatusText(st); return st; } function startLlmPolling() { if (llmPollTimer) return; llmPollTimer = setInterval(async () => { try { await refreshLlmStatus(); await loadLlmInterpretations(); const st = await fetch("/api/llm/status").then((r) => r.json()); if (!st.running) { clearInterval(llmPollTimer); llmPollTimer = null; await loadLlmInterpretations(); } } catch (e) { console.warn("LLM poll:", e); } }, 3000); } function stopLlmPolling() { if (llmPollTimer) { clearInterval(llmPollTimer); llmPollTimer = null; } } async function refreshLlmAll() { const text = document.getElementById("llm-status-text"); if (text) text.textContent = "刷新中…"; try { const st = await refreshLlmStatus(); await loadLlmInterpretations(); if (st.running) startLlmPolling(); else stopLlmPolling(); } catch (e) { if (text) text.textContent = "刷新失败"; console.error(e); } } async function runLlmInterpret() { const btn = document.getElementById("btn-llm-run"); if (btn) btn.disabled = true; try { const res = await fetch("/api/llm/interpret/run", { method: "POST" }); const data = await res.json(); if (!data.ok) { alert(data.message || "启动失败"); return; } llmInterpretMap = {}; llmSeenDone.clear(); if (data.batch_id) llmRunState.batch_id = data.batch_id; llmRunState.running = true; await refreshLlmStatus(); await loadLlmInterpretations(); startLlmPolling(); if (document.getElementById("stats-body")) updateStatsLlmRows(); } catch (e) { alert(e.message); } finally { if (btn) btn.disabled = false; } } function renderWecomDayRow(label, row) { if (!row?.rank) { return `
${label}
`; } const pct = row.price_change_pct ?? 0; return `
${label} #${row.rank} ${row.quote_volume_fmt || row.quote_volume} ${row.price_change_pct_fmt || pct.toFixed(2) + "%"} ${row.funding_rate_fmt || "—"}
`; } function renderWecomPreview(payload) { const panel = document.getElementById("wecom-preview-panel"); const cards = document.getElementById("wecom-preview-cards"); const meta = document.getElementById("wecom-preview-meta"); if (!panel || !cards) return; panel.classList.remove("hidden"); if (!payload?.ok) { if (meta) meta.textContent = "未就绪"; cards.innerHTML = `

${escapeHtml(payload?.message || "无法生成预览")}

`; return; } if (meta) { meta.textContent = `${payload.period_label || "—"} · ${payload.count} 个币种`; } if (!payload.items?.length) { cards.innerHTML = '

暂无三日交集币种

'; return; } cards.innerHTML = payload.items .map( (it) => `
${it.rank} ${it.symbol}
${renderWecomDayRow("昨日", it.yesterday)} ${renderWecomDayRow("今日", it.today)} ${renderWecomDayRow("前日", it.daybefore)}
` ) .join(""); } async function loadWecomPreview() { const cards = document.getElementById("wecom-preview-cards"); if (cards) cards.innerHTML = '

生成预览…

'; try { const res = await fetch("/api/push/preview"); const data = await res.json(); renderWecomPreview(data); } catch (e) { if (cards) cards.innerHTML = `

${e.message}

`; } } async function testWecomPush() { const el = document.getElementById("push-status"); if (el) el.textContent = "推送中…"; try { const res = await fetch("/api/push/test", { method: "POST" }); const data = await res.json(); if (!res.ok) { const detail = data.detail; const msg = typeof detail === "string" ? detail : Array.isArray(detail) ? detail.map((x) => x.msg).join("; ") : data.message || res.statusText; throw new Error(msg); } if (el) el.textContent = data.message || "推送成功"; await loadWecomPreview(); } catch (e) { if (el) el.textContent = "推送失败"; alert(e.message); } } async function loadStats() { document.getElementById("stats-table-wrap").innerHTML = '

统计中…

'; try { const res = await fetch("/api/stats/three-day"); statsData = await res.json(); renderStatsTable(); await refreshLlmAll(); await loadWecomPreview(); } catch (e) { document.getElementById("stats-table-wrap").innerHTML = `

${e.message}

`; } } function exportStatsCsv() { if (!statsData?.items?.length) return alert("暂无数据"); const header = [ "合约", "今日排名", "今日涨跌%", "今日成交额", "昨日排名", "昨日涨跌%", "昨日成交额", "前日排名", "前日涨跌%", "前日成交额", "三日总成交额", ]; const rows = statsData.items.map((r) => [ r.symbol, r.today?.rank, r.today?.price_change_pct, r.today?.quote_volume, r.yesterday?.rank, r.yesterday?.price_change_pct, r.yesterday?.quote_volume, r.daybefore?.rank, r.daybefore?.price_change_pct, r.daybefore?.quote_volume, r.total_quote_volume, ]); downloadCsv("binance-three-day-stats", header, rows, "stats"); } function switchView(view) { currentView = view; document.querySelectorAll(".nav-item").forEach((b) => { b.classList.toggle("active", b.dataset.view === view); }); document.querySelectorAll(".view-panel").forEach((p) => { p.classList.toggle("active", p.id === `view-${view}`); }); if (view === "stats") { if (!statsData) loadStats(); else refreshLlmAll(); return; } const tbody = document.getElementById(`${view}-body`); if (tbody && tableState[view].items.length) { renderPeriodTable(view); enqueueCharts(tbody); if (typeof enqueueFundingCharts === "function") enqueueFundingCharts(tbody); } else if (!tableState[view].items.length) { loadPeriod(view); } } document.getElementById("main-nav").addEventListener("click", (e) => { const btn = e.target.closest(".nav-item"); if (btn?.dataset.view) switchView(btn.dataset.view); }); document.querySelectorAll("[data-export]").forEach((btn) => { btn.addEventListener("click", () => exportPeriodCsv(btn.dataset.export)); }); document.querySelectorAll("[data-reset]").forEach((btn) => { btn.addEventListener("click", () => resetSort(btn.dataset.reset)); }); document.getElementById("btn-refresh").addEventListener("click", async () => { document.getElementById("status").textContent = "刷新中…"; await fetch("/api/refresh/today", { method: "POST" }); await loadPeriod("today", true); if (currentView === "stats") await loadStats(); }); document.getElementById("btn-llm-run")?.addEventListener("click", runLlmInterpret); document.getElementById("btn-llm-refresh")?.addEventListener("click", () => refreshLlmAll()); document.getElementById("btn-reload-stats")?.addEventListener("click", () => { statsData = null; loadStats(); }); document.getElementById("btn-export-stats")?.addEventListener("click", exportStatsCsv); document.getElementById("btn-push-preview")?.addEventListener("click", loadWecomPreview); document.getElementById("btn-push-test")?.addEventListener("click", testWecomPush); loadPeriod("today"); loadPeriod("yesterday"); loadPeriod("daybefore");