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";
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 = 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;
}
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 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 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) {
const partsHint =
payload.parts > 1 ? ` · 企微分 ${payload.parts} 条发送` : "";
meta.textContent = `${payload.period_label || "—"} · ${payload.count} 个币种${partsHint}`;
}
if (!payload.items?.length) {
cards.innerHTML = '暂无三日交集币种
';
return;
}
cards.innerHTML = payload.items
.map(
(it) => `
${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 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();
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", () => {
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");