b804bd19a7
新增 kline_store 优先读本地库;修复加载中遮挡、支持缩放与交易时段刷新;修复交易记录操作列被裁切。 Co-authored-by: Cursor <cursoragent@cursor.com>
463 lines
17 KiB
JavaScript
463 lines
17 KiB
JavaScript
(function () {
|
|
var chartEl = document.getElementById('market-chart');
|
|
var emptyEl = document.getElementById('market-chart-empty');
|
|
var wrapEl = chartEl && chartEl.parentElement;
|
|
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');
|
|
var input = document.getElementById('market-symbol-input');
|
|
if (hidden && hidden.value) return hidden.value.trim();
|
|
if (input && input.value) return input.value.trim();
|
|
return '';
|
|
}
|
|
|
|
function getMarketCodes() {
|
|
return {
|
|
symbol: getSymbol(),
|
|
market_code: (document.getElementById('market-market-code') || {}).value || '',
|
|
sina_code: (document.getElementById('market-sina-code') || {}).value || '',
|
|
};
|
|
}
|
|
|
|
function themeColors() {
|
|
var dark = document.documentElement.getAttribute('data-theme') !== 'light';
|
|
return {
|
|
bg: dark ? '#0a0c14' : '#f4f7fc',
|
|
text: dark ? '#a8b0c8' : '#5c6578',
|
|
title: dark ? '#e8eaf6' : '#1a2233',
|
|
grid: dark ? '#1a2038' : '#e2e8f0',
|
|
up: dark ? '#4cd97f' : '#15803d',
|
|
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);
|
|
window.addEventListener('resize', function () {
|
|
if (chart) chart.resize();
|
|
});
|
|
document.addEventListener('click', function (e) {
|
|
if (e.target.closest('[data-theme-pick]')) {
|
|
setTimeout(function () {
|
|
if (chart && lastData) renderChart(lastData, true);
|
|
}, 100);
|
|
}
|
|
});
|
|
}
|
|
|
|
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, 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(Object.assign(base, {
|
|
series: [
|
|
{
|
|
name: '价格',
|
|
type: 'line',
|
|
data: closes,
|
|
smooth: true,
|
|
showSymbol: false,
|
|
lineStyle: { width: 1.5, color: c.line },
|
|
areaStyle: { color: c.area },
|
|
xAxisIndex: 0,
|
|
yAxisIndex: 0,
|
|
},
|
|
{
|
|
name: '成交量',
|
|
type: 'bar',
|
|
data: vols,
|
|
itemStyle: { color: c.area },
|
|
xAxisIndex: 1,
|
|
yAxisIndex: 1,
|
|
},
|
|
],
|
|
}), true);
|
|
} else {
|
|
var candle = bars.map(function (b) { return [b.open, b.close, b.low, b.high]; });
|
|
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(Object.assign(base, {
|
|
tooltip: {
|
|
trigger: 'axis',
|
|
axisPointer: { type: 'cross' },
|
|
formatter: function (params) {
|
|
var idx = params[0] && params[0].dataIndex;
|
|
if (idx == null || !bars[idx]) return '';
|
|
var b = bars[idx];
|
|
return [
|
|
b.time,
|
|
'开 ' + b.open,
|
|
'高 ' + b.high,
|
|
'低 ' + b.low,
|
|
'收 ' + b.close,
|
|
'量 ' + b.volume,
|
|
].join('<br/>');
|
|
},
|
|
},
|
|
series: [
|
|
{
|
|
name: 'K线',
|
|
type: 'candlestick',
|
|
data: candle,
|
|
itemStyle: {
|
|
color: c.up,
|
|
color0: c.down,
|
|
borderColor: c.up,
|
|
borderColor0: c.down,
|
|
},
|
|
xAxisIndex: 0,
|
|
yAxisIndex: 0,
|
|
},
|
|
{
|
|
name: '成交量',
|
|
type: 'bar',
|
|
data: vols,
|
|
xAxisIndex: 1,
|
|
yAxisIndex: 1,
|
|
},
|
|
],
|
|
}), 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 } },
|
|
});
|
|
}
|
|
|
|
function periodLabel(key) {
|
|
var tabs = document.querySelectorAll('.period-tab');
|
|
for (var i = 0; i < tabs.length; i++) {
|
|
if (tabs[i].getAttribute('data-period') === key) return tabs[i].textContent;
|
|
}
|
|
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 (on) {
|
|
showEmptyOverlay('加载中…');
|
|
} else if (lastData) {
|
|
hideEmptyOverlay();
|
|
}
|
|
}
|
|
|
|
function updateRefreshHint() {
|
|
var el = document.getElementById('market-refresh-hint');
|
|
if (!el) return;
|
|
if (!getSymbol()) {
|
|
el.textContent = '';
|
|
return;
|
|
}
|
|
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)
|
|
.then(function (r) {
|
|
return r.json().then(function (j) { return { ok: r.ok, data: j }; });
|
|
})
|
|
.then(function (res) {
|
|
if (!res.ok) throw new Error(res.data.error || '加载失败');
|
|
hideEmptyOverlay();
|
|
renderChart(res.data, silent);
|
|
updateQuoteMeta(res.data);
|
|
updateRefreshHint();
|
|
if (!quoteTimer) startQuotePoll();
|
|
if (!klineTimer) startKlinePoll();
|
|
})
|
|
.catch(function (err) {
|
|
if (!silent) {
|
|
showEmptyOverlay(err.message || '加载失败');
|
|
}
|
|
})
|
|
.finally(function () {
|
|
klineLoading = false;
|
|
if (!silent) setLoading(false);
|
|
});
|
|
}
|
|
|
|
function updateQuoteMeta(data) {
|
|
var meta = document.getElementById('market-quote-meta');
|
|
if (meta) {
|
|
meta.textContent = data.count ? ('共 ' + data.count + ' 根 · ' + periodLabel(data.period)) : '';
|
|
}
|
|
var nameEl = document.getElementById('market-quote-name');
|
|
var hiddenName = document.getElementById('market-symbol-name');
|
|
if (nameEl) {
|
|
nameEl.textContent = (hiddenName && hiddenName.value) || data.symbol || '—';
|
|
}
|
|
}
|
|
|
|
function loadQuote() {
|
|
var codes = getMarketCodes();
|
|
if (!codes.symbol) return;
|
|
var q = 'symbol=' + encodeURIComponent(codes.symbol);
|
|
if (codes.market_code) q += '&market_code=' + encodeURIComponent(codes.market_code);
|
|
if (codes.sina_code) q += '&sina_code=' + encodeURIComponent(codes.sina_code);
|
|
fetch('/api/market_quote?' + q)
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
var priceEl = document.getElementById('market-quote-price');
|
|
var nameEl = document.getElementById('market-quote-name');
|
|
if (nameEl && data.name) nameEl.textContent = data.name + ' ' + (data.symbol || '');
|
|
if (priceEl) {
|
|
priceEl.textContent = data.price != null ? Number(data.price).toFixed(2) : '—';
|
|
}
|
|
})
|
|
.catch(function () { /* ignore */ });
|
|
}
|
|
|
|
function startQuotePoll() {
|
|
if (quoteTimer) clearInterval(quoteTimer);
|
|
loadQuote();
|
|
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() {
|
|
var tabs = document.getElementById('market-period-tabs');
|
|
if (!tabs) return;
|
|
tabs.addEventListener('click', function (e) {
|
|
var btn = e.target.closest('.period-tab');
|
|
if (!btn) return;
|
|
tabs.querySelectorAll('.period-tab').forEach(function (el) { el.classList.remove('active'); });
|
|
btn.classList.add('active');
|
|
currentPeriod = btn.getAttribute('data-period') || '15m';
|
|
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', 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;
|
|
restartPollers();
|
|
loadKline(false);
|
|
} else {
|
|
updateRefreshHint();
|
|
}
|
|
});
|
|
})();
|