Restructure into modules/ with single-process CTP and config/ layout.
Move business code under modules/, env template to config/, PM2 single qihuo process, and _legacy shims for old imports. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,849 @@
|
||||
/* Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
* 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
* 详见 LICENSE.zh-CN.txt
|
||||
*/
|
||||
(function () {
|
||||
var chartEl = document.getElementById('market-chart');
|
||||
var emptyEl = document.getElementById('market-chart-empty');
|
||||
var wrapEl = document.getElementById('market-chart-wrap');
|
||||
var chart = null;
|
||||
var candleSeries = null;
|
||||
var volumeSeries = null;
|
||||
var areaSeries = null;
|
||||
var ma21Series = null;
|
||||
var ma55Series = null;
|
||||
var prevCloseLine = null;
|
||||
var resizeObs = null;
|
||||
var currentPeriod = '15m';
|
||||
var currentChartMode = '';
|
||||
var klineSource = null;
|
||||
var streamActive = false;
|
||||
var reconnectTimer = null;
|
||||
var lastData = null;
|
||||
var lastRenderedPrepared = null;
|
||||
var lastPrevClose = null;
|
||||
var chartOpts = { prevClose: false, ma: false, gapDay: false };
|
||||
var followingLatest = true;
|
||||
var autoFollow = true;
|
||||
var DEFAULT_VISIBLE_BARS = 80;
|
||||
var EMA_STORAGE_KEY = 'qihuo-market-ema-periods';
|
||||
var DEFAULT_EMA = { fast: 21, slow: 55 };
|
||||
|
||||
var PERIOD_SECONDS = {
|
||||
timeshare: 60,
|
||||
'1m': 60,
|
||||
'2m': 120,
|
||||
'5m': 300,
|
||||
'15m': 900,
|
||||
'1h': 3600,
|
||||
'2h': 7200,
|
||||
'4h': 14400,
|
||||
d: 86400,
|
||||
w: 604800,
|
||||
};
|
||||
|
||||
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' : '#ffffff',
|
||||
text: dark ? '#a8b0c8' : '#5c6578',
|
||||
grid: dark ? '#1e2640' : '#e8edf5',
|
||||
up: dark ? '#26a69a' : '#089981',
|
||||
down: dark ? '#ef5350' : '#f23645',
|
||||
line: dark ? '#4cc2ff' : '#2962ff',
|
||||
areaTop: dark ? 'rgba(76,194,255,0.28)' : 'rgba(41,98,255,0.22)',
|
||||
ma21: dark ? '#ffb347' : '#f7931a',
|
||||
ma55: dark ? '#c084fc' : '#7c3aed',
|
||||
prevClose: dark ? '#fbbf24' : '#b45309',
|
||||
};
|
||||
}
|
||||
|
||||
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 barUnixTime(bar) {
|
||||
if (bar.timestamp) return Math.floor(bar.timestamp / 1000);
|
||||
if (bar.time) {
|
||||
var d = new Date(String(bar.time).replace(' ', 'T'));
|
||||
if (!isNaN(d.getTime())) return Math.floor(d.getTime() / 1000);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function prepareBars(bars, periodKey) {
|
||||
var out = [];
|
||||
var gapDay = chartOpts.gapDay;
|
||||
var seen = {};
|
||||
var gapBase = null;
|
||||
var step = PERIOD_SECONDS[periodKey] || 60;
|
||||
for (var i = 0; i < bars.length; i++) {
|
||||
var b = bars[i];
|
||||
var o = Number(b.open);
|
||||
var h = Number(b.high);
|
||||
var l = Number(b.low);
|
||||
var c = Number(b.close);
|
||||
if (!isFinite(o) || !isFinite(c)) continue;
|
||||
if (!isFinite(h)) h = Math.max(o, c);
|
||||
if (!isFinite(l)) l = Math.min(o, c);
|
||||
h = Math.max(h, o, c);
|
||||
l = Math.min(l, o, c);
|
||||
var t;
|
||||
if (gapDay) {
|
||||
if (gapBase == null) {
|
||||
gapBase = b.timestamp ? Math.floor(b.timestamp / 1000) : 946684800;
|
||||
}
|
||||
t = gapBase + out.length * step;
|
||||
} else {
|
||||
t = barUnixTime(b);
|
||||
}
|
||||
if (t == null || seen[t]) continue;
|
||||
seen[t] = true;
|
||||
out.push({
|
||||
time: t,
|
||||
open: o,
|
||||
high: h,
|
||||
low: l,
|
||||
close: c,
|
||||
volume: Number(b.volume) || 0,
|
||||
rawTime: b.time,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function clampEmaPeriod(v, fallback) {
|
||||
var n = parseInt(v, 10);
|
||||
if (isNaN(n)) return fallback;
|
||||
return Math.max(2, Math.min(500, n));
|
||||
}
|
||||
|
||||
function loadEmaPeriodsFromStorage() {
|
||||
try {
|
||||
var raw = localStorage.getItem(EMA_STORAGE_KEY);
|
||||
if (raw) {
|
||||
var o = JSON.parse(raw);
|
||||
return {
|
||||
fast: clampEmaPeriod(o.fast, DEFAULT_EMA.fast),
|
||||
slow: clampEmaPeriod(o.slow, DEFAULT_EMA.slow),
|
||||
};
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
return { fast: DEFAULT_EMA.fast, slow: DEFAULT_EMA.slow };
|
||||
}
|
||||
|
||||
function saveEmaPeriods(fast, slow) {
|
||||
try {
|
||||
localStorage.setItem(EMA_STORAGE_KEY, JSON.stringify({ fast: fast, slow: slow }));
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function getEmaPeriods() {
|
||||
var fastEl = document.getElementById('chart-ema-fast');
|
||||
var slowEl = document.getElementById('chart-ema-slow');
|
||||
var fast = clampEmaPeriod(fastEl && fastEl.value, DEFAULT_EMA.fast);
|
||||
var slow = clampEmaPeriod(slowEl && slowEl.value, DEFAULT_EMA.slow);
|
||||
if (fastEl) fastEl.value = String(fast);
|
||||
if (slowEl) slowEl.value = String(slow);
|
||||
return { fast: fast, slow: slow };
|
||||
}
|
||||
|
||||
function syncEmaInputsEnabled() {
|
||||
var fastEl = document.getElementById('chart-ema-fast');
|
||||
var slowEl = document.getElementById('chart-ema-slow');
|
||||
var on = !!chartOpts.ma;
|
||||
if (fastEl) fastEl.disabled = !on;
|
||||
if (slowEl) slowEl.disabled = !on;
|
||||
}
|
||||
|
||||
function initEmaPeriodInputs() {
|
||||
var stored = loadEmaPeriodsFromStorage();
|
||||
var fastEl = document.getElementById('chart-ema-fast');
|
||||
var slowEl = document.getElementById('chart-ema-slow');
|
||||
if (fastEl) fastEl.value = String(stored.fast);
|
||||
if (slowEl) slowEl.value = String(stored.slow);
|
||||
syncEmaInputsEnabled();
|
||||
}
|
||||
|
||||
function calcEMA(period, bars) {
|
||||
var result = [];
|
||||
if (!bars.length || period < 1) return result;
|
||||
var k = 2 / (period + 1);
|
||||
var ema = null;
|
||||
for (var i = 0; i < bars.length; i++) {
|
||||
var close = bars[i].close;
|
||||
if (ema == null) {
|
||||
if (i < period - 1) continue;
|
||||
var sum = 0;
|
||||
for (var j = i - period + 1; j <= i; j++) sum += bars[j].close;
|
||||
ema = sum / period;
|
||||
} else {
|
||||
ema = close * k + ema * (1 - k);
|
||||
}
|
||||
result.push({ time: bars[i].time, value: +ema.toFixed(4) });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function destroyChart() {
|
||||
if (resizeObs) {
|
||||
resizeObs.disconnect();
|
||||
resizeObs = null;
|
||||
}
|
||||
if (chart) {
|
||||
chart.remove();
|
||||
chart = null;
|
||||
}
|
||||
candleSeries = null;
|
||||
volumeSeries = null;
|
||||
areaSeries = null;
|
||||
ma21Series = null;
|
||||
ma55Series = null;
|
||||
prevCloseLine = null;
|
||||
currentChartMode = '';
|
||||
lastRenderedPrepared = null;
|
||||
}
|
||||
|
||||
function buildChart(mode) {
|
||||
destroyChart();
|
||||
if (!chartEl || !window.LightweightCharts) return;
|
||||
var c = themeColors();
|
||||
var w = chartEl.clientWidth || 600;
|
||||
var h = chartEl.clientHeight || 400;
|
||||
chart = LightweightCharts.createChart(chartEl, {
|
||||
width: w,
|
||||
height: h,
|
||||
layout: {
|
||||
background: { type: 'solid', color: c.bg },
|
||||
textColor: c.text,
|
||||
fontSize: 11,
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: c.grid, style: 1 },
|
||||
horzLines: { color: c.grid, style: 1 },
|
||||
},
|
||||
crosshair: {
|
||||
mode: LightweightCharts.CrosshairMode.Normal,
|
||||
vertLine: { width: 1, color: c.text, style: 2, labelBackgroundColor: c.grid },
|
||||
horzLine: { width: 1, color: c.text, style: 2, labelBackgroundColor: c.grid },
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderColor: c.grid,
|
||||
scaleMargins: mode === 'line' ? { top: 0.08, bottom: 0.08 } : { top: 0.05, bottom: 0.22 },
|
||||
},
|
||||
timeScale: {
|
||||
borderColor: c.grid,
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
rightOffset: 8,
|
||||
barSpacing: 10,
|
||||
minBarSpacing: 4,
|
||||
fixLeftEdge: false,
|
||||
fixRightEdge: false,
|
||||
},
|
||||
handleScroll: { mouseWheel: true, pressedMouseMove: true, horzTouchDrag: true, vertTouchDrag: true },
|
||||
handleScale: { axisPressedMouseMove: true, mouseWheel: true, pinch: true },
|
||||
localization: { locale: 'zh-CN' },
|
||||
});
|
||||
|
||||
if (mode === 'line') {
|
||||
areaSeries = chart.addAreaSeries({
|
||||
lineColor: c.line,
|
||||
topColor: c.areaTop,
|
||||
bottomColor: 'rgba(0,0,0,0)',
|
||||
lineWidth: 2,
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: true,
|
||||
});
|
||||
} else {
|
||||
candleSeries = chart.addCandlestickSeries({
|
||||
upColor: c.up,
|
||||
downColor: c.down,
|
||||
borderVisible: true,
|
||||
borderUpColor: c.up,
|
||||
borderDownColor: c.down,
|
||||
wickUpColor: c.up,
|
||||
wickDownColor: c.down,
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: true,
|
||||
});
|
||||
volumeSeries = chart.addHistogramSeries({
|
||||
priceFormat: { type: 'volume' },
|
||||
priceScaleId: 'volume',
|
||||
lastValueVisible: false,
|
||||
priceLineVisible: false,
|
||||
});
|
||||
chart.priceScale('volume').applyOptions({
|
||||
scaleMargins: { top: 0.82, bottom: 0 },
|
||||
borderVisible: false,
|
||||
});
|
||||
if (chartOpts.ma) {
|
||||
ma21Series = chart.addLineSeries({
|
||||
color: c.ma21,
|
||||
lineWidth: 1,
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: false,
|
||||
crosshairMarkerVisible: false,
|
||||
});
|
||||
ma55Series = chart.addLineSeries({
|
||||
color: c.ma55,
|
||||
lineWidth: 1,
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: false,
|
||||
crosshairMarkerVisible: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
chart.timeScale().subscribeVisibleLogicalRangeChange(function () {
|
||||
if (!chart) return;
|
||||
var range = chart.timeScale().getVisibleLogicalRange();
|
||||
if (!range || !lastData || !lastData.preparedBars) return;
|
||||
var total = lastData.preparedBars.length;
|
||||
followingLatest = range.to >= total - 2;
|
||||
});
|
||||
|
||||
resizeObs = new ResizeObserver(function () {
|
||||
if (!chart || !chartEl) return;
|
||||
chart.applyOptions({ width: chartEl.clientWidth, height: chartEl.clientHeight });
|
||||
});
|
||||
resizeObs.observe(chartEl);
|
||||
currentChartMode = mode;
|
||||
}
|
||||
|
||||
function applyPrevCloseLine(price) {
|
||||
if (!candleSeries || currentChartMode !== 'candle') return;
|
||||
if (prevCloseLine) {
|
||||
candleSeries.removePriceLine(prevCloseLine);
|
||||
prevCloseLine = null;
|
||||
}
|
||||
if (!chartOpts.prevClose || price == null || !isFinite(Number(price))) return;
|
||||
var c = themeColors();
|
||||
prevCloseLine = candleSeries.createPriceLine({
|
||||
price: Number(price),
|
||||
color: c.prevClose,
|
||||
lineWidth: 1,
|
||||
lineStyle: LightweightCharts.LineStyle.Dashed,
|
||||
axisLabelVisible: true,
|
||||
title: '昨收',
|
||||
});
|
||||
}
|
||||
|
||||
function setVisibleRange(prepared, preserve) {
|
||||
if (!chart || !prepared.length) return;
|
||||
var ts = chart.timeScale();
|
||||
if (preserve && followingLatest) {
|
||||
var span = DEFAULT_VISIBLE_BARS;
|
||||
try {
|
||||
var cur = ts.getVisibleLogicalRange();
|
||||
if (cur) span = Math.max(20, cur.to - cur.from);
|
||||
} catch (e) { /* ignore */ }
|
||||
ts.setVisibleLogicalRange({
|
||||
from: Math.max(0, prepared.length - span),
|
||||
to: prepared.length + 4,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (preserve) return;
|
||||
var show = Math.min(DEFAULT_VISIBLE_BARS, prepared.length);
|
||||
ts.setVisibleLogicalRange({
|
||||
from: Math.max(0, prepared.length - show),
|
||||
to: prepared.length + 4,
|
||||
});
|
||||
}
|
||||
|
||||
function shouldPreserveView() {
|
||||
return !autoFollow || !followingLatest;
|
||||
}
|
||||
|
||||
function applyBarUpdate(bar, mode, prepared) {
|
||||
if (mode === 'line') {
|
||||
areaSeries.update({ time: bar.time, value: bar.close });
|
||||
return;
|
||||
}
|
||||
candleSeries.update({
|
||||
time: bar.time,
|
||||
open: bar.open,
|
||||
high: bar.high,
|
||||
low: bar.low,
|
||||
close: bar.close,
|
||||
});
|
||||
var up = bar.close >= bar.open;
|
||||
var c = themeColors();
|
||||
volumeSeries.update({
|
||||
time: bar.time,
|
||||
value: bar.volume,
|
||||
color: up ? c.up : c.down,
|
||||
});
|
||||
if (chartOpts.ma && ma21Series && ma55Series && prepared && prepared.length) {
|
||||
var emaP = getEmaPeriods();
|
||||
var emaFast = calcEMA(emaP.fast, prepared);
|
||||
var emaSlow = calcEMA(emaP.slow, prepared);
|
||||
if (emaFast.length) ma21Series.update(emaFast[emaFast.length - 1]);
|
||||
if (emaSlow.length) ma55Series.update(emaSlow[emaSlow.length - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
function tryIncrementalUpdate(prepared, mode) {
|
||||
if (!lastRenderedPrepared || !prepared.length) return false;
|
||||
var prev = lastRenderedPrepared;
|
||||
var prevLast = prev[prev.length - 1];
|
||||
var newLast = prepared[prepared.length - 1];
|
||||
|
||||
if (prepared.length === prev.length && newLast.time === prevLast.time) {
|
||||
applyBarUpdate(newLast, mode, prepared);
|
||||
return true;
|
||||
}
|
||||
if (prepared.length === prev.length + 1 && prepared[prepared.length - 2].time === prevLast.time) {
|
||||
applyBarUpdate(newLast, mode, prepared);
|
||||
if (autoFollow && followingLatest) {
|
||||
setVisibleRange(prepared, true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function renderChartFull(prepared, data, mode, preserveRange) {
|
||||
if (mode === 'line') {
|
||||
areaSeries.setData(prepared.map(function (b) {
|
||||
return { time: b.time, value: b.close };
|
||||
}));
|
||||
} else {
|
||||
candleSeries.setData(prepared.map(function (b) {
|
||||
return { time: b.time, open: b.open, high: b.high, low: b.low, close: b.close };
|
||||
}));
|
||||
volumeSeries.setData(prepared.map(function (b) {
|
||||
var up = b.close >= b.open;
|
||||
var c = themeColors();
|
||||
return {
|
||||
time: b.time,
|
||||
value: b.volume,
|
||||
color: up ? c.up : c.down,
|
||||
};
|
||||
}));
|
||||
if (chartOpts.ma && ma21Series && ma55Series) {
|
||||
var emaP = getEmaPeriods();
|
||||
ma21Series.setData(calcEMA(emaP.fast, prepared));
|
||||
ma55Series.setData(calcEMA(emaP.slow, prepared));
|
||||
}
|
||||
applyPrevCloseLine(lastPrevClose != null ? lastPrevClose : data.prev_close);
|
||||
}
|
||||
if (!shouldPreserveView()) {
|
||||
setVisibleRange(prepared, !!preserveRange);
|
||||
}
|
||||
}
|
||||
|
||||
function renderChart(data, options) {
|
||||
options = options || {};
|
||||
if (!chartEl || !window.LightweightCharts) return;
|
||||
lastData = data;
|
||||
if (data.prev_close != null) lastPrevClose = data.prev_close;
|
||||
|
||||
var isLine = data.chart_type === 'line' || data.period === 'timeshare';
|
||||
var mode = isLine ? 'line' : 'candle';
|
||||
if (!chart || currentChartMode !== mode) buildChart(mode);
|
||||
if (!chart) return;
|
||||
|
||||
var prepared = prepareBars(data.bars || [], data.period || currentPeriod);
|
||||
data.preparedBars = prepared;
|
||||
if (!prepared.length) return;
|
||||
|
||||
if (!options.forceFull && shouldPreserveView() && tryIncrementalUpdate(prepared, mode)) {
|
||||
lastRenderedPrepared = prepared;
|
||||
return;
|
||||
}
|
||||
|
||||
renderChartFull(prepared, data, mode, options.preserveRange);
|
||||
lastRenderedPrepared = prepared;
|
||||
}
|
||||
|
||||
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 (wrapEl) wrapEl.classList.add('has-data');
|
||||
}
|
||||
|
||||
function showEmptyOverlay(text) {
|
||||
if (emptyEl) emptyEl.textContent = text;
|
||||
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 (!wrapEl) return;
|
||||
if (on && !lastData) {
|
||||
wrapEl.classList.add('loading');
|
||||
showEmptyOverlay('请选择合约并点击「查看」');
|
||||
} else {
|
||||
wrapEl.classList.remove('loading');
|
||||
if (lastData) hideEmptyOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
function klineSourceLabel(src) {
|
||||
if (src === 'local') return '本地缓存';
|
||||
return '新浪';
|
||||
}
|
||||
|
||||
function updateRefreshHint(disconnected) {
|
||||
var el = document.getElementById('market-refresh-hint');
|
||||
if (!el) return;
|
||||
if (!getSymbol()) {
|
||||
el.textContent = '';
|
||||
return;
|
||||
}
|
||||
if (disconnected) {
|
||||
el.textContent = 'SSE 连接中断,正在重连…';
|
||||
return;
|
||||
}
|
||||
if (!streamActive) {
|
||||
el.textContent = '';
|
||||
return;
|
||||
}
|
||||
var src = '';
|
||||
if (lastData && lastData.source) {
|
||||
src = ' · ' + klineSourceLabel(lastData.source);
|
||||
}
|
||||
if (isTradingSession()) {
|
||||
el.textContent = '新浪数据 · 交易中 SSE 推送' + src;
|
||||
} else {
|
||||
el.textContent = '新浪数据 · 非交易时段低频刷新' + src;
|
||||
}
|
||||
}
|
||||
|
||||
function updatePrevCloseDisplay(val) {
|
||||
var prevEl = document.getElementById('market-quote-prev');
|
||||
if (!prevEl) return;
|
||||
if (val != null && !isNaN(Number(val))) {
|
||||
prevEl.textContent = '昨收 ' + Number(val).toFixed(2);
|
||||
} else {
|
||||
prevEl.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
function applyQuote(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) : '—';
|
||||
}
|
||||
if (data.quote_source && lastData) {
|
||||
updateQuoteMeta(Object.assign({}, lastData, { quote_source: data.quote_source }));
|
||||
}
|
||||
if (data.prev_close != null) {
|
||||
lastPrevClose = data.prev_close;
|
||||
updatePrevCloseDisplay(data.prev_close);
|
||||
if (chartOpts.prevClose && lastData) {
|
||||
applyPrevCloseLine(data.prev_close);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopKlineStream() {
|
||||
streamActive = false;
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
if (klineSource) {
|
||||
klineSource.close();
|
||||
klineSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer) return;
|
||||
updateRefreshHint(true);
|
||||
reconnectTimer = setTimeout(function () {
|
||||
reconnectTimer = null;
|
||||
if (getSymbol()) startKlineStream(false);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function startKlineStream(showLoading) {
|
||||
stopKlineStream();
|
||||
var symbol = getSymbol();
|
||||
if (!symbol) {
|
||||
alert('请先选择或输入合约代码');
|
||||
return;
|
||||
}
|
||||
if (showLoading) setLoading(true);
|
||||
|
||||
var codes = getMarketCodes();
|
||||
var q = 'symbol=' + encodeURIComponent(symbol) +
|
||||
'&period=' + encodeURIComponent(currentPeriod);
|
||||
if (codes.market_code) q += '&market_code=' + encodeURIComponent(codes.market_code);
|
||||
if (codes.sina_code) q += '&sina_code=' + encodeURIComponent(codes.sina_code);
|
||||
|
||||
klineSource = new EventSource('/api/kline/stream?' + q);
|
||||
streamActive = true;
|
||||
followingLatest = true;
|
||||
updateRefreshHint(false);
|
||||
|
||||
klineSource.addEventListener('kline', function (e) {
|
||||
try {
|
||||
var data = JSON.parse(e.data);
|
||||
if (!data.bars || !data.bars.length) return;
|
||||
hideEmptyOverlay();
|
||||
renderChart(data, { preserveRange: lastData !== null });
|
||||
updateQuoteMeta(data);
|
||||
if (data.prev_close != null) updatePrevCloseDisplay(data.prev_close);
|
||||
updateRefreshHint(false);
|
||||
setLoading(false);
|
||||
} catch (err) { /* ignore */ }
|
||||
});
|
||||
|
||||
klineSource.addEventListener('quote', function (e) {
|
||||
try {
|
||||
applyQuote(JSON.parse(e.data));
|
||||
} catch (err) { /* ignore */ }
|
||||
});
|
||||
|
||||
klineSource.onerror = function () {
|
||||
stopKlineStream();
|
||||
scheduleReconnect();
|
||||
};
|
||||
}
|
||||
|
||||
function updateQuoteMeta(data) {
|
||||
var meta = document.getElementById('market-quote-meta');
|
||||
if (meta) {
|
||||
var parts = [];
|
||||
if (data.count) parts.push('共 ' + data.count + ' 根 · ' + periodLabel(data.period));
|
||||
if (data.source) parts.push('K线 ' + klineSourceLabel(data.source));
|
||||
if (data.quote_source) {
|
||||
parts.push('报价 新浪');
|
||||
}
|
||||
meta.textContent = parts.join(' · ');
|
||||
}
|
||||
var nameEl = document.getElementById('market-quote-name');
|
||||
var hiddenName = document.getElementById('market-symbol-name');
|
||||
if (nameEl && !(nameEl.textContent && nameEl.textContent.trim())) {
|
||||
nameEl.textContent = (hiddenName && hiddenName.value) || data.symbol || '—';
|
||||
}
|
||||
}
|
||||
|
||||
function loadKline(showLoading) {
|
||||
startKlineStream(showLoading);
|
||||
}
|
||||
|
||||
function shiftDataZoom(delta) {
|
||||
if (!chart) return;
|
||||
var ts = chart.timeScale();
|
||||
var range = ts.getVisibleLogicalRange();
|
||||
if (!range) return;
|
||||
var span = range.to - range.from;
|
||||
var newSpan = Math.max(15, span + delta);
|
||||
var center = (range.from + range.to) / 2;
|
||||
ts.setVisibleLogicalRange({
|
||||
from: center - newSpan / 2,
|
||||
to: center + newSpan / 2,
|
||||
});
|
||||
}
|
||||
|
||||
function resetDataZoom() {
|
||||
if (!chart || !lastData || !lastData.preparedBars) return;
|
||||
followingLatest = true;
|
||||
setVisibleRange(lastData.preparedBars, false);
|
||||
}
|
||||
|
||||
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';
|
||||
followingLatest = true;
|
||||
if (getSymbol()) loadKline(true);
|
||||
});
|
||||
}
|
||||
|
||||
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(-20); });
|
||||
if (zoomOut) zoomOut.addEventListener('click', function () { shiftDataZoom(20); });
|
||||
if (zoomReset) zoomReset.addEventListener('click', resetDataZoom);
|
||||
}
|
||||
|
||||
function bindAutoButton() {
|
||||
var btn = document.getElementById('market-auto-btn');
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', function () {
|
||||
autoFollow = !autoFollow;
|
||||
btn.classList.toggle('is-active', autoFollow);
|
||||
if (autoFollow) {
|
||||
followingLatest = true;
|
||||
if (lastData && lastData.preparedBars) {
|
||||
setVisibleRange(lastData.preparedBars, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindChartOptions() {
|
||||
var prevCb = document.getElementById('chart-opt-prev-close');
|
||||
var maCb = document.getElementById('chart-opt-ma');
|
||||
var gapCb = document.getElementById('chart-opt-gap-day');
|
||||
var emaFastEl = document.getElementById('chart-ema-fast');
|
||||
var emaSlowEl = document.getElementById('chart-ema-slow');
|
||||
|
||||
function onEmaPeriodChange() {
|
||||
var emaP = getEmaPeriods();
|
||||
saveEmaPeriods(emaP.fast, emaP.slow);
|
||||
if (chartOpts.ma && lastData) {
|
||||
renderChart(lastData, { forceFull: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (prevCb) {
|
||||
prevCb.addEventListener('change', function () {
|
||||
chartOpts.prevClose = prevCb.checked;
|
||||
if (lastData) {
|
||||
applyPrevCloseLine(lastPrevClose != null ? lastPrevClose : lastData.prev_close);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (maCb) {
|
||||
maCb.addEventListener('change', function () {
|
||||
chartOpts.ma = maCb.checked;
|
||||
syncEmaInputsEnabled();
|
||||
if (lastData) {
|
||||
destroyChart();
|
||||
renderChart(lastData, { forceFull: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
if (emaFastEl) {
|
||||
emaFastEl.addEventListener('change', onEmaPeriodChange);
|
||||
emaFastEl.addEventListener('keydown', function (ev) {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
emaFastEl.blur();
|
||||
onEmaPeriodChange();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (emaSlowEl) {
|
||||
emaSlowEl.addEventListener('change', onEmaPeriodChange);
|
||||
emaSlowEl.addEventListener('keydown', function (ev) {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
emaSlowEl.blur();
|
||||
onEmaPeriodChange();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (gapCb) {
|
||||
gapCb.addEventListener('change', function () {
|
||||
chartOpts.gapDay = gapCb.checked;
|
||||
followingLatest = true;
|
||||
if (lastData) renderChart(lastData, { forceFull: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupMarketPage() {
|
||||
stopKlineStream();
|
||||
destroyChart();
|
||||
}
|
||||
|
||||
function bootMarketPage() {
|
||||
if (!window.LightweightCharts) {
|
||||
if (emptyEl) emptyEl.textContent = '图表库加载失败,请刷新页面';
|
||||
return;
|
||||
}
|
||||
bindPeriodTabs();
|
||||
bindZoomButtons();
|
||||
bindAutoButton();
|
||||
initEmaPeriodInputs();
|
||||
bindChartOptions();
|
||||
|
||||
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 () { loadKline(true); });
|
||||
|
||||
var hidden = document.getElementById('market-symbol-hidden');
|
||||
var input = document.getElementById('market-symbol-input');
|
||||
if (input) {
|
||||
input.addEventListener('symbol-selected', function () {
|
||||
lastPrevClose = null;
|
||||
lastData = null;
|
||||
lastRenderedPrepared = null;
|
||||
destroyChart();
|
||||
updatePrevCloseDisplay(null);
|
||||
loadKline(true);
|
||||
});
|
||||
}
|
||||
if (hidden && hidden.value) {
|
||||
if (input && !input.value) input.value = hidden.value;
|
||||
loadKline(true);
|
||||
} else {
|
||||
updateRefreshHint(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!window.__QIHUO_MARKET_THEME__) {
|
||||
window.__QIHUO_MARKET_THEME__ = true;
|
||||
document.addEventListener('click', function (e) {
|
||||
if (e.target.closest('[data-theme-pick]') && lastData) {
|
||||
setTimeout(function () {
|
||||
destroyChart();
|
||||
renderChart(lastData, { forceFull: true });
|
||||
}, 80);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (window.qihuoPageBoot) window.qihuoPageBoot(bootMarketPage, '#market-chart-wrap');
|
||||
else if (window.qihuoOnPageLoad) window.qihuoOnPageLoad(bootMarketPage);
|
||||
else document.addEventListener('DOMContentLoaded', bootMarketPage);
|
||||
if (window.qihuoOnPageLeave) window.qihuoOnPageLeave(cleanupMarketPage);
|
||||
window.addEventListener('pagehide', cleanupMarketPage);
|
||||
})();
|
||||
Reference in New Issue
Block a user