增加大模型

This commit is contained in:
dekun
2026-05-26 09:38:23 +08:00
parent e0ec3f87a9
commit 27031ab676
14 changed files with 797 additions and 69 deletions
+151 -9
View File
@@ -1,5 +1,3 @@
const REFRESH_MS = 60_000;
const PERIOD_API = {
today: "/api/today/top30",
yesterday: "/api/yesterday/top30",
@@ -12,8 +10,13 @@ const tableState = {
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 = {};
const SORT_KEYS = {
rank: (r) => Number(r.rank) || 0,
@@ -171,15 +174,49 @@ function setPeriodData(periodId, data) {
renderPeriodTable(periodId);
}
async function loadPeriod(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 = "今日数据已刷新";
document.getElementById("status").textContent = force ? "今日数据已手动刷新" : "今日数据已加载";
}
} catch (e) {
if (tbody) tbody.innerHTML = `<tr><td colspan="7" class="error">${e.message}</td></tr>`;
@@ -258,6 +295,7 @@ function renderStatsTable() {
<th>昨日排名</th><th>昨日涨跌</th><th>昨日成交额</th>
<th>前日排名</th><th>前日涨跌</th><th>前日成交额</th>
<th>三日总成交额</th>
<th>AI解读</th>
</tr></thead>
<tbody id="stats-body"></tbody>
</table>`;
@@ -271,30 +309,128 @@ function renderStatsTable() {
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>`;
};
const llm = llmInterpretMap[row.symbol];
const llmCell = llm
? `<details class="llm-inline"><summary>AI解读</summary><div class="llm-text">${escapeHtml(llm.content)}</div><small>${llm.created_at?.slice(0, 19) || ""}</small></details>`
: '<span class="muted">—</span>';
return `<tr class="row-highlight">
<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>${formatVol(row.total_quote_volume)}</td>
<td class="llm-col">${llmCell}</td>
</tr>`;
})
.join("");
}
function escapeHtml(s) {
return String(s || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.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));
}
async function loadLlmInterpretations() {
try {
const res = await fetch("/api/llm/interpretations");
const data = await res.json();
llmInterpretMap = {};
for (const item of data.items || []) {
llmInterpretMap[item.symbol] = item;
}
renderLlmList(data.items || []);
if (statsData?.ok) renderStatsTable();
} catch {
/* ignore */
}
}
function renderLlmList(items) {
const el = document.getElementById("llm-interpret-list");
if (!el) return;
if (!items.length) {
el.innerHTML = '<p class="loading">暂无解读记录</p>';
return;
}
el.innerHTML = items
.map(
(it) => `
<article class="llm-card">
<h4>${it.symbol} <small>${it.batch_id}</small></h4>
<div class="llm-text">${escapeHtml(it.content)}</div>
<time>${(it.created_at || "").replace("T", " ").slice(0, 19)}</time>
</article>`
)
.join("");
}
async function refreshLlmStatus() {
try {
const res = await fetch("/api/llm/status");
const st = await res.json();
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 || "—"} · 批次 ${st.batch_id}`;
if (!llmPollTimer) {
llmPollTimer = setInterval(async () => {
await refreshLlmStatus();
await loadLlmInterpretations();
if (!(await fetch("/api/llm/status").then((r) => r.json())).running) {
clearInterval(llmPollTimer);
llmPollTimer = null;
}
}, 5000);
}
} else {
text.textContent = st.enabled
? `就绪 · 每币 ${st.interval_sec}s · 最近批次 ${st.batch_id || "—"}`
: "请在 .env 配置 LLM_API_KEY";
if (llmPollTimer) {
clearInterval(llmPollTimer);
llmPollTimer = null;
}
}
} catch {
/* ignore */
}
}
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 || "启动失败");
await refreshLlmStatus();
} catch (e) {
alert(e.message);
} finally {
if (btn) btn.disabled = false;
}
}
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();
await loadLlmInterpretations();
renderStatsTable();
await refreshLlmStatus();
} catch (e) {
document.getElementById("stats-table-wrap").innerHTML = `<p class="error">${e.message}</p>`;
}
@@ -330,6 +466,10 @@ function switchView(view) {
if (view === "stats") {
if (!statsData) loadStats();
else {
refreshLlmStatus();
loadLlmInterpretations();
}
return;
}
@@ -358,10 +498,16 @@ document.querySelectorAll("[data-reset]").forEach((btn) => {
document.getElementById("btn-refresh").addEventListener("click", async () => {
document.getElementById("status").textContent = "刷新中…";
await fetch("/api/refresh/today", { method: "POST" });
await loadPeriod("today");
await loadPeriod("today", true);
if (currentView === "stats") await loadStats();
});
document.getElementById("btn-llm-run")?.addEventListener("click", runLlmInterpret);
document.getElementById("btn-llm-refresh")?.addEventListener("click", async () => {
await loadLlmInterpretations();
await refreshLlmStatus();
});
document.getElementById("btn-reload-stats")?.addEventListener("click", () => {
statsData = null;
loadStats();
@@ -371,7 +517,3 @@ document.getElementById("btn-export-stats")?.addEventListener("click", exportSta
loadPeriod("today");
loadPeriod("yesterday");
loadPeriod("daybefore");
setInterval(() => {
if (currentView === "today") loadPeriod("today");
}, REFRESH_MS);
+120 -32
View File
@@ -1,10 +1,13 @@
/** 日 K + 成交量(Canvas 高清) */
/** 日 K + 成交量(Canvas 高清)· 浏览器 localStorage 缓存 · 点击全屏 */
const chartDataCache = new Map();
const chartQueue = [];
let chartQueueRunning = false;
const CHART_FETCH_GAP_MS = 120;
const LS_KLINE_PREFIX = "ba_kline_";
const KLINE_TTL_MS = 60 * 60 * 1000;
const COLORS = {
bg: "#0d1118",
grid: "#2a3548",
@@ -16,7 +19,40 @@ const COLORS = {
};
const MINI_SIZE = { w: 380, h: 100 };
const MODAL_SIZE = { w: 1280, h: 720 };
function modalSize() {
const fs = document.fullscreenElement;
if (fs) {
return {
w: Math.max(800, window.innerWidth - 48),
h: Math.max(480, window.innerHeight - 100),
};
}
return { w: 1280, h: 720 };
}
function loadKlineFromLS(symbol) {
try {
const raw = localStorage.getItem(LS_KLINE_PREFIX + symbol);
if (!raw) return null;
const obj = JSON.parse(raw);
if (!obj?.candles?.length || Date.now() - (obj.ts || 0) > KLINE_TTL_MS) return null;
return obj;
} catch {
return null;
}
}
function saveKlineToLS(symbol, candles, source) {
try {
localStorage.setItem(
LS_KLINE_PREFIX + symbol,
JSON.stringify({ ts: Date.now(), candles, source })
);
} catch {
/* quota */
}
}
function enqueueCharts(root) {
root.querySelectorAll(".mini-chart[data-symbol]").forEach((box) => {
@@ -66,7 +102,7 @@ function drawCandlestickChart(canvas, candles, options = {}) {
if (!canvas || !candles.length) return;
const large = options.large === true;
const size = large ? MODAL_SIZE : MINI_SIZE;
const size = large ? modalSize() : MINI_SIZE;
const volRatio = large ? 0.22 : 0.32;
const pad = large
? { t: 16, r: 16, b: 28, l: 56 }
@@ -164,7 +200,7 @@ function drawCandlestickChart(canvas, candles, options = {}) {
function drawEmptyChart(canvas, large = false) {
if (!canvas) return;
const size = large ? MODAL_SIZE : MINI_SIZE;
const size = large ? modalSize() : MINI_SIZE;
const { ctx, w, h } = setupCanvas(canvas, size.w, size.h);
ctx.fillStyle = "#1a2332";
ctx.fillRect(0, 0, w, h);
@@ -173,6 +209,32 @@ function drawEmptyChart(canvas, large = false) {
ctx.fillText("暂无数据", w / 2 - 28, h / 2);
}
async function fetchKlines(symbol) {
let candles = chartDataCache.get(symbol);
let source = "memory";
if (candles) return { candles, source };
const ls = loadKlineFromLS(symbol);
if (ls) {
candles = ls.candles;
source = "browser";
chartDataCache.set(symbol, candles);
return { candles, source: ls.source || source };
}
const res = await fetch(`/api/chart/${symbol}/daily?limit=300`);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || res.statusText);
}
const data = await res.json();
candles = data.candles || [];
source = data.source || "db";
chartDataCache.set(symbol, candles);
saveKlineToLS(symbol, candles, source);
return { candles, source };
}
async function loadMiniChart(box) {
const symbol = box.dataset.symbol;
if (!symbol) return;
@@ -182,26 +244,22 @@ async function loadMiniChart(box) {
if (status) status.textContent = "加载…";
try {
let candles = chartDataCache.get(symbol);
let source = "cache";
if (!candles) {
const res = await fetch(`/api/chart/${symbol}/daily?limit=300`);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || res.statusText);
}
const data = await res.json();
candles = data.candles || [];
source = data.source || "db";
chartDataCache.set(symbol, candles);
}
const { candles, source } = await fetchKlines(symbol);
if (!candles.length) throw new Error("无K线数据");
drawCandlestickChart(canvas, candles, { large: false });
box.dataset.loaded = "1";
const srcLabel =
source === "db" ? "本地" : source === "db_stale" ? "本地(旧)" : source === "cache" ? "缓存" : "同步";
source === "browser"
? "浏览器"
: source === "db"
? "本地"
: source === "db_stale"
? "本地(旧)"
: source === "cache"
? "缓存"
: "同步";
if (status) status.textContent = `${candles.length}日·${srcLabel}`;
box.title = `${symbol} 日K+量 ${candles.length}根 (${srcLabel}),点击放大`;
box.title = `${symbol} 日K+量 ${candles.length}根 (${srcLabel}),点击全屏`;
} catch (e) {
if (status) status.textContent = "—";
box.title = `${symbol}: ${e.message}`;
@@ -211,6 +269,36 @@ async function loadMiniChart(box) {
}
}
let chartModalSymbol = "";
function closeChartModal() {
const modal = document.getElementById("chart-modal");
if (!modal) return;
modal.classList.add("hidden");
if (document.fullscreenElement) {
document.exitFullscreen?.().catch(() => {});
}
}
function openChartModal(symbol) {
const candles = chartDataCache.get(symbol);
if (!candles?.length) return;
chartModalSymbol = symbol;
const modal = document.getElementById("chart-modal");
modal.classList.remove("hidden");
document.getElementById("chart-modal-title").textContent =
`${symbol} · 日K + 成交量(${candles.length} 根)`;
const canvas = document.getElementById("chart-modal-canvas");
drawCandlestickChart(canvas, candles, { large: true });
const inner = modal.querySelector(".chart-modal-inner");
const req = inner.requestFullscreen || inner.webkitRequestFullscreen;
if (req) {
req.call(inner).catch(() => {});
}
}
function setupChartModal() {
let modal = document.getElementById("chart-modal");
if (!modal) {
@@ -221,33 +309,33 @@ function setupChartModal() {
<div class="chart-modal-inner">
<button type="button" class="chart-modal-close" aria-label="关闭">×</button>
<h3 id="chart-modal-title"></h3>
<p class="chart-modal-hint">日K + 成交量 · 300根 · 滚轮可缩放页面</p>
<p class="chart-modal-hint">日K + 成交量 · 300根 · 点击全屏 · Esc 退出</p>
<div class="chart-modal-canvas-wrap">
<canvas id="chart-modal-canvas"></canvas>
</div>
</div>`;
document.body.appendChild(modal);
modal.querySelector(".chart-modal-close").onclick = () =>
modal.classList.add("hidden");
modal.querySelector(".chart-modal-close").onclick = closeChartModal;
modal.addEventListener("click", (e) => {
if (e.target === modal) modal.classList.add("hidden");
if (e.target === modal) closeChartModal();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") modal.classList.add("hidden");
if (e.key === "Escape") closeChartModal();
});
document.addEventListener("fullscreenchange", () => {
if (!chartModalSymbol) return;
const canvas = document.getElementById("chart-modal-canvas");
const candles = chartDataCache.get(chartModalSymbol);
if (canvas && candles?.length) {
drawCandlestickChart(canvas, candles, { large: true });
}
});
}
document.body.addEventListener("click", (e) => {
const box = e.target.closest(".mini-chart[data-symbol]");
if (!box || box.dataset.loaded !== "1") return;
const symbol = box.dataset.symbol;
const candles = chartDataCache.get(symbol);
if (!candles) return;
modal.classList.remove("hidden");
document.getElementById("chart-modal-title").textContent =
`${symbol} · 日K + 成交量(${candles.length} 根)`;
const canvas = document.getElementById("chart-modal-canvas");
drawCandlestickChart(canvas, candles, { large: true });
openChartModal(box.dataset.symbol);
});
}
+14 -2
View File
@@ -9,7 +9,7 @@
<body>
<header class="site-header">
<h1>币安 U本位合约 · 成交额排名</h1>
<p class="subtitle">北京时间 08:00 切日 · Top30 · 日K+成交量+资金费率</p>
<p class="subtitle">北京时间 08:00 切日 · Top30 · 今日每4小时自动刷新+手动 · 08:05 AI解读三日交集</p>
</header>
<nav class="main-nav" id="main-nav">
@@ -22,7 +22,7 @@
<main id="view-today" class="view-panel active">
<section class="panel">
<div class="panel-head">
<h2>今日周期 <span class="live">实时</span></h2>
<h2>今日周期 <span class="live">4h+手动</span></h2>
<span class="period" id="today-period"></span>
<span class="updated" id="today-updated"></span>
<div class="panel-actions">
@@ -78,6 +78,18 @@
<p class="stats-desc" id="stats-desc"></p>
<div class="table-wrap" id="stats-table-wrap"></div>
</section>
<section class="panel llm-panel">
<div class="panel-head">
<h2>大模型解读 <span class="llm-model" id="llm-model-label"></span></h2>
<span class="updated" id="llm-status-text"></span>
<div class="panel-actions">
<button type="button" class="btn-secondary" id="btn-llm-run">开始解读</button>
<button type="button" class="btn-secondary" id="btn-llm-refresh">刷新解读</button>
</div>
</div>
<p class="stats-desc">每日 08:05(北京时间)自动对「三日 Top30 交集」逐币解读,每币约 3 分钟;启动时也会自动跑一轮(需配置 LLM_API_KEY)。</p>
<div id="llm-interpret-list" class="llm-list"></div>
</section>
</main>
<footer>
+74
View File
@@ -339,6 +339,80 @@ button:hover {
overflow: auto;
}
.chart-modal-inner:fullscreen {
width: 100vw;
height: 100vh;
max-width: none;
max-height: none;
border-radius: 0;
display: flex;
flex-direction: column;
}
.chart-modal-inner:fullscreen .chart-modal-canvas-wrap {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.llm-panel {
margin-top: 1rem;
}
.llm-model {
font-size: 0.75rem;
color: var(--accent);
font-weight: normal;
}
.llm-list {
display: grid;
gap: 0.75rem;
max-height: 420px;
overflow-y: auto;
}
.llm-card {
background: #121a26;
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.75rem 1rem;
}
.llm-card h4 {
margin: 0 0 0.5rem;
font-size: 0.95rem;
}
.llm-card h4 small {
color: var(--muted);
font-weight: normal;
}
.llm-text {
font-size: 0.88rem;
line-height: 1.55;
color: var(--text);
white-space: pre-wrap;
}
.llm-inline summary {
cursor: pointer;
color: var(--accent);
font-size: 0.85rem;
}
.llm-col {
max-width: 280px;
font-size: 0.82rem;
}
.muted {
color: var(--muted);
}
.chart-modal-inner h3 {
margin: 0 0 0.25rem;
font-size: 1.15rem;