Files
qihuo/static/js/market.js
T
dekun 6d55a54946 Fix turbo nav layout flash and stats page not loading.
Wait for page CSS before swapping content, hoist inline styles to head, and boot page scripts immediately when DOM markers exist.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 20:55:36 +08:00

751 lines
26 KiB
JavaScript

/* 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 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 calcMA(period, bars) {
var result = [];
for (var i = 0; i < bars.length; i++) {
if (i < period - 1) continue;
var sum = 0;
for (var j = 0; j < period; j++) sum += bars[i - j].close;
result.push({ time: bars[i].time, value: +(sum / period).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 ma21 = calcMA(21, prepared);
var ma55 = calcMA(55, prepared);
if (ma21.length) ma21Series.update(ma21[ma21.length - 1]);
if (ma55.length) ma55Series.update(ma55[ma55.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) {
ma21Series.setData(calcMA(21, prepared));
ma55Series.setData(calcMA(55, 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');
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;
if (lastData) {
destroyChart();
renderChart(lastData, { forceFull: true });
}
});
}
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();
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);
})();