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 wecomPreviewData = 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); } 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 updateStatsHeader(payload) { const criteria = document.getElementById("stats-criteria"); const summary = document.getElementById("stats-summary"); const desc = document.getElementById("stats-desc"); if (!payload) return; if (criteria) { criteria.textContent = payload.ok ? payload.period_label || "—" : "数据未就绪"; } if (summary) { summary.textContent = payload.ok ? `共 ${payload.count} 个币种` + (payload.parts > 1 ? ` · 企微将分 ${payload.parts} 条发送` : "") : ""; } if (desc && payload.message && !payload.ok) { desc.textContent = payload.message; } } function escapeHtml(s) { return String(s || "") .replace(/&/g, "&") .replace(//g, ">") .replace(/\n/g, "
"); } function renderWecomPreview(payload) { const cards = document.getElementById("wecom-preview-cards"); const meta = document.getElementById("wecom-preview-meta"); if (!cards) return; wecomPreviewData = payload; updateStatsHeader(payload); if (!payload?.ok) { if (meta) meta.textContent = ""; cards.innerHTML = `

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

`; return; } if (meta) { meta.textContent = `昨日周期 ${payload.period_label || "—"} · 昨/今/前 = 排名+涨跌幅` + (payload.parts > 1 ? ` · 超长将分 ${payload.parts} 条企微消息` : ""); } if (!payload.items?.length) { cards.innerHTML = '

暂无三日交集币种

'; return; } cards.innerHTML = payload.items .map((it) => { const line = (label, row) => { if (!row?.rank) return `${label}—`; const pct = row.price_change_pct ?? 0; const pctStr = row.price_change_pct_fmt || `${pct.toFixed(2)}%`; return `${label}#${row.rank}${pctStr}`; }; return `
${it.rank} ${it.symbol}

${line("昨 ", it.yesterday)} ${line("今 ", it.today)} ${line("前 ", 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() { await loadWecomPreview(); } function exportStatsCsv() { const items = wecomPreviewData?.items; if (!items?.length) return alert("暂无数据"); const header = [ "合约", "今日排名", "今日涨跌%", "昨日排名", "昨日涨跌%", "前日排名", "前日涨跌%", ]; const rows = items.map((r) => [ r.symbol, r.today?.rank, r.today?.price_change_pct, r.yesterday?.rank, r.yesterday?.price_change_pct, r.daybefore?.rank, r.daybefore?.price_change_pct, ]); downloadCsv("binance-wecom-push", header, rows, wecomPreviewData?.period_label); } 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 (!wecomPreviewData) 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", true); if (currentView === "stats") await loadStats(); }); document.getElementById("btn-reload-stats")?.addEventListener("click", () => { wecomPreviewData = null; loadStats(); }); document.getElementById("btn-export-stats")?.addEventListener("click", exportStatsCsv); document.getElementById("btn-push-test")?.addEventListener("click", testWecomPush); loadPeriod("today"); loadPeriod("yesterday"); loadPeriod("daybefore");