K线本地缓存、图表交互优化与交易记录表格修复

新增 kline_store 优先读本地库;修复加载中遮挡、支持缩放与交易时段刷新;修复交易记录操作列被裁切。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-15 17:27:31 +08:00
parent a9f4e2b1a5
commit b804bd19a7
7 changed files with 505 additions and 80 deletions
+229 -64
View File
@@ -5,6 +5,11 @@
var chart = null;
var currentPeriod = '15m';
var quoteTimer = null;
var klineTimer = null;
var lastData = null;
var klineLoading = false;
var FAST_PERIODS = ['timeshare', '1m', '2m', '5m', '15m', '1h', '2h', '4h'];
function getSymbol() {
var hidden = document.getElementById('market-symbol-hidden');
@@ -33,9 +38,40 @@
down: dark ? '#ff6b7a' : '#dc2626',
line: dark ? '#4cc2ff' : '#2563eb',
area: dark ? 'rgba(76,194,255,0.12)' : 'rgba(37,99,235,0.1)',
slider: dark ? '#1e2640' : '#cbd5e1',
sliderFill: dark ? '#4cc2ff' : '#2563eb',
};
}
function isTradingSession() {
var d = new Date();
var wd = d.getDay();
if (wd === 0) return false;
if (wd === 6 && d.getHours() < 21) return false;
var t = d.getHours() * 60 + d.getMinutes();
function inRange(sh, sm, eh, em) {
return t >= sh * 60 + sm && t < eh * 60 + em;
}
if (inRange(9, 0, 11, 30)) return true;
if (inRange(13, 30, 15, 0)) return true;
if (inRange(21, 0, 24, 0)) return true;
if (inRange(0, 0, 2, 30)) return true;
return false;
}
function klinePollMs() {
if (!isTradingSession()) return 0;
if (currentPeriod === 'timeshare' || FAST_PERIODS.indexOf(currentPeriod) >= 0) {
return 1000;
}
if (currentPeriod === 'd' || currentPeriod === 'w') return 30000;
return 5000;
}
function quotePollMs() {
return isTradingSession() ? 1000 : 10000;
}
function initChart() {
if (!chartEl || !window.echarts) return;
chart = echarts.init(chartEl);
@@ -45,42 +81,90 @@
document.addEventListener('click', function (e) {
if (e.target.closest('[data-theme-pick]')) {
setTimeout(function () {
if (chart && lastData) renderChart(lastData);
if (chart && lastData) renderChart(lastData, true);
}, 100);
}
});
}
var lastData = null;
function getDataZoom(c, preserve) {
var defStart = currentPeriod === 'timeshare' ? 60 : 75;
var zoom = [
{
type: 'inside',
xAxisIndex: [0, 1],
start: defStart,
end: 100,
zoomOnMouseWheel: true,
moveOnMouseMove: true,
moveOnMouseWheel: false,
},
{
type: 'slider',
xAxisIndex: [0, 1],
start: defStart,
end: 100,
height: 22,
bottom: 4,
borderColor: c.grid,
backgroundColor: c.bg,
fillerColor: c.area,
handleStyle: { color: c.sliderFill },
dataBackground: {
lineStyle: { color: c.grid },
areaStyle: { color: c.area },
},
textStyle: { color: c.text, fontSize: 10 },
},
];
if (preserve && chart) {
var opt = chart.getOption();
if (opt && opt.dataZoom) {
opt.dataZoom.forEach(function (z, i) {
if (zoom[i] && z.start != null && z.end != null) {
zoom[i].start = z.start;
zoom[i].end = z.end;
}
});
}
}
return zoom;
}
function renderChart(data) {
function renderChart(data, preserveZoom) {
if (!chart) return;
lastData = data;
var c = themeColors();
var bars = data.bars || [];
var times = bars.map(function (b) { return b.time; });
var isLine = data.chart_type === 'line' || data.period === 'timeshare';
var dataZoom = getDataZoom(c, preserveZoom);
var grids = [
{ left: 56, right: 20, top: 44, height: '50%' },
{ left: 56, right: 20, top: '66%', height: '14%' },
];
var base = {
backgroundColor: c.bg,
animation: false,
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
axisPointer: { link: [{ xAxisIndex: 'all' }] },
dataZoom: dataZoom,
grid: grids,
xAxis: [
{ type: 'category', data: times, gridIndex: 0, axisLabel: { color: c.text, fontSize: 10 }, axisLine: { lineStyle: { color: c.grid } } },
{ type: 'category', data: times, gridIndex: 1, axisLabel: { show: false }, axisLine: { lineStyle: { color: c.grid } } },
],
yAxis: [
{ scale: true, gridIndex: 0, splitLine: { lineStyle: { color: c.grid } }, axisLabel: { color: c.text } },
{ scale: true, gridIndex: 1, splitLine: { show: false }, axisLabel: { color: c.text, fontSize: 10 }, splitNumber: 2 },
],
};
if (isLine) {
var closes = bars.map(function (b) { return b.close; });
var vols = bars.map(function (b) { return b.volume; });
chart.setOption({
backgroundColor: c.bg,
animation: false,
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
axisPointer: { link: [{ xAxisIndex: 'all' }] },
grid: [
{ left: 56, right: 16, top: 40, height: '58%' },
{ left: 56, right: 16, top: '72%', height: '18%' },
],
xAxis: [
{ type: 'category', data: times, gridIndex: 0, axisLabel: { color: c.text, fontSize: 10 }, axisLine: { lineStyle: { color: c.grid } } },
{ type: 'category', data: times, gridIndex: 1, axisLabel: { show: false }, axisLine: { lineStyle: { color: c.grid } } },
],
yAxis: [
{ scale: true, gridIndex: 0, splitLine: { lineStyle: { color: c.grid } }, axisLabel: { color: c.text } },
{ scale: true, gridIndex: 1, splitLine: { show: false }, axisLabel: { color: c.text, fontSize: 10 }, splitNumber: 2 },
],
chart.setOption(Object.assign(base, {
series: [
{
name: '价格',
@@ -102,19 +186,17 @@
yAxisIndex: 1,
},
],
}, true);
}), true);
} else {
var candle = bars.map(function (b) { return [b.open, b.close, b.low, b.high]; });
var vols = bars.map(function (b, i) {
var vols = bars.map(function (b) {
var up = b.close >= b.open;
return {
value: b.volume,
itemStyle: { color: up ? c.up : c.down, opacity: 0.65 },
};
});
chart.setOption({
backgroundColor: c.bg,
animation: false,
chart.setOption(Object.assign(base, {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
@@ -132,19 +214,6 @@
].join('<br/>');
},
},
axisPointer: { link: [{ xAxisIndex: 'all' }] },
grid: [
{ left: 56, right: 16, top: 40, height: '58%' },
{ left: 56, right: 16, top: '72%', height: '18%' },
],
xAxis: [
{ type: 'category', data: times, gridIndex: 0, axisLabel: { color: c.text, fontSize: 10 }, axisLine: { lineStyle: { color: c.grid } } },
{ type: 'category', data: times, gridIndex: 1, axisLabel: { show: false }, axisLine: { lineStyle: { color: c.grid } } },
],
yAxis: [
{ scale: true, gridIndex: 0, splitLine: { lineStyle: { color: c.grid } }, axisLabel: { color: c.text } },
{ scale: true, gridIndex: 1, splitLine: { show: false }, axisLabel: { color: c.text, fontSize: 10 }, splitNumber: 2 },
],
series: [
{
name: 'K线',
@@ -167,11 +236,13 @@
yAxisIndex: 1,
},
],
}, true);
}), true);
}
var title = (data.chart_symbol || data.symbol || '') + ' · ' + periodLabel(data.period);
chart.setOption({ title: { text: title, left: 12, top: 8, textStyle: { color: c.title, fontSize: 13, fontWeight: 600 } } });
chart.setOption({
title: { text: title, left: 12, top: 8, textStyle: { color: c.title, fontSize: 13, fontWeight: 600 } },
});
}
function periodLabel(key) {
@@ -182,26 +253,61 @@
return key;
}
function hideEmptyOverlay() {
if (emptyEl) {
emptyEl.style.display = '';
}
if (wrapEl) wrapEl.classList.add('has-data');
}
function showEmptyOverlay(text) {
if (emptyEl) {
emptyEl.textContent = text;
emptyEl.style.display = 'flex';
}
if (wrapEl) wrapEl.classList.remove('has-data');
}
function setLoading(on) {
var btn = document.getElementById('market-load-btn');
if (btn) {
btn.disabled = on;
btn.textContent = on ? '加载中…' : '查看';
}
if (emptyEl && on) {
emptyEl.textContent = '加载中…';
emptyEl.style.display = 'flex';
if (on) {
showEmptyOverlay('加载中…');
} else if (lastData) {
hideEmptyOverlay();
}
}
function loadKline() {
var symbol = getSymbol();
if (!symbol) {
alert('请先选择或输入合约代码');
function updateRefreshHint() {
var el = document.getElementById('market-refresh-hint');
if (!el) return;
if (!getSymbol()) {
el.textContent = '';
return;
}
setLoading(true);
if (wrapEl) wrapEl.classList.remove('has-data');
if (isTradingSession()) {
var ms = klinePollMs();
var src = lastData && lastData.source === 'local' ? ' · 本地缓存' : '';
el.textContent = ms === 1000
? '交易中 · 1秒刷新' + src
: '交易中 · 自动刷新' + src;
} else {
el.textContent = '非交易时段 · 暂停高频刷新';
}
}
function loadKline(silent) {
var symbol = getSymbol();
if (!symbol) {
if (!silent) alert('请先选择或输入合约代码');
return;
}
if (klineLoading) return;
klineLoading = true;
if (!silent) setLoading(true);
var url = '/api/kline?symbol=' + encodeURIComponent(symbol) + '&period=' + encodeURIComponent(currentPeriod);
fetch(url)
@@ -209,22 +315,23 @@
return r.json().then(function (j) { return { ok: r.ok, data: j }; });
})
.then(function (res) {
if (!res.ok) {
throw new Error(res.data.error || '加载失败');
}
if (wrapEl) wrapEl.classList.add('has-data');
renderChart(res.data);
if (!res.ok) throw new Error(res.data.error || '加载失败');
hideEmptyOverlay();
renderChart(res.data, silent);
updateQuoteMeta(res.data);
startQuotePoll();
updateRefreshHint();
if (!quoteTimer) startQuotePoll();
if (!klineTimer) startKlinePoll();
})
.catch(function (err) {
if (emptyEl) {
emptyEl.textContent = err.message || '加载失败';
emptyEl.style.display = 'flex';
if (!silent) {
showEmptyOverlay(err.message || '加载失败');
}
if (wrapEl) wrapEl.classList.remove('has-data');
})
.finally(function () { setLoading(false); });
.finally(function () {
klineLoading = false;
if (!silent) setLoading(false);
});
}
function updateQuoteMeta(data) {
@@ -261,7 +368,48 @@
function startQuotePoll() {
if (quoteTimer) clearInterval(quoteTimer);
loadQuote();
quoteTimer = setInterval(loadQuote, 5000);
var ms = quotePollMs();
if (ms > 0) quoteTimer = setInterval(loadQuote, ms);
}
function startKlinePoll() {
if (klineTimer) clearInterval(klineTimer);
var ms = klinePollMs();
if (ms > 0 && getSymbol()) {
klineTimer = setInterval(function () {
loadKline(true);
updateRefreshHint();
}, ms);
}
}
function restartPollers() {
startQuotePoll();
startKlinePoll();
updateRefreshHint();
}
function shiftDataZoom(delta) {
if (!chart) return;
var opt = chart.getOption();
if (!opt || !opt.dataZoom || !opt.dataZoom.length) return;
var z = opt.dataZoom[0];
var span = (z.end - z.start) || 20;
var newSpan = Math.max(5, Math.min(100, span + delta));
var center = (z.start + z.end) / 2;
var start = Math.max(0, center - newSpan / 2);
var end = Math.min(100, center + newSpan / 2);
if (end - start < newSpan) {
if (start === 0) end = newSpan;
else start = end - newSpan;
}
chart.dispatchAction({ type: 'dataZoom', start: start, end: end });
}
function resetDataZoom() {
if (!chart) return;
var start = currentPeriod === 'timeshare' ? 60 : 75;
chart.dispatchAction({ type: 'dataZoom', start: start, end: 100 });
}
function bindPeriodTabs() {
@@ -273,25 +421,42 @@
tabs.querySelectorAll('.period-tab').forEach(function (el) { el.classList.remove('active'); });
btn.classList.add('active');
currentPeriod = btn.getAttribute('data-period') || '15m';
if (getSymbol()) loadKline();
restartPollers();
if (getSymbol()) loadKline(false);
});
}
function bindZoomButtons() {
var zoomIn = document.getElementById('chart-zoom-in');
var zoomOut = document.getElementById('chart-zoom-out');
var zoomReset = document.getElementById('chart-zoom-reset');
if (zoomIn) zoomIn.addEventListener('click', function () { shiftDataZoom(-12); });
if (zoomOut) zoomOut.addEventListener('click', function () { shiftDataZoom(12); });
if (zoomReset) zoomReset.addEventListener('click', resetDataZoom);
}
document.addEventListener('DOMContentLoaded', function () {
initChart();
bindPeriodTabs();
bindZoomButtons();
var active = document.querySelector('.period-tab.active');
if (active) currentPeriod = active.getAttribute('data-period') || '15m';
var loadBtn = document.getElementById('market-load-btn');
if (loadBtn) loadBtn.addEventListener('click', loadKline);
if (loadBtn) loadBtn.addEventListener('click', function () {
restartPollers();
loadKline(false);
});
var hidden = document.getElementById('market-symbol-hidden');
var input = document.getElementById('market-symbol-input');
if (hidden && hidden.value) {
if (input && !input.value) input.value = hidden.value;
loadKline();
restartPollers();
loadKline(false);
} else {
updateRefreshHint();
}
});
})();