740 lines
25 KiB
JavaScript
740 lines
25 KiB
JavaScript
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";
|
|
let llmPollTimer = null;
|
|
let llmInterpretMap = {};
|
|
let llmRunState = {
|
|
running: false,
|
|
batch_id: "",
|
|
current_symbol: "",
|
|
done: 0,
|
|
total: 0,
|
|
};
|
|
let llmSymbolOrder = [];
|
|
const llmExpandedSymbols = new Set();
|
|
const llmSeenDone = new Set();
|
|
|
|
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 = `
|
|
<thead><tr>
|
|
<th class="sortable" data-sort="rank">排名</th>
|
|
<th class="sortable" data-sort="symbol">合约</th>
|
|
<th class="chart-col">日线图</th>
|
|
<th class="sortable" data-sort="quote_volume">成交额 (USDT)</th>
|
|
<th class="sortable" data-sort="price_change_pct">涨跌幅</th>
|
|
<th class="funding-col">资金费率</th>
|
|
<th class="sortable" data-sort="tags">标记</th>
|
|
</tr></thead>`;
|
|
|
|
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('<span class="tag tag-vol">千万+</span>');
|
|
if (row.is_high_change) p.push('<span class="tag tag-chg">涨跌5%+</span>');
|
|
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 data-table="${periodId}">${TABLE_HEADER}<tbody id="${periodId}-body"></tbody></table>`;
|
|
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 = '<tr><td colspan="7" class="loading">暂无数据</td></tr>';
|
|
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 `<tr class="${hl}">
|
|
<td class="rank">${rank}</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(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 = '<tr><td colspan="7" class="loading">加载中…</td></tr>';
|
|
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 = `<tr><td colspan="7" class="error">${e.message}</td></tr>`;
|
|
}
|
|
}
|
|
|
|
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 = `<p class="loading">${statsData.message || "请等待三个周期数据就绪"}</p>`;
|
|
return;
|
|
}
|
|
|
|
if (!items.length) {
|
|
wrap.innerHTML = '<p class="loading">暂无符合条件的合约</p>';
|
|
return;
|
|
}
|
|
|
|
llmSymbolOrder = items.map((r) => r.symbol);
|
|
|
|
wrap.innerHTML = `
|
|
<table data-table="stats" class="stats-table">
|
|
<thead><tr>
|
|
<th>合约</th>
|
|
<th>今日排名</th><th>今日涨跌</th><th>今日成交额</th>
|
|
<th>昨日排名</th><th>昨日涨跌</th><th>昨日成交额</th>
|
|
<th>前日排名</th><th>前日涨跌</th><th>前日成交额</th>
|
|
<th>三日总成交额</th>
|
|
<th class="llm-col-head">AI解读</th>
|
|
</tr></thead>
|
|
<tbody id="stats-body"></tbody>
|
|
</table>`;
|
|
|
|
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 `<td class="${f === "pct" ? pctClass(pct) : ""}">${f === "pct" ? x.price_change_pct_fmt || "—" : f === "rank" ? x.rank ?? "—" : x.quote_volume_fmt || "—"}</td>`;
|
|
};
|
|
return `<tr class="row-highlight stats-row" data-symbol="${row.symbol}">
|
|
<td><strong>${row.symbol}</strong></td>
|
|
${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")}
|
|
<td class="stats-total-vol">${formatVol(row.total_quote_volume)}</td>
|
|
<td class="llm-col">${buildLlmCellHtml(row.symbol)}</td>
|
|
</tr>`;
|
|
})
|
|
.join("");
|
|
bindLlmFoldHandlers();
|
|
}
|
|
|
|
function getLlmCellState(symbol) {
|
|
const llm = llmInterpretMap[symbol];
|
|
if (llm?.content) {
|
|
const failed = String(llm.content).startsWith("[解读失败]");
|
|
return { kind: failed ? "failed" : "done", llm };
|
|
}
|
|
if (!llmRunState.running) return { kind: "idle" };
|
|
if (symbol === llmRunState.current_symbol) return { kind: "running" };
|
|
const idx = llmSymbolOrder.indexOf(symbol);
|
|
if (idx >= 0 && idx < llmRunState.done) return { kind: "done", llm: null };
|
|
return { kind: "pending" };
|
|
}
|
|
|
|
function buildLlmCellHtml(symbol) {
|
|
const st = getLlmCellState(symbol);
|
|
if (st.kind === "done" && !st.llm) {
|
|
return `<div class="llm-placeholder done">已完成,点「刷新解读」</div>`;
|
|
}
|
|
if (st.kind === "done" && st.llm) {
|
|
const t = (st.llm.created_at || "").replace("T", " ").slice(0, 19);
|
|
const open = llmExpandedSymbols.has(symbol) ? " open" : "";
|
|
return `<details class="llm-fold"${open} data-symbol="${symbol}">
|
|
<summary class="llm-summary done">查看解读 <span class="llm-time">${t}</span></summary>
|
|
<div class="llm-text">${escapeHtml(st.llm.content)}</div>
|
|
</details>`;
|
|
}
|
|
if (st.kind === "failed" && st.llm) {
|
|
return `<details class="llm-fold" data-symbol="${symbol}">
|
|
<summary class="llm-summary failed">解读失败</summary>
|
|
<div class="llm-text llm-err">${escapeHtml(st.llm.content)}</div>
|
|
</details>`;
|
|
}
|
|
if (st.kind === "running") {
|
|
return `<div class="llm-placeholder running">解读中…</div>`;
|
|
}
|
|
if (st.kind === "pending") {
|
|
return `<div class="llm-placeholder pending">排队等待</div>`;
|
|
}
|
|
return `<span class="muted">—</span>`;
|
|
}
|
|
|
|
function bindLlmFoldHandlers() {
|
|
document.querySelectorAll("details.llm-fold").forEach((el) => {
|
|
el.ontoggle = () => {
|
|
const sym = el.dataset.symbol;
|
|
if (!sym) return;
|
|
if (el.open) llmExpandedSymbols.add(sym);
|
|
else llmExpandedSymbols.delete(sym);
|
|
};
|
|
});
|
|
}
|
|
|
|
function updateStatsLlmRows() {
|
|
const body = document.getElementById("stats-body");
|
|
if (!body) return;
|
|
|
|
document.querySelectorAll("tr.stats-row[data-symbol]").forEach((tr) => {
|
|
const sym = tr.dataset.symbol;
|
|
const td = tr.querySelector("td.llm-col");
|
|
if (!td) return;
|
|
const wasOpen = td.querySelector("details.llm-fold")?.open;
|
|
td.innerHTML = buildLlmCellHtml(sym);
|
|
const det = td.querySelector("details.llm-fold");
|
|
if (det) {
|
|
det.ontoggle = () => {
|
|
if (det.open) llmExpandedSymbols.add(sym);
|
|
else llmExpandedSymbols.delete(sym);
|
|
};
|
|
if (wasOpen || llmExpandedSymbols.has(sym)) det.open = true;
|
|
}
|
|
});
|
|
|
|
for (const sym of Object.keys(llmInterpretMap)) {
|
|
if (llmSeenDone.has(sym)) continue;
|
|
const llm = llmInterpretMap[sym];
|
|
if (llm?.content && !String(llm.content).startsWith("[解读失败]")) {
|
|
llmSeenDone.add(sym);
|
|
const det = document.querySelector(`details.llm-fold[data-symbol="${sym}"]`);
|
|
if (det) {
|
|
det.open = true;
|
|
llmExpandedSymbols.add(sym);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s || "")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/\n/g, "<br>");
|
|
}
|
|
|
|
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 applyLlmPayload(data) {
|
|
if (data.batch_id) llmRunState.batch_id = data.batch_id;
|
|
if (data.running != null) {
|
|
llmRunState.running = !!data.running;
|
|
llmRunState.batch_id = data.batch_id || llmRunState.batch_id;
|
|
llmRunState.current_symbol = data.current_symbol || "";
|
|
llmRunState.done = data.done ?? llmRunState.done;
|
|
llmRunState.total = data.total ?? llmRunState.total;
|
|
}
|
|
const nextMap = { ...llmInterpretMap };
|
|
for (const item of data.items || []) {
|
|
if (item.symbol) nextMap[item.symbol] = item;
|
|
}
|
|
llmInterpretMap = nextMap;
|
|
if (document.getElementById("stats-body")) {
|
|
updateStatsLlmRows();
|
|
} else if (statsData?.ok) {
|
|
renderStatsTable();
|
|
}
|
|
}
|
|
|
|
async function loadLlmInterpretations() {
|
|
const q = llmRunState.batch_id
|
|
? `?batch_id=${encodeURIComponent(llmRunState.batch_id)}`
|
|
: "";
|
|
const res = await fetch(`/api/llm/interpretations${q}`);
|
|
if (!res.ok) throw new Error("加载解读失败");
|
|
const data = await res.json();
|
|
applyLlmPayload(data);
|
|
return data;
|
|
}
|
|
|
|
function updateLlmStatusText(st) {
|
|
const label = document.getElementById("llm-model-label");
|
|
const text = document.getElementById("llm-status-text");
|
|
if (label) label.textContent = st.enabled ? st.model : "未配置";
|
|
if (!text) return;
|
|
if (st.running) {
|
|
text.textContent = `解读中 ${st.done}/${st.total} · 当前 ${st.current_symbol || "—"}`;
|
|
} else {
|
|
text.textContent = st.enabled
|
|
? `就绪 · 已完成 ${Object.keys(llmInterpretMap).length} 条 · 批次 ${st.batch_id || dataBatchId(st)}`
|
|
: "请在 .env 配置 LLM_API_KEY";
|
|
}
|
|
}
|
|
|
|
function dataBatchId(st) {
|
|
const keys = Object.keys(llmInterpretMap);
|
|
return keys.length ? llmInterpretMap[keys[0]]?.batch_id || "—" : "—";
|
|
}
|
|
|
|
async function refreshLlmStatus() {
|
|
const res = await fetch("/api/llm/status");
|
|
if (!res.ok) throw new Error("状态获取失败");
|
|
const st = await res.json();
|
|
llmRunState.running = !!st.running;
|
|
llmRunState.batch_id = st.batch_id || llmRunState.batch_id;
|
|
llmRunState.current_symbol = st.current_symbol || "";
|
|
llmRunState.done = st.done ?? 0;
|
|
llmRunState.total = st.total ?? 0;
|
|
updateLlmStatusText(st);
|
|
return st;
|
|
}
|
|
|
|
function startLlmPolling() {
|
|
if (llmPollTimer) return;
|
|
llmPollTimer = setInterval(async () => {
|
|
try {
|
|
await refreshLlmStatus();
|
|
await loadLlmInterpretations();
|
|
const st = await fetch("/api/llm/status").then((r) => r.json());
|
|
if (!st.running) {
|
|
clearInterval(llmPollTimer);
|
|
llmPollTimer = null;
|
|
await loadLlmInterpretations();
|
|
}
|
|
} catch (e) {
|
|
console.warn("LLM poll:", e);
|
|
}
|
|
}, 3000);
|
|
}
|
|
|
|
function stopLlmPolling() {
|
|
if (llmPollTimer) {
|
|
clearInterval(llmPollTimer);
|
|
llmPollTimer = null;
|
|
}
|
|
}
|
|
|
|
async function refreshLlmAll() {
|
|
const text = document.getElementById("llm-status-text");
|
|
if (text) text.textContent = "刷新中…";
|
|
try {
|
|
const st = await refreshLlmStatus();
|
|
await loadLlmInterpretations();
|
|
if (st.running) startLlmPolling();
|
|
else stopLlmPolling();
|
|
} catch (e) {
|
|
if (text) text.textContent = "刷新失败";
|
|
console.error(e);
|
|
}
|
|
}
|
|
|
|
async function runLlmInterpret() {
|
|
const btn = document.getElementById("btn-llm-run");
|
|
if (btn) btn.disabled = true;
|
|
try {
|
|
const res = await fetch("/api/llm/interpret/run", { method: "POST" });
|
|
const data = await res.json();
|
|
if (!data.ok) {
|
|
alert(data.message || "启动失败");
|
|
return;
|
|
}
|
|
llmInterpretMap = {};
|
|
llmSeenDone.clear();
|
|
if (data.batch_id) llmRunState.batch_id = data.batch_id;
|
|
llmRunState.running = true;
|
|
await refreshLlmStatus();
|
|
await loadLlmInterpretations();
|
|
startLlmPolling();
|
|
if (document.getElementById("stats-body")) updateStatsLlmRows();
|
|
} catch (e) {
|
|
alert(e.message);
|
|
} finally {
|
|
if (btn) btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
function renderWecomDayRow(label, row) {
|
|
if (!row?.rank) {
|
|
return `<div class="wecom-day muted"><span class="wecom-day-label">${label}</span>—</div>`;
|
|
}
|
|
const pct = row.price_change_pct ?? 0;
|
|
return `<div class="wecom-day">
|
|
<span class="wecom-day-label">${label}</span>
|
|
<span class="wecom-rank-num">#${row.rank}</span>
|
|
<span class="wecom-vol">${row.quote_volume_fmt || row.quote_volume}</span>
|
|
<span class="${pctClass(pct)}">${row.price_change_pct_fmt || pct.toFixed(2) + "%"}</span>
|
|
<span class="wecom-fr">${row.funding_rate_fmt || "—"}</span>
|
|
</div>`;
|
|
}
|
|
|
|
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 = `<p class="error">${escapeHtml(payload?.message || "无法生成预览")}</p>`;
|
|
return;
|
|
}
|
|
if (meta) {
|
|
meta.textContent = `${payload.period_label || "—"} · ${payload.count} 个币种`;
|
|
}
|
|
if (!payload.items?.length) {
|
|
cards.innerHTML = '<p class="loading">暂无三日交集币种</p>';
|
|
return;
|
|
}
|
|
cards.innerHTML = payload.items
|
|
.map(
|
|
(it) => `
|
|
<article class="wecom-card">
|
|
<header class="wecom-card-head">
|
|
<span class="wecom-seq">${it.rank}</span>
|
|
<strong class="wecom-symbol">${it.symbol}</strong>
|
|
</header>
|
|
${renderWecomDayRow("昨日", it.yesterday)}
|
|
${renderWecomDayRow("今日", it.today)}
|
|
${renderWecomDayRow("前日", it.daybefore)}
|
|
</article>`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
async function loadWecomPreview() {
|
|
const cards = document.getElementById("wecom-preview-cards");
|
|
if (cards) cards.innerHTML = '<p class="loading">生成预览…</p>';
|
|
try {
|
|
const res = await fetch("/api/push/preview");
|
|
const data = await res.json();
|
|
renderWecomPreview(data);
|
|
} catch (e) {
|
|
if (cards) cards.innerHTML = `<p class="error">${e.message}</p>`;
|
|
}
|
|
}
|
|
|
|
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 =
|
|
'<p class="loading">统计中…</p>';
|
|
try {
|
|
const res = await fetch("/api/stats/three-day");
|
|
statsData = await res.json();
|
|
renderStatsTable();
|
|
await refreshLlmAll();
|
|
await loadWecomPreview();
|
|
} catch (e) {
|
|
document.getElementById("stats-table-wrap").innerHTML = `<p class="error">${e.message}</p>`;
|
|
}
|
|
}
|
|
|
|
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();
|
|
else refreshLlmAll();
|
|
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-llm-run")?.addEventListener("click", runLlmInterpret);
|
|
document.getElementById("btn-llm-refresh")?.addEventListener("click", () => refreshLlmAll());
|
|
|
|
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");
|