中控行情区与 K 线本地库(15 天滚动、按需拉取)

新增行情区单图与周期切换,K 线优先读 hub_kline.db,不足时经各实例 /api/hub/ohlcv 补齐;无后台定时更新。含回滚标签说明与单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-02 10:58:59 +08:00
parent ef99fb6c2e
commit ba681c7a58
16 changed files with 1298 additions and 3 deletions
+92
View File
@@ -1920,3 +1920,95 @@ body.login-page {
white-space: normal;
}
}
/* ---------- 行情区 ---------- */
.market-toolbar {
flex-wrap: wrap;
gap: 10px;
align-items: flex-end;
}
.market-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.72rem;
color: var(--muted);
}
.market-field select,
.market-field input {
min-width: 120px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--border-soft);
background: var(--bg-elevated);
color: var(--text);
font-family: var(--font);
}
.market-status {
font-size: 0.8rem;
color: var(--muted);
margin: 0 0 10px;
}
.market-status.err {
color: var(--red);
}
.market-status.warn {
color: #ffb84d;
}
.market-chart-wrap {
position: relative;
height: min(72vh, 640px);
min-height: 360px;
border: 1px solid var(--border-soft);
border-radius: var(--radius);
background: #0a1018;
overflow: hidden;
}
.market-chart-host {
width: 100%;
height: 100%;
}
.market-ohlcv-overlay {
position: absolute;
top: 10px;
left: 10px;
z-index: 4;
pointer-events: none;
padding: 10px 12px;
border-radius: 8px;
background: rgba(8, 14, 24, 0.88);
border: 1px solid var(--border-soft);
font-size: 0.78rem;
min-width: 200px;
}
.market-ohlcv-title {
font-weight: 600;
color: var(--accent);
margin-bottom: 6px;
display: flex;
gap: 8px;
}
.market-ohlcv-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 14px;
}
.market-ohlcv-grid .k {
color: var(--muted);
margin-right: 6px;
}
.market-ohlcv-grid .market-vol {
grid-column: 1 / -1;
}
+4
View File
@@ -245,6 +245,7 @@
function currentPage() {
const p = window.location.pathname.replace(/\/$/, "") || "/monitor";
if (p.includes("settings")) return "settings";
if (p.includes("market")) return "market";
return "monitor";
}
@@ -259,6 +260,9 @@
if (page === "monitor") startMonitorPoll();
else stopMonitorPoll();
if (page === "settings") loadSettingsUI();
if (page === "market" && window.hubMarketChart) {
window.hubMarketChart.init();
}
}
function stopMonitorPoll() {
+299
View File
@@ -0,0 +1,299 @@
/**
* 中控行情区:单图 + 周期切换,数据来自 /api/chart/ohlcv(本地库优先)。
*/
(function () {
const TF_ORDER = ["1m", "5m", "15m", "1h", "4h", "1d", "1w"];
const chartHost = document.getElementById("market-chart");
if (!chartHost) return;
const elExchange = document.getElementById("market-exchange");
const elSymbol = document.getElementById("market-symbol");
const elTf = document.getElementById("market-timeframe");
const elRefresh = document.getElementById("market-refresh");
const elStatus = document.getElementById("market-status");
const elUpdated = document.getElementById("market-updated");
const elO = document.getElementById("mkt-o");
const elH = document.getElementById("mkt-h");
const elL = document.getElementById("mkt-l");
const elC = document.getElementById("mkt-c");
const elV = document.getElementById("mkt-v");
const elSymLabel = document.getElementById("mkt-symbol-label");
const elTfLabel = document.getElementById("mkt-tf-label");
let chart = null;
let candleSeries = null;
let priceTick = null;
let rangeMarkers = [];
let lastCandles = [];
let chartMeta = null;
let loadToken = 0;
let marketInited = false;
function fmtVol(v) {
if (v == null || Number.isNaN(Number(v))) return "-";
const n = Number(v);
if (n >= 1e9) return (n / 1e9).toFixed(2) + "B";
if (n >= 1e6) return (n / 1e6).toFixed(2) + "M";
if (n >= 1e3) return (n / 1e3).toFixed(2) + "K";
return n.toFixed(2);
}
function paintOhlcv(bar) {
if (!bar) {
["o", "h", "l", "c", "v"].forEach(function (k) {
const el = { o: elO, h: elH, l: elL, c: elC, v: elV }[k];
if (el) el.textContent = "-";
});
return;
}
if (elO) elO.textContent = bar.open != null ? String(bar.open) : "-";
if (elH) elH.textContent = bar.high != null ? String(bar.high) : "-";
if (elL) elL.textContent = bar.low != null ? String(bar.low) : "-";
if (elC) elC.textContent = bar.close != null ? String(bar.close) : "-";
if (elV) elV.textContent = fmtVol(bar.volume);
}
function ensureChart() {
if (chart && candleSeries) return true;
if (!window.LightweightCharts) {
if (elStatus) {
elStatus.className = "market-status err";
elStatus.textContent = "图表库加载失败";
}
return false;
}
chart = LightweightCharts.createChart(chartHost, {
layout: { background: { color: "#0a1018" }, textColor: "#b8d4e8" },
grid: { vertLines: { color: "#1a2838" }, horzLines: { color: "#1a2838" } },
rightPriceScale: { borderColor: "#2a4058" },
timeScale: { borderColor: "#2a4058", timeVisible: true, secondsVisible: false },
crosshair: { mode: LightweightCharts.CrosshairMode ? LightweightCharts.CrosshairMode.Normal : 0 },
});
const opts = {
upColor: "#00ff9d",
downColor: "#ff4d6d",
borderVisible: false,
wickUpColor: "#00ff9d",
wickDownColor: "#ff4d6d",
};
if (typeof chart.addCandlestickSeries === "function") {
candleSeries = chart.addCandlestickSeries(opts);
} else if (
typeof chart.addSeries === "function" &&
window.LightweightCharts &&
window.LightweightCharts.CandlestickSeries
) {
candleSeries = chart.addSeries(window.LightweightCharts.CandlestickSeries, opts);
}
if (!candleSeries) return false;
chart.subscribeCrosshairMove(function (param) {
if (!param || !param.time || !param.seriesData) return;
const d = param.seriesData.get(candleSeries);
if (!d) return;
paintOhlcv({
open: d.open,
high: d.high,
low: d.low,
close: d.close,
volume: d.volume,
});
});
window.addEventListener("resize", function () {
if (!chart) return;
chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight });
});
chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight });
return true;
}
function clearMarkers() {
rangeMarkers.forEach(function (m) {
try {
candleSeries.removePriceLine(m);
} catch (e) {}
});
rangeMarkers = [];
}
function addRangeMarkers(data) {
clearMarkers();
if (!candleSeries || !data) return;
const hi = data.range_high;
const lo = data.range_low;
if (hi && hi.price != null) {
rangeMarkers.push(
candleSeries.createPriceLine({
price: Number(hi.price),
color: "#ffb84d",
lineWidth: 1,
lineStyle: 2,
axisLabelVisible: true,
title: "区间高",
})
);
}
if (lo && lo.price != null) {
rangeMarkers.push(
candleSeries.createPriceLine({
price: Number(lo.price),
color: "#4cd97f",
lineWidth: 1,
lineStyle: 2,
axisLabelVisible: true,
title: "区间低",
})
);
}
}
function readQuery() {
const qs = new URLSearchParams(window.location.search);
const ex = qs.get("exchange_key") || qs.get("exchange") || "";
const sym = qs.get("symbol") || "";
const tf = qs.get("timeframe") || "";
if (ex && elExchange) elExchange.value = ex;
if (sym && elSymbol) elSymbol.value = sym;
if (tf && elTf) elTf.value = tf;
}
async function loadMeta() {
const r = await fetch("/api/chart/meta", { credentials: "same-origin" });
chartMeta = await r.json();
if (!elExchange || !chartMeta.exchanges) return;
elExchange.innerHTML = "";
chartMeta.exchanges.forEach(function (ex) {
const opt = document.createElement("option");
opt.value = ex.key || ex.id;
opt.textContent = ex.name || ex.key;
elExchange.appendChild(opt);
});
readQuery();
}
async function loadChart(force) {
if (!ensureChart()) return;
const exKey = (elExchange && elExchange.value) || "";
const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
const tf = (elTf && elTf.value) || "5m";
if (!exKey || !sym) {
if (elStatus) {
elStatus.className = "market-status err";
elStatus.textContent = "请选择交易所并输入币种";
}
return;
}
const myToken = ++loadToken;
if (elStatus) {
elStatus.className = "market-status";
elStatus.textContent = "加载中…";
}
if (elSymLabel) elSymLabel.textContent = sym;
if (elTfLabel) elTfLabel.textContent = tf;
const qs = new URLSearchParams({
exchange_key: exKey,
symbol: sym,
timeframe: tf,
});
if (force) qs.set("refresh", "1");
try {
const r = await fetch("/api/chart/ohlcv?" + qs.toString(), { credentials: "same-origin" });
const data = await r.json();
if (myToken !== loadToken) return;
if (!r.ok) {
throw new Error(data.detail || data.msg || "请求失败");
}
if (!data.ok || !data.candles || !data.candles.length) {
throw new Error(data.msg || "无 K 线");
}
priceTick = data.price_tick;
lastCandles = data.candles;
candleSeries.setData(data.candles);
chart.timeScale().fitContent();
addRangeMarkers(data);
const ohlcv = data.ohlcv || {};
paintOhlcv({
open: ohlcv.open,
high: ohlcv.high,
low: ohlcv.low,
close: ohlcv.close,
volume: ohlcv.volume,
});
let hint =
"已加载 " +
data.candles.length +
" 根(库 " +
(data.from_cache || 0) +
" / 新拉 " +
(data.fetched || 0) +
")· 保留 " +
(data.retention_days || 15) +
" 天";
if (data.stale && data.stale_message) {
hint += " · 缓存:" + data.stale_message;
}
if (elStatus) {
elStatus.className = data.stale ? "market-status warn" : "market-status";
elStatus.textContent = hint;
}
if (elUpdated) elUpdated.textContent = data.updated_at || "--";
} catch (e) {
if (myToken !== loadToken) return;
if (elStatus) {
elStatus.className = "market-status err";
elStatus.textContent = String(e.message || e);
}
}
}
function bind() {
if (elRefresh) {
elRefresh.addEventListener("click", function () {
loadChart(true);
});
}
if (elTf) {
elTf.addEventListener("change", function () {
loadChart(false);
});
}
if (elExchange) {
elExchange.addEventListener("change", function () {
loadChart(false);
});
}
if (elSymbol) {
elSymbol.addEventListener("keydown", function (e) {
if (e.key === "Enter") loadChart(false);
});
}
const btnLoad = document.getElementById("market-load");
if (btnLoad) btnLoad.addEventListener("click", function () {
loadChart(false);
});
}
window.hubMarketChart = {
init: async function () {
if (!marketInited) {
marketInited = true;
await loadMeta();
bind();
}
await loadChart(false);
},
reload: function (force) {
loadChart(!!force);
},
};
if (document.getElementById("page-market") && !document.getElementById("page-market").classList.contains("hidden")) {
window.hubMarketChart.init();
}
})();
+62 -2
View File
@@ -8,7 +8,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
<link rel="stylesheet" href="/assets/app.css?v=20260530-hub-iframe" />
<link rel="stylesheet" href="/assets/app.css?v=20260528-hub-market" />
</head>
<body>
<div class="app-bg" aria-hidden="true"></div>
@@ -25,6 +25,7 @@
<span id="sys-status" class="sys-pill" title="系统状态">SYNC</span>
<nav class="top-nav">
<a href="/monitor" id="nav-monitor">监控区</a>
<a href="/market" id="nav-market">行情区</a>
<a href="/settings" id="nav-settings">系统设置</a>
</nav>
<button type="button" id="btn-logout" class="ghost" title="退出登录">退出</button>
@@ -56,6 +57,63 @@
<div id="monitor-grid" class="grid-monitor"></div>
</div>
<div id="page-market" class="page hidden">
<div class="page-head">
<h1><span class="head-tag">MKT</span> 行情区</h1>
<p class="page-desc">按需拉取 K 线,本地库保留 15 天(无后台自动更新)</p>
</div>
<details class="hint-box">
<summary>数据说明</summary>
<div class="hint-body">
优先读中控 <code>data/hub_kline.db</code>,不足时向所选交易所实例请求并写入库。<br />
日内周期最多 1000 根,日线/周线最多 500 根。仅在本页操作或点「刷新」时拉取交易所。
</div>
</details>
<div class="market-toolbar toolbar">
<label class="market-field">
<span>交易所</span>
<select id="market-exchange"></select>
</label>
<label class="market-field">
<span>币种</span>
<input id="market-symbol" type="text" placeholder="TON/USDT" autocomplete="off" />
</label>
<label class="market-field">
<span>周期</span>
<select id="market-timeframe">
<option value="1m">1m</option>
<option value="5m" selected>5m</option>
<option value="15m">15m</option>
<option value="1h">1h</option>
<option value="4h">4h</option>
<option value="1d">1d</option>
<option value="1w">1w</option>
</select>
</label>
<button type="button" id="market-load" class="primary">加载</button>
<button type="button" id="market-refresh" class="ghost">强制刷新</button>
<span class="toolbar-spacer"></span>
<span id="market-updated" class="toolbar-meta"></span>
</div>
<p id="market-status" class="market-status"></p>
<div class="market-chart-wrap">
<div class="market-ohlcv-overlay" aria-label="K线详情">
<div class="market-ohlcv-title">
<span id="mkt-symbol-label"></span>
<span id="mkt-tf-label">5m</span>
</div>
<div class="market-ohlcv-grid">
<div><span class="k"></span><span id="mkt-o"></span></div>
<div><span class="k"></span><span id="mkt-h"></span></div>
<div><span class="k"></span><span id="mkt-l"></span></div>
<div><span class="k"></span><span id="mkt-c"></span></div>
<div class="market-vol"><span class="k"></span><span id="mkt-v"></span></div>
</div>
</div>
<div id="market-chart" class="market-chart-host"></div>
</div>
</div>
<div id="instance-frame-shell" class="instance-frame-shell hidden" aria-hidden="true">
<div class="instance-frame-toolbar">
<button type="button" id="instance-frame-back" class="ghost">← 返回监控</button>
@@ -120,6 +178,8 @@
</div>
<div id="toast"></div>
<script src="/assets/app.js?v=20260530-hub-embed-sso"></script>
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<script src="/assets/chart.js?v=20260528-hub-market"></script>
<script src="/assets/app.js?v=20260528-hub-market"></script>
</body>
</html>