272 lines
8.4 KiB
JavaScript
272 lines
8.4 KiB
JavaScript
const REFRESH_MS = 60_000;
|
|
|
|
const tableState = {
|
|
yesterday: {
|
|
items: [],
|
|
meta: {},
|
|
sortKey: "rank",
|
|
sortDir: "asc",
|
|
},
|
|
today: {
|
|
items: [],
|
|
meta: {},
|
|
sortKey: "rank",
|
|
sortDir: "asc",
|
|
},
|
|
};
|
|
|
|
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,
|
|
tags: (r) => {
|
|
let score = 0;
|
|
if (r.is_high_volume) score += 2;
|
|
if (r.is_high_change) score += 1;
|
|
return score;
|
|
},
|
|
funding_rate: (r) => Number(r.funding_rate_pct) || 0,
|
|
};
|
|
|
|
function formatPeriod(start, end) {
|
|
const fmt = (s) => s.replace("T", " ").slice(0, 16);
|
|
return `${fmt(start)} ~ ${fmt(end)}`;
|
|
}
|
|
|
|
function tagText(row) {
|
|
const tags = [];
|
|
if (row.is_high_volume) tags.push("千万+");
|
|
if (row.is_high_change) tags.push("涨跌5%+");
|
|
return tags.join(" ") || "";
|
|
}
|
|
|
|
function renderTags(row) {
|
|
const parts = [];
|
|
if (row.is_high_volume) {
|
|
parts.push('<span class="tag tag-vol">千万+</span>');
|
|
}
|
|
if (row.is_high_change) {
|
|
parts.push('<span class="tag tag-chg">涨跌5%+</span>');
|
|
}
|
|
return parts.length ? parts.join("") : "—";
|
|
}
|
|
|
|
function pctClass(pct) {
|
|
if (pct > 0) return "pct-up";
|
|
if (pct < 0) return "pct-down";
|
|
return "";
|
|
}
|
|
|
|
function sortItems(items, key, dir) {
|
|
const getter = SORT_KEYS[key] || SORT_KEYS.rank;
|
|
const sorted = [...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;
|
|
});
|
|
return sorted;
|
|
}
|
|
|
|
function updateSortHeaders(tableId) {
|
|
const table = document.querySelector(`table[data-table="${tableId}"]`);
|
|
if (!table) return;
|
|
const { sortKey, sortDir } = tableState[tableId];
|
|
table.querySelectorAll("th.sortable").forEach((th) => {
|
|
th.classList.remove("sorted-asc", "sorted-desc");
|
|
const key = th.dataset.sort;
|
|
if (key === sortKey) {
|
|
th.classList.add(sortDir === "asc" ? "sorted-asc" : "sorted-desc");
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderTable(tableId, tbody) {
|
|
const state = tableState[tableId];
|
|
const items = sortItems(state.items, state.sortKey, state.sortDir);
|
|
|
|
if (!items.length) {
|
|
tbody.innerHTML = '<tr><td colspan="7" class="loading">暂无数据</td></tr>';
|
|
updateSortHeaders(tableId);
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = items
|
|
.map((row, idx) => {
|
|
const highlight =
|
|
row.is_high_volume || row.is_high_change ? " row-highlight" : "";
|
|
const pct = row.price_change_pct ?? 0;
|
|
const displayRank =
|
|
state.sortKey === "rank" && state.sortDir === "asc"
|
|
? row.rank
|
|
: idx + 1;
|
|
return `<tr class="${highlight}">
|
|
<td class="rank">${displayRank}</td>
|
|
<td class="symbol-cell"><strong>${row.symbol}</strong></td>
|
|
<td class="chart-cell">
|
|
<div class="mini-chart" data-symbol="${row.symbol}">
|
|
<canvas></canvas>
|
|
<span class="chart-status"></span>
|
|
</div>
|
|
</td>
|
|
<td data-value="${row.quote_volume ?? 0}">${row.quote_volume_fmt || row.quote_volume}</td>
|
|
<td class="${pctClass(pct)}" data-value="${pct}">${row.price_change_pct_fmt || pct.toFixed(2) + "%"}</td>
|
|
<td class="funding-cell" data-value="${row.funding_rate_pct ?? 0}">
|
|
<div class="funding-cell-inner">
|
|
<span class="funding-rate-label ${pctClass(row.funding_rate_pct ?? 0)}">${row.funding_rate_fmt || "—"}</span>
|
|
<div class="mini-funding-chart" data-symbol="${row.symbol}">
|
|
<canvas></canvas>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td data-value="${tagText(row)}">${renderTags(row)}</td>
|
|
</tr>`;
|
|
})
|
|
.join("");
|
|
|
|
updateSortHeaders(tableId);
|
|
enqueueCharts(tbody);
|
|
if (typeof enqueueFundingCharts === "function") enqueueFundingCharts(tbody);
|
|
}
|
|
|
|
function setTableData(tableId, data) {
|
|
tableState[tableId].items = data.items || [];
|
|
tableState[tableId].meta = {
|
|
period_start: data.period_start,
|
|
period_end: data.period_end,
|
|
updated_at: data.updated_at,
|
|
};
|
|
const tbody = document.getElementById(`${tableId}-body`);
|
|
renderTable(tableId, tbody);
|
|
}
|
|
|
|
function toggleSort(tableId, key) {
|
|
const state = tableState[tableId];
|
|
if (state.sortKey === key) {
|
|
state.sortDir = state.sortDir === "asc" ? "desc" : "asc";
|
|
} else {
|
|
state.sortKey = key;
|
|
state.sortDir = key === "symbol" ? "asc" : "desc";
|
|
}
|
|
const tbody = document.getElementById(`${tableId}-body`);
|
|
renderTable(tableId, tbody);
|
|
}
|
|
|
|
function resetSort(tableId) {
|
|
tableState[tableId].sortKey = "rank";
|
|
tableState[tableId].sortDir = "asc";
|
|
const tbody = document.getElementById(`${tableId}-body`);
|
|
renderTable(tableId, tbody);
|
|
}
|
|
|
|
function exportCsv(tableId) {
|
|
const state = tableState[tableId];
|
|
if (!state.items.length) {
|
|
alert("暂无数据可导出");
|
|
return;
|
|
}
|
|
const items = sortItems(state.items, state.sortKey, state.sortDir);
|
|
const header = [
|
|
"排名",
|
|
"合约",
|
|
"成交额显示",
|
|
"成交额USDT",
|
|
"涨跌幅%",
|
|
"资金费率%",
|
|
"千万+",
|
|
"涨跌5%+",
|
|
"标记",
|
|
];
|
|
const rows = items.map((r, i) => [
|
|
state.sortKey === "rank" && state.sortDir === "asc" ? r.rank : i + 1,
|
|
r.symbol,
|
|
r.quote_volume_fmt || "",
|
|
r.quote_volume ?? "",
|
|
r.price_change_pct ?? "",
|
|
r.funding_rate_pct ?? "",
|
|
r.is_high_volume ? "是" : "否",
|
|
r.is_high_change ? "是" : "否",
|
|
tagText(r),
|
|
]);
|
|
|
|
const escape = (v) => `"${String(v).replace(/"/g, '""')}"`;
|
|
const csv = [header, ...rows].map((row) => row.map(escape).join(",")).join("\n");
|
|
const blob = new Blob(["\ufeff" + csv], { type: "text/csv;charset=utf-8" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
const period = state.meta.period_start
|
|
? state.meta.period_start.slice(0, 10)
|
|
: tableId;
|
|
a.href = url;
|
|
a.download = `binance-top30-${tableId}-${period}.csv`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
document.querySelectorAll("th.sortable").forEach((th) => {
|
|
th.addEventListener("click", () => {
|
|
const table = th.closest("table");
|
|
const tableId = table?.dataset.table;
|
|
const key = th.dataset.sort;
|
|
if (tableId && key) toggleSort(tableId, key);
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll("[data-export]").forEach((btn) => {
|
|
btn.addEventListener("click", () => exportCsv(btn.dataset.export));
|
|
});
|
|
|
|
document.querySelectorAll("[data-reset]").forEach((btn) => {
|
|
btn.addEventListener("click", () => resetSort(btn.dataset.reset));
|
|
});
|
|
|
|
async function loadYesterday() {
|
|
const body = document.getElementById("yesterday-body");
|
|
body.innerHTML = '<tr><td colspan="7" class="loading">加载中…</td></tr>';
|
|
try {
|
|
const res = await fetch("/api/yesterday/top30");
|
|
const data = await res.json();
|
|
document.getElementById("yesterday-period").textContent = formatPeriod(
|
|
data.period_start,
|
|
data.period_end
|
|
);
|
|
document.getElementById("yesterday-updated").textContent =
|
|
"更新: " + (data.updated_at || "").replace("T", " ").slice(0, 19);
|
|
setTableData("yesterday", data);
|
|
} catch (e) {
|
|
body.innerHTML = `<tr><td colspan="7" class="error">加载失败: ${e.message}</td></tr>`;
|
|
}
|
|
}
|
|
|
|
async function loadToday() {
|
|
const body = document.getElementById("today-body");
|
|
try {
|
|
const res = await fetch("/api/today/top30");
|
|
const data = await res.json();
|
|
document.getElementById("today-period").textContent = formatPeriod(
|
|
data.period_start,
|
|
data.period_end
|
|
);
|
|
document.getElementById("today-updated").textContent =
|
|
"更新: " + (data.updated_at || "").replace("T", " ").slice(0, 19);
|
|
setTableData("today", data);
|
|
document.getElementById("status").textContent = "今日数据已刷新";
|
|
} catch (e) {
|
|
body.innerHTML = `<tr><td colspan="7" class="error">加载失败: ${e.message}</td></tr>`;
|
|
document.getElementById("status").textContent = e.message;
|
|
}
|
|
}
|
|
|
|
document.getElementById("btn-refresh").addEventListener("click", async () => {
|
|
document.getElementById("status").textContent = "刷新中…";
|
|
await fetch("/api/refresh/today", { method: "POST" });
|
|
await loadToday();
|
|
});
|
|
|
|
loadYesterday();
|
|
loadToday();
|
|
setInterval(loadToday, REFRESH_MS);
|