diff --git a/web/app.js b/web/app.js
index c010308..591a8ac 100644
--- a/web/app.js
+++ b/web/app.js
@@ -1,10 +1,45 @@
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;
+ },
+};
+
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) {
@@ -22,27 +57,153 @@ function pctClass(pct) {
return "";
}
-function renderTable(tbody, items) {
- if (!items || !items.length) {
+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 = '
| 暂无数据 |
';
+ updateSortHeaders(tableId);
return;
}
+
tbody.innerHTML = items
- .map((row) => {
+ .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 `
- | ${row.rank} |
+ ${displayRank} |
${row.symbol} |
- ${row.quote_volume_fmt || row.quote_volume} |
- ${row.price_change_pct_fmt || pct.toFixed(2) + "%"} |
- ${renderTags(row)} |
+ ${row.quote_volume_fmt || row.quote_volume} |
+ ${row.price_change_pct_fmt || pct.toFixed(2) + "%"} |
+ ${renderTags(row)} |
`;
})
.join("");
+
+ updateSortHeaders(tableId);
}
+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.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 = '| 加载中… |
';
@@ -55,7 +216,7 @@ async function loadYesterday() {
);
document.getElementById("yesterday-updated").textContent =
"更新: " + (data.updated_at || "").replace("T", " ").slice(0, 19);
- renderTable(body, data.items);
+ setTableData("yesterday", data);
} catch (e) {
body.innerHTML = `| 加载失败: ${e.message} |
`;
}
@@ -72,7 +233,7 @@ async function loadToday() {
);
document.getElementById("today-updated").textContent =
"更新: " + (data.updated_at || "").replace("T", " ").slice(0, 19);
- renderTable(body, data.items);
+ setTableData("today", data);
document.getElementById("status").textContent = "今日数据已刷新";
} catch (e) {
body.innerHTML = `| 加载失败: ${e.message} |
`;
diff --git a/web/index.html b/web/index.html
index 4c96193..1fdf472 100644
--- a/web/index.html
+++ b/web/index.html
@@ -9,7 +9,7 @@
币安 U本位合约 · 成交额排名
- 北京时间 08:00 切日 · Top30 · 高亮:≥1000万 USDT / |涨跌|≥5%
+ 北京时间 08:00 切日 · Top30 · 高亮:≥1000万 USDT / |涨跌|≥5% · 点击表头可排序
@@ -17,16 +17,20 @@
昨日周期
—
+
+
+
+
-
+
- | 排名 |
- 合约 |
- 成交额 (USDT) |
- 涨跌幅 |
- 标记 |
+ 排名 |
+ 合约 |
+ 成交额 (USDT) |
+ 涨跌幅 |
+ 标记 |
@@ -39,16 +43,20 @@
今日周期 实时
—
+
+
+
+
-
+
- | 排名 |
- 合约 |
- 成交额 (USDT) |
- 涨跌幅 |
- 标记 |
+ 排名 |
+ 合约 |
+ 成交额 (USDT) |
+ 涨跌幅 |
+ 标记 |
diff --git a/web/style.css b/web/style.css
index d0f0740..2273a3c 100644
--- a/web/style.css
+++ b/web/style.css
@@ -71,7 +71,6 @@ header h1 {
}
.updated {
- margin-left: auto;
color: var(--muted);
font-size: 0.8rem;
}
@@ -99,6 +98,57 @@ th {
font-size: 0.8rem;
}
+th.sortable {
+ cursor: pointer;
+ user-select: none;
+ white-space: nowrap;
+}
+
+th.sortable:hover {
+ color: var(--text);
+}
+
+th.sortable::after {
+ content: " ⇅";
+ font-size: 0.7rem;
+ opacity: 0.35;
+}
+
+th.sorted-asc::after {
+ content: " ↑";
+ opacity: 1;
+ color: var(--accent);
+}
+
+th.sorted-desc::after {
+ content: " ↓";
+ opacity: 1;
+ color: var(--accent);
+}
+
+.panel-actions {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ margin-left: auto;
+}
+
+.btn-secondary {
+ background: transparent;
+ color: var(--text);
+ border: 1px solid var(--border);
+ padding: 0.35rem 0.75rem;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 0.8rem;
+ font-weight: 500;
+}
+
+.btn-secondary:hover {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
tr:hover td {
background: rgba(255, 255, 255, 0.03);
}