const REFRESH_MS = 60_000; 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" }, }; let statsData = null; let currentView = "today"; 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); } async function loadPeriod(periodId) { const tbody = ensurePeriodTable(periodId); if (tbody) tbody.innerHTML = '加载中…'; try { const res = await fetch(PERIOD_API[periodId]); const data = await res.json(); setPeriodData(periodId, data); if (periodId === "today") { document.getElementById("status").textContent = "今日数据已刷新"; } } 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; } wrap.innerHTML = `
合约 今日排名今日涨跌今日成交额 昨日排名昨日涨跌昨日成交额 前日排名前日涨跌前日成交额 三日总成交额
`; 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)} `; }) .join(""); } function formatVol(v) { if (v >= 1e8) return (v / 1e8).toFixed(2) + "亿"; if (v >= 1e4) return (v / 1e4).toFixed(2) + "万"; return String(Math.round(v)); } async function loadStats() { document.getElementById("stats-table-wrap").innerHTML = '

统计中…

'; try { const res = await fetch("/api/stats/three-day"); statsData = await res.json(); renderStatsTable(); } 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(); 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"); if (currentView === "stats") await loadStats(); }); document.getElementById("btn-reload-stats")?.addEventListener("click", () => { statsData = null; loadStats(); }); document.getElementById("btn-export-stats")?.addEventListener("click", exportStatsCsv); loadPeriod("today"); loadPeriod("yesterday"); loadPeriod("daybefore"); setInterval(() => { if (currentView === "today") loadPeriod("today"); }, REFRESH_MS);