From 5f238441604d84f4127386a2d621b7d504c8eb4f Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Thu, 11 Dec 2025 13:39:45 +0800 Subject: [PATCH] update pay ui --- .../FlexScreen/hooks/useRealtimeQuote.ts | 826 ++++++------------ .../components/FlexScreen/hooks/utils.ts | 19 +- .../components/FlexScreen/types.ts | 237 +++-- 3 files changed, 362 insertions(+), 720 deletions(-) diff --git a/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts b/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts index 9b4ad3c4..df943ba5 100644 --- a/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts +++ b/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts @@ -7,15 +7,18 @@ * - 开发环境 (HTTP): 直连 ws:// * * 上交所 (SSE): 需主动订阅,提供五档行情 - * 深交所 (SZSE): v4.0 API - 需主动订阅,提供十档行情 + * 深交所 (SZSE): v4.0 API - 批量推送模式,提供五档行情 + * + * API 文档: SZSE_WEBSOCKET_API.md */ import { useState, useEffect, useRef, useCallback } from 'react'; import { logger } from '@utils/logger'; import { WS_CONFIG, HEARTBEAT_INTERVAL, RECONNECT_INTERVAL } from './constants'; -import { getExchange, normalizeCode, extractOrderBook, calcChangePct } from './utils'; +import { getExchange, normalizeCode, calcChangePct } from './utils'; import type { Exchange, + SZSEChannel, ConnectionStatus, QuotesMap, QuoteData, @@ -28,10 +31,12 @@ import type { SZSEIndexData, SZSEBondData, SZSEHKStockData, - SZSEAfterhoursData, UseRealtimeQuoteReturn, } from '../types'; +/** 最大重连次数 */ +const MAX_RECONNECT_ATTEMPTS = 5; + /** * 处理上交所消息 */ @@ -79,69 +84,27 @@ const handleSSEMessage = ( }; /** - * 从深交所数据中提取盘口价格和量 - * 新 API 格式:直接使用 bid_prices/bid_volumes/ask_prices/ask_volumes 数组 - * 旧 API 格式:使用 bids/asks 对象数组 + * 处理深交所批量行情消息 (新 API 格式) + * 格式:{ type: 'stock'/'index'/'bond'/'hkstock', data: { code: quote, ... }, timestamp } */ -const extractSZSEOrderBook = ( - stockData: SZSEStockData -): { bidPrices: number[]; bidVolumes: number[]; askPrices: number[]; askVolumes: number[] } => { - // 新 API 格式:直接是数组 - if (stockData.bid_prices && Array.isArray(stockData.bid_prices)) { - return { - bidPrices: stockData.bid_prices, - bidVolumes: stockData.bid_volumes || [], - askPrices: stockData.ask_prices || [], - askVolumes: stockData.ask_volumes || [], - }; - } - // 旧 API 格式:对象数组 - const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(stockData.bids); - const { prices: askPrices, volumes: askVolumes } = extractOrderBook(stockData.asks); - return { bidPrices, bidVolumes, askPrices, askVolumes }; -}; - -/** - * 从深交所数据中提取价格字段(兼容新旧 API) - */ -const extractSZSEPrices = (stockData: SZSEStockData) => { - return { - prevClose: stockData.prev_close_px ?? stockData.prev_close ?? 0, - volume: stockData.total_volume_trade ?? stockData.volume ?? 0, - amount: stockData.total_value_trade ?? stockData.amount ?? 0, - upperLimit: stockData.upper_limit_px ?? stockData.upper_limit, - lowerLimit: stockData.lower_limit_px ?? stockData.lower_limit, - tradingPhase: stockData.trading_phase_code ?? stockData.trading_phase, - }; -}; - -/** - * 处理深交所批量行情消息(新 API 格式,与 SSE 一致) - * 格式:{ type: 'stock'/'index', data: { '000001': {...}, '399001': {...} }, timestamp: '...' } - */ -const handleSZSEBatchMessage = ( +const handleSZSERealtimeMessage = ( msg: SZSERealtimeMessage, subscribedCodes: Set, prevQuotes: QuotesMap ): QuotesMap | null => { const { type, data, timestamp } = msg; - // 新 API 格式:data 是 { code: quote, ... } 的字典 if (!data || typeof data !== 'object') { return null; } const updated: QuotesMap = { ...prevQuotes }; let hasUpdate = false; - const isIndexType = type === 'index'; - // 遍历所有数据 Object.entries(data).forEach(([code, quote]) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const quoteData = quote as any; - if (!quoteData || typeof quoteData !== 'object') return; + if (!quote || typeof quote !== 'object') return; - const rawCode = quoteData.security_id || code; + const rawCode = (quote as { security_id?: string }).security_id || code; const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`; // 只处理已订阅的代码 @@ -151,60 +114,125 @@ const handleSZSEBatchMessage = ( hasUpdate = true; - if (isIndexType) { - // 指数数据格式(兼容多种字段名) - const prevClose = quoteData.prev_close_px ?? quoteData.prev_close ?? quoteData.prevClose ?? 0; - const currentIndex = quoteData.current_index ?? quoteData.last_px ?? 0; + switch (type) { + case 'index': { + const indexData = quote as SZSEIndexData; + const prevClose = indexData.prev_close || 0; + const currentIndex = indexData.current_index || 0; - updated[fullCode] = { - code: fullCode, - name: prevQuotes[fullCode]?.name || '', - price: currentIndex, - prevClose, - open: quoteData.open_index ?? quoteData.open_px ?? 0, - high: quoteData.high_index ?? quoteData.high_px ?? 0, - low: quoteData.low_index ?? quoteData.low_px ?? 0, - close: quoteData.close_index, - volume: quoteData.total_volume_trade ?? quoteData.volume ?? 0, - amount: quoteData.total_value_trade ?? quoteData.amount ?? 0, - numTrades: quoteData.num_trades, - change: currentIndex - prevClose, - changePct: calcChangePct(currentIndex, prevClose), - bidPrices: [], - bidVolumes: [], - askPrices: [], - askVolumes: [], - updateTime: quoteData.update_time || timestamp, - exchange: 'SZSE', - } as QuoteData; - } else { - // 股票/基金/债券数据格式 - const { bidPrices, bidVolumes, askPrices, askVolumes } = extractSZSEOrderBook(quoteData); - const { prevClose, volume, amount, upperLimit, lowerLimit, tradingPhase } = extractSZSEPrices(quoteData); + updated[fullCode] = { + code: fullCode, + name: prevQuotes[fullCode]?.name || '', + price: currentIndex, + prevClose, + open: indexData.open_index || 0, + high: indexData.high_index || 0, + low: indexData.low_index || 0, + close: indexData.close_index, + volume: indexData.volume || 0, + amount: indexData.amount || 0, + numTrades: indexData.num_trades, + change: currentIndex - prevClose, + changePct: calcChangePct(currentIndex, prevClose), + bidPrices: [], + bidVolumes: [], + askPrices: [], + askVolumes: [], + updateTime: indexData.update_time || timestamp, + exchange: 'SZSE', + } as QuoteData; + break; + } - updated[fullCode] = { - code: fullCode, - name: prevQuotes[fullCode]?.name || '', - price: quoteData.last_px, - prevClose, - open: quoteData.open_px, - high: quoteData.high_px, - low: quoteData.low_px, - volume, - amount, - numTrades: quoteData.num_trades, - upperLimit, - lowerLimit, - change: quoteData.last_px - prevClose, - changePct: calcChangePct(quoteData.last_px, prevClose), - bidPrices, - bidVolumes, - askPrices, - askVolumes, - tradingPhase, - updateTime: quoteData.update_time || timestamp, - exchange: 'SZSE', - } as QuoteData; + case 'stock': { + const stockData = quote as SZSEStockData; + const prevClose = stockData.prev_close_px || 0; + + updated[fullCode] = { + code: fullCode, + name: prevQuotes[fullCode]?.name || '', + price: stockData.last_px, + prevClose, + open: stockData.open_px, + high: stockData.high_px, + low: stockData.low_px, + volume: stockData.total_volume_trade, + amount: stockData.total_value_trade, + numTrades: stockData.num_trades, + upperLimit: stockData.upper_limit_px, + lowerLimit: stockData.lower_limit_px, + change: stockData.last_px - prevClose, + changePct: calcChangePct(stockData.last_px, prevClose), + bidPrices: stockData.bid_prices || [], + bidVolumes: stockData.bid_volumes || [], + askPrices: stockData.ask_prices || [], + askVolumes: stockData.ask_volumes || [], + tradingPhase: stockData.trading_phase_code, + updateTime: stockData.update_time || timestamp, + exchange: 'SZSE', + } as QuoteData; + break; + } + + case 'bond': { + const bondData = quote as SZSEBondData; + const prevClose = bondData.prev_close || 0; + + updated[fullCode] = { + code: fullCode, + name: prevQuotes[fullCode]?.name || '', + price: bondData.last_px, + prevClose, + open: bondData.open_px, + high: bondData.high_px, + low: bondData.low_px, + volume: bondData.volume, + amount: bondData.amount, + numTrades: bondData.num_trades, + weightedAvgPx: bondData.weighted_avg_px, + change: bondData.last_px - prevClose, + changePct: calcChangePct(bondData.last_px, prevClose), + bidPrices: [], + bidVolumes: [], + askPrices: [], + askVolumes: [], + tradingPhase: bondData.trading_phase_code, + updateTime: bondData.update_time || timestamp, + exchange: 'SZSE', + isBond: true, + } as QuoteData; + break; + } + + case 'hkstock': { + const hkData = quote as SZSEHKStockData; + const prevClose = hkData.prev_close || 0; + + updated[fullCode] = { + code: fullCode, + name: prevQuotes[fullCode]?.name || '', + price: hkData.last_px, + prevClose, + open: hkData.open_px, + high: hkData.high_px, + low: hkData.low_px, + volume: hkData.volume, + amount: hkData.amount, + numTrades: hkData.num_trades, + nominalPx: hkData.nominal_px, + change: hkData.last_px - prevClose, + changePct: calcChangePct(hkData.last_px, prevClose), + bidPrices: hkData.bid_prices || [], + bidVolumes: hkData.bid_volumes || [], + askPrices: hkData.ask_prices || [], + askVolumes: hkData.ask_volumes || [], + tradingPhase: hkData.trading_phase_code, + updateTime: hkData.update_time || timestamp, + exchange: 'SZSE', + isHK: true, + } as QuoteData; + break; + } } }); @@ -212,383 +240,59 @@ const handleSZSEBatchMessage = ( }; /** - * 处理深交所实时消息 (兼容新旧 API) - * 新 API (批量模式): type='stock'/'bond'/'fund'/'index', data = { code: quote, ... } - * 旧 API (单条模式): type='realtime', category='stock', data = { security_id, ... } + * 处理深交所快照消息 */ -const handleSZSERealtimeMessage = ( - msg: SZSERealtimeMessage, +const handleSZSESnapshotMessage = ( + msg: SZSESnapshotMessage, subscribedCodes: Set, prevQuotes: QuotesMap ): QuotesMap | null => { - const { type, category, data, timestamp } = msg; + const { data, timestamp } = msg; - // 新 API 批量格式检测:data 是字典 { code: quote, ... },没有 security_id 在顶层 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const anyData = data as any; - const isBatchFormat = anyData && typeof anyData === 'object' && !anyData.security_id; - - if (isBatchFormat && (type === 'stock' || type === 'bond' || type === 'fund' || type === 'index')) { - return handleSZSEBatchMessage(msg, subscribedCodes, prevQuotes); - } - - // 旧 API 单条格式:data 直接是行情对象 { security_id, last_px, ... } - const rawCode = anyData?.security_id; - if (!rawCode) { + if (!data || typeof data !== 'object') { return null; } + const stockData = data as SZSEStockData; + const rawCode = stockData.security_id; + if (!rawCode) return null; + const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`; if (!subscribedCodes.has(rawCode) && !subscribedCodes.has(fullCode)) { return null; } + const prevClose = stockData.prev_close_px || 0; + const updated: QuotesMap = { ...prevQuotes }; - - // 确定实际的类别:新 API 直接用 type,旧 API 用 category - const actualCategory = (type === 'realtime' ? category : type) as string; - - switch (actualCategory) { - case 'stock': { - const stockData = data as SZSEStockData; - const { bidPrices, bidVolumes, askPrices, askVolumes } = extractSZSEOrderBook(stockData); - const { prevClose, volume, amount, upperLimit, lowerLimit, tradingPhase } = extractSZSEPrices(stockData); - - updated[fullCode] = { - code: fullCode, - name: prevQuotes[fullCode]?.name || '', - price: stockData.last_px, - prevClose, - open: stockData.open_px, - high: stockData.high_px, - low: stockData.low_px, - volume, - amount, - numTrades: stockData.num_trades, - upperLimit, - lowerLimit, - change: stockData.last_px - prevClose, - changePct: calcChangePct(stockData.last_px, prevClose), - bidPrices, - bidVolumes, - askPrices, - askVolumes, - tradingPhase, - updateTime: stockData.update_time || timestamp, - exchange: 'SZSE', - } as QuoteData; - break; - } - - case 'index': { - const indexData = data as SZSEIndexData; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const anyIndexData = indexData as any; - const prevClose = anyIndexData.prev_close_px ?? indexData.prev_close ?? 0; - const currentIndex = anyIndexData.current_index ?? anyIndexData.last_px ?? 0; - - updated[fullCode] = { - code: fullCode, - name: prevQuotes[fullCode]?.name || '', - price: currentIndex, - prevClose, - open: indexData.open_index ?? anyIndexData.open_px ?? 0, - high: indexData.high_index ?? anyIndexData.high_px ?? 0, - low: indexData.low_index ?? anyIndexData.low_px ?? 0, - close: indexData.close_index, - volume: anyIndexData.total_volume_trade ?? indexData.volume ?? 0, - amount: anyIndexData.total_value_trade ?? indexData.amount ?? 0, - numTrades: indexData.num_trades, - change: currentIndex - prevClose, - changePct: calcChangePct(currentIndex, prevClose), - bidPrices: [], - bidVolumes: [], - askPrices: [], - askVolumes: [], - tradingPhase: indexData.trading_phase, - updateTime: timestamp, - exchange: 'SZSE', - } as QuoteData; - break; - } - - case 'bond': - case 'fund': { - const bondData = data as SZSEBondData; - const prevClose = (bondData as SZSEStockData).prev_close_px ?? bondData.prev_close ?? 0; - updated[fullCode] = { - code: fullCode, - name: prevQuotes[fullCode]?.name || '', - price: bondData.last_px, - prevClose, - open: bondData.open_px, - high: bondData.high_px, - low: bondData.low_px, - volume: bondData.volume, - amount: bondData.amount, - numTrades: bondData.num_trades, - weightedAvgPx: bondData.weighted_avg_px, - change: bondData.last_px - prevClose, - changePct: calcChangePct(bondData.last_px, prevClose), - bidPrices: [], - bidVolumes: [], - askPrices: [], - askVolumes: [], - tradingPhase: bondData.trading_phase, - updateTime: timestamp, - exchange: 'SZSE', - isBond: true, - } as QuoteData; - break; - } - - case 'hk_stock': { - const hkData = data as SZSEHKStockData; - const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(hkData.bids); - const { prices: askPrices, volumes: askVolumes } = extractOrderBook(hkData.asks); - - updated[fullCode] = { - code: fullCode, - name: prevQuotes[fullCode]?.name || '', - price: hkData.last_px, - prevClose: hkData.prev_close, - open: hkData.open_px, - high: hkData.high_px, - low: hkData.low_px, - volume: hkData.volume, - amount: hkData.amount, - numTrades: hkData.num_trades, - nominalPx: hkData.nominal_px, - referencePx: hkData.reference_px, - change: hkData.last_px - hkData.prev_close, - changePct: calcChangePct(hkData.last_px, hkData.prev_close), - bidPrices, - bidVolumes, - askPrices, - askVolumes, - tradingPhase: hkData.trading_phase, - updateTime: timestamp, - exchange: 'SZSE', - isHK: true, - } as QuoteData; - break; - } - - case 'afterhours_block': - case 'afterhours_trading': { - const afterhoursData = data as SZSEAfterhoursData; - const existing = prevQuotes[fullCode]; - const afterhoursInfo = { - bidPx: afterhoursData.bid_px, - bidSize: afterhoursData.bid_size, - offerPx: afterhoursData.offer_px, - offerSize: afterhoursData.offer_size, - volume: afterhoursData.volume, - amount: afterhoursData.amount, - numTrades: afterhoursData.num_trades || 0, - }; - - // 盘后数据的当前价格:优先使用 bid_px(买入价),否则使用 offer_px(卖出价) - const afterhoursPrice = afterhoursData.bid_px || afterhoursData.offer_px || 0; - const prevClose = afterhoursData.prev_close || existing?.prevClose || 0; - - if (existing) { - // 合并到现有数据,同时更新价格(如果盘后价格有效) - const newPrice = afterhoursPrice > 0 ? afterhoursPrice : existing.price; - updated[fullCode] = { - ...existing, - price: newPrice, - change: newPrice - prevClose, - changePct: calcChangePct(newPrice, prevClose), - afterhours: afterhoursInfo, - updateTime: timestamp, - } as QuoteData; - } else { - // 盘后首次收到数据(刷新页面后),使用盘后价格创建行情结构 - updated[fullCode] = { - code: fullCode, - name: '', - price: afterhoursPrice, - prevClose: prevClose, - open: 0, - high: 0, - low: 0, - volume: 0, - amount: 0, - change: afterhoursPrice - prevClose, - changePct: calcChangePct(afterhoursPrice, prevClose), - bidPrices: afterhoursPrice > 0 ? [afterhoursPrice] : [], - bidVolumes: [], - askPrices: afterhoursData.offer_px > 0 ? [afterhoursData.offer_px] : [], - askVolumes: [], - tradingPhase: afterhoursData.trading_phase, - afterhours: afterhoursInfo, - updateTime: timestamp, - exchange: 'SZSE', - } as QuoteData; - } - break; - } - - default: - return null; - } + updated[fullCode] = { + code: fullCode, + name: prevQuotes[fullCode]?.name || '', + price: stockData.last_px, + prevClose, + open: stockData.open_px, + high: stockData.high_px, + low: stockData.low_px, + volume: stockData.total_volume_trade, + amount: stockData.total_value_trade, + numTrades: stockData.num_trades, + upperLimit: stockData.upper_limit_px, + lowerLimit: stockData.lower_limit_px, + change: stockData.last_px - prevClose, + changePct: calcChangePct(stockData.last_px, prevClose), + bidPrices: stockData.bid_prices || [], + bidVolumes: stockData.bid_volumes || [], + askPrices: stockData.ask_prices || [], + askVolumes: stockData.ask_volumes || [], + tradingPhase: stockData.trading_phase_code, + updateTime: stockData.update_time || timestamp, + exchange: 'SZSE', + } as QuoteData; return updated; }; -/** - * 处理深交所快照消息 (snapshot) - * 新 API: data 是单个股票对象 { security_id, last_px, ... } - * 旧 API: data 是批量数据 { stocks: [...], indexes: [...], bonds: [...] } - */ -const handleSZSESnapshotMessage = ( - msg: SZSESnapshotMessage, - subscribedCodes: Set, - prevQuotes: QuotesMap -): QuotesMap | null => { - const updated: QuotesMap = { ...prevQuotes }; - let hasUpdate = false; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const data = msg.data as any; - - // 新 API 格式:data 直接是单个股票对象 - if (data && data.security_id) { - const stockData = data as SZSEStockData; - const rawCode = stockData.security_id; - const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`; - - if (subscribedCodes.has(rawCode) || subscribedCodes.has(fullCode)) { - const { bidPrices, bidVolumes, askPrices, askVolumes } = extractSZSEOrderBook(stockData); - const { prevClose, volume, amount, upperLimit, lowerLimit, tradingPhase } = extractSZSEPrices(stockData); - - updated[fullCode] = { - code: fullCode, - name: '', - price: stockData.last_px, - prevClose, - open: stockData.open_px, - high: stockData.high_px, - low: stockData.low_px, - volume, - amount, - numTrades: stockData.num_trades, - upperLimit, - lowerLimit, - change: stockData.last_px - prevClose, - changePct: calcChangePct(stockData.last_px, prevClose), - bidPrices, - bidVolumes, - askPrices, - askVolumes, - tradingPhase, - updateTime: stockData.update_time || msg.timestamp, - exchange: 'SZSE', - } as QuoteData; - return updated; - } - return null; - } - - // 旧 API 格式:批量数据 - const { stocks = [], indexes = [], bonds = [] } = data || {}; - - stocks.forEach((s: SZSEStockData) => { - const rawCode = s.security_id; - const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`; - - if (subscribedCodes.has(rawCode) || subscribedCodes.has(fullCode)) { - hasUpdate = true; - const { bidPrices, bidVolumes, askPrices, askVolumes } = extractSZSEOrderBook(s); - const { prevClose, volume, amount, upperLimit, lowerLimit, tradingPhase } = extractSZSEPrices(s); - - updated[fullCode] = { - code: fullCode, - name: '', - price: s.last_px, - prevClose, - open: s.open_px, - high: s.high_px, - low: s.low_px, - volume, - amount, - numTrades: s.num_trades, - upperLimit, - lowerLimit, - change: s.last_px - prevClose, - changePct: calcChangePct(s.last_px, prevClose), - bidPrices, - bidVolumes, - askPrices, - askVolumes, - tradingPhase, - exchange: 'SZSE', - } as QuoteData; - } - }); - - indexes.forEach((i: SZSEIndexData) => { - const rawCode = i.security_id; - const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`; - - if (subscribedCodes.has(rawCode) || subscribedCodes.has(fullCode)) { - hasUpdate = true; - updated[fullCode] = { - code: fullCode, - name: '', - price: i.current_index, - prevClose: i.prev_close, - open: i.open_index, - high: i.high_index, - low: i.low_index, - volume: i.volume, - amount: i.amount, - numTrades: i.num_trades, - change: i.current_index - i.prev_close, - changePct: calcChangePct(i.current_index, i.prev_close), - bidPrices: [], - bidVolumes: [], - askPrices: [], - askVolumes: [], - exchange: 'SZSE', - } as QuoteData; - } - }); - - bonds.forEach((b: SZSEBondData) => { - const rawCode = b.security_id; - const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`; - - if (subscribedCodes.has(rawCode) || subscribedCodes.has(fullCode)) { - hasUpdate = true; - const prevClose = (b as SZSEStockData).prev_close_px ?? b.prev_close ?? 0; - updated[fullCode] = { - code: fullCode, - name: '', - price: b.last_px, - prevClose, - open: b.open_px, - high: b.high_px, - low: b.low_px, - volume: b.volume, - amount: b.amount, - change: b.last_px - prevClose, - changePct: calcChangePct(b.last_px, prevClose), - bidPrices: [], - bidVolumes: [], - askPrices: [], - askVolumes: [], - exchange: 'SZSE', - isBond: true, - } as QuoteData; - } - }); - - return hasUpdate ? updated : null; -}; - /** * 实时行情 Hook * @param codes - 订阅的证券代码列表(带后缀,如 000001.SZ) @@ -600,13 +304,7 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = const wsRefs = useRef>({ SSE: null, SZSE: null }); const heartbeatRefs = useRef>({ SSE: null, SZSE: null }); const reconnectRefs = useRef>({ SSE: null, SZSE: null }); - // 重连计数器(避免无限重连刷屏) const reconnectCountRef = useRef>({ SSE: 0, SZSE: 0 }); - const MAX_RECONNECT_ATTEMPTS = 5; - // 深交所 WebSocket 就绪状态(收到 welcome 消息后才能订阅) - const szseReadyRef = useRef(false); - // 待发送的深交所订阅队列(在 welcome 之前收到的订阅请求) - const szsePendingSubscribeRef = useRef([]); const subscribedCodes = useRef>>({ SSE: new Set(), @@ -625,25 +323,45 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = heartbeatRefs.current[exchange] = setInterval(() => { const ws = wsRefs.current[exchange]; if (ws && ws.readyState === WebSocket.OPEN) { - // 上交所和深交所(新 API)都使用 action: 'ping' ws.send(JSON.stringify({ action: 'ping' })); } }, HEARTBEAT_INTERVAL); }, [stopHeartbeat]); /** - * 发送深交所订阅请求(新 API 格式) - * 格式:{ action: 'subscribe', channels: ['stock', 'index'], codes: ['000001', '000002'] } + * 发送深交所订阅请求 (新 API 格式) */ const sendSZSESubscribe = useCallback((baseCodes: string[]) => { const ws = wsRefs.current.SZSE; if (ws && ws.readyState === WebSocket.OPEN && baseCodes.length > 0) { + const channels: SZSEChannel[] = ['stock', 'index']; ws.send(JSON.stringify({ action: 'subscribe', - channels: ['stock', 'index'], // 订阅股票和指数频道 + channels, codes: baseCodes, })); - logger.info('FlexScreen', `SZSE 发送订阅请求`, { codes: baseCodes }); + logger.info('FlexScreen', `SZSE 发送订阅请求`, { channels, codes: baseCodes }); + } + }, []); + + /** + * 获取深交所订阅状态 + */ + const getStatus = useCallback(() => { + const ws = wsRefs.current.SZSE; + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ action: 'status' })); + } + }, []); + + /** + * 获取深交所股票快照 + */ + const getSnapshot = useCallback((code: string) => { + const ws = wsRefs.current.SZSE; + if (ws && ws.readyState === WebSocket.OPEN) { + const baseCode = normalizeCode(code); + ws.send(JSON.stringify({ action: 'snapshot', code: baseCode })); } }, []); @@ -651,20 +369,19 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = * 处理消息 */ const handleMessage = useCallback((exchange: Exchange, msg: SSEMessage | SZSEMessage) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const anyMsg = msg as any; - - // 心跳响应(上交所和深交所格式可能不同) - if (msg.type === 'pong' || anyMsg.action === 'pong') return; + // 心跳响应 + if (msg.type === 'pong') return; if (exchange === 'SSE') { // 上交所消息处理 if (msg.type === 'subscribed') { - logger.info('FlexScreen', 'SSE 订阅成功', { channels: anyMsg.channels }); + const sseMsg = msg as SSEMessage; + logger.info('FlexScreen', 'SSE 订阅成功', { channels: sseMsg.channels }); return; } if (msg.type === 'error') { - logger.error('FlexScreen', 'SSE WebSocket 错误', { message: anyMsg.message }); + const sseMsg = msg as SSEMessage; + logger.error('FlexScreen', 'SSE WebSocket 错误', { message: sseMsg.message }); return; } // 处理行情数据 @@ -677,46 +394,36 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = setQuotes(prev => ({ ...prev, ...result })); } } else { - // 深交所消息处理(支持新旧两种 API 格式) - switch (msg.type) { - case 'welcome': - // 收到欢迎消息,深交所 WebSocket 就绪 - logger.info('FlexScreen', 'SZSE WebSocket 就绪,可以订阅'); - szseReadyRef.current = true; - - // 发送之前待处理的订阅请求 - if (szsePendingSubscribeRef.current.length > 0) { - sendSZSESubscribe(szsePendingSubscribeRef.current); - szsePendingSubscribeRef.current = []; - } else { - // 如果已有订阅的代码,立即发送订阅 - const currentCodes = Array.from(subscribedCodes.current.SZSE).map(c => normalizeCode(c)); - if (currentCodes.length > 0) { - sendSZSESubscribe(currentCodes); - } - } - break; + // 深交所消息处理 (v4.0 API) + const szseMsg = msg as SZSEMessage; + switch (szseMsg.type) { case 'subscribed': - // 订阅成功确认(兼容新旧格式) logger.info('FlexScreen', 'SZSE 订阅成功', { - channels: anyMsg.channels, - codes: anyMsg.codes, - securities: anyMsg.securities, - categories: anyMsg.categories, + channels: szseMsg.channels, + codes: szseMsg.codes, }); break; case 'unsubscribed': - // 取消订阅确认 - logger.info('FlexScreen', 'SZSE 取消订阅成功'); + logger.info('FlexScreen', 'SZSE 取消订阅成功', { + channels: szseMsg.channels, + codes: szseMsg.codes, + }); + break; + + case 'status': + logger.info('FlexScreen', 'SZSE 订阅状态', { + channels: szseMsg.channels, + codes: szseMsg.codes, + filter_active: szseMsg.filter_active, + }); break; case 'snapshot': - // 快照消息(订阅后首次返回的批量数据) setQuotes(prev => { const result = handleSZSESnapshotMessage( - msg as SZSESnapshotMessage, + szseMsg as SZSESnapshotMessage, subscribedCodes.current.SZSE, prev ); @@ -724,26 +431,13 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = }); break; - case 'realtime': - // 旧 API:实时行情推送 (type='realtime' + category) - setQuotes(prev => { - const result = handleSZSERealtimeMessage( - msg as SZSERealtimeMessage, - subscribedCodes.current.SZSE, - prev - ); - return result || prev; - }); - break; - - // 新 API:直接使用 type='stock'/'bond'/'fund'/'index' 作为消息类型 case 'stock': - case 'bond': - case 'fund': case 'index': + case 'bond': + case 'hkstock': setQuotes(prev => { const result = handleSZSERealtimeMessage( - msg as SZSERealtimeMessage, + szseMsg as SZSERealtimeMessage, subscribedCodes.current.SZSE, prev ); @@ -751,21 +445,20 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = }); break; - case 'query_result': - case 'query_batch_result': - // 查询结果(目前不处理) + case 'codes_list': + // 代码列表响应(调试用) + logger.info('FlexScreen', 'SZSE 可用代码列表', szseMsg); break; case 'error': - logger.error('FlexScreen', 'SZSE WebSocket 错误', { message: anyMsg.message }); + logger.error('FlexScreen', 'SZSE WebSocket 错误', { message: szseMsg.message }); break; default: - // 未知消息类型 break; } } - }, [sendSZSESubscribe]); + }, []); /** * 创建 WebSocket 连接 @@ -787,11 +480,6 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = wsRefs.current[exchange]!.close(); } - // 重置深交所就绪状态 - if (exchange === 'SZSE') { - szseReadyRef.current = false; - } - try { const ws = new WebSocket(wsUrl); wsRefs.current[exchange] = ws; @@ -799,14 +487,21 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = ws.onopen = () => { logger.info('FlexScreen', `${exchange} WebSocket 已连接`, { url: wsUrl }); setConnected(prev => ({ ...prev, [exchange]: true })); - // 连接成功,重置重连计数 reconnectCountRef.current[exchange] = 0; - if (exchange === 'SSE') { - // 上交所:连接后立即发送订阅 - const fullCodes = Array.from(subscribedCodes.current.SSE); - const baseCodes = fullCodes.map(c => normalizeCode(c)); - if (baseCodes.length > 0) { + // 连接后立即发送订阅 + const fullCodes = Array.from(subscribedCodes.current[exchange]); + const baseCodes = fullCodes.map(c => normalizeCode(c)); + + if (baseCodes.length > 0) { + if (exchange === 'SSE') { + ws.send(JSON.stringify({ + action: 'subscribe', + channels: ['stock', 'index'], + codes: baseCodes, + })); + } else { + // 深交所:新 API 格式,连接后直接订阅 ws.send(JSON.stringify({ action: 'subscribe', channels: ['stock', 'index'], @@ -814,7 +509,6 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = })); } } - // 深交所:等待 welcome 消息后再订阅 startHeartbeat(exchange); }; @@ -828,10 +522,9 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = } }; - ws.onerror = (error: Event) => { + ws.onerror = () => { logger.error('FlexScreen', `${exchange} WebSocket 连接失败`, { url: wsUrl, - readyState: ws.readyState, hint: '请检查:1) 后端服务是否启动 2) Nginx 代理是否配置正确', }); }; @@ -841,12 +534,7 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = setConnected(prev => ({ ...prev, [exchange]: false })); stopHeartbeat(exchange); - // 重置深交所就绪状态 - if (exchange === 'SZSE') { - szseReadyRef.current = false; - } - - // 自动重连(有次数限制,避免刷屏) + // 自动重连(指数退避) const currentAttempts = reconnectCountRef.current[exchange]; if ( !reconnectRefs.current[exchange] && @@ -854,7 +542,6 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = currentAttempts < MAX_RECONNECT_ATTEMPTS ) { reconnectCountRef.current[exchange] = currentAttempts + 1; - // 指数退避:3秒、6秒、12秒、24秒、48秒 const delay = RECONNECT_INTERVAL * Math.pow(2, currentAttempts); logger.info('FlexScreen', `${exchange} 将在 ${delay / 1000} 秒后重连 (${currentAttempts + 1}/${MAX_RECONNECT_ATTEMPTS})`); @@ -865,9 +552,7 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = } }, delay); } else if (currentAttempts >= MAX_RECONNECT_ATTEMPTS) { - logger.warn('FlexScreen', `${exchange} 达到最大重连次数,停止重连。请检查 WebSocket 服务是否正常。`, { - url: wsUrl, - }); + logger.warn('FlexScreen', `${exchange} 达到最大重连次数,停止重连`, { url: wsUrl }); } }; } catch (e) { @@ -887,26 +572,17 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = subscribedCodes.current[exchange].add(fullCode); const ws = wsRefs.current[exchange]; - - if (exchange === 'SSE') { - if (ws && ws.readyState === WebSocket.OPEN) { + if (ws && ws.readyState === WebSocket.OPEN) { + if (exchange === 'SSE') { ws.send(JSON.stringify({ action: 'subscribe', channels: ['stock', 'index'], codes: [baseCode], })); + } else { + sendSZSESubscribe([baseCode]); } } else { - // 深交所 - if (ws && ws.readyState === WebSocket.OPEN && szseReadyRef.current) { - sendSZSESubscribe([baseCode]); - } else if (ws && ws.readyState === WebSocket.OPEN) { - // WebSocket 已连接但未收到 welcome,加入待处理队列 - szsePendingSubscribeRef.current.push(baseCode); - } - } - - if (!ws || ws.readyState !== WebSocket.OPEN) { createConnection(exchange); } }, [createConnection, sendSZSESubscribe]); @@ -921,9 +597,8 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = subscribedCodes.current[exchange].delete(fullCode); - // 发送取消订阅请求(深交所新 API 格式) const ws = wsRefs.current[exchange]; - if (exchange === 'SZSE' && ws && ws.readyState === WebSocket.OPEN) { + if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ action: 'unsubscribe', codes: [baseCode], @@ -936,11 +611,9 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = return updated; }); - if (subscribedCodes.current[exchange].size === 0) { - if (ws) { - ws.close(); - wsRefs.current[exchange] = null; - } + if (subscribedCodes.current[exchange].size === 0 && ws) { + ws.close(); + wsRefs.current[exchange] = null; } }, []); @@ -998,14 +671,9 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = const ws = wsRefs.current.SZSE; if (szseToAdd.length > 0) { - if (ws && ws.readyState === WebSocket.OPEN && szseReadyRef.current) { - // WebSocket 已就绪,直接发送订阅 + if (ws && ws.readyState === WebSocket.OPEN) { sendSZSESubscribe(szseToAddBase); - } else if (ws && ws.readyState === WebSocket.OPEN) { - // WebSocket 已连接但未就绪,加入待处理队列 - szsePendingSubscribeRef.current.push(...szseToAddBase); } else { - // WebSocket 未连接,创建连接 createConnection('SZSE'); } } @@ -1045,7 +713,7 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = }; }, [stopHeartbeat]); - return { quotes, connected, subscribe, unsubscribe }; + return { quotes, connected, subscribe, unsubscribe, getStatus, getSnapshot }; }; export default useRealtimeQuote; diff --git a/src/views/StockOverview/components/FlexScreen/hooks/utils.ts b/src/views/StockOverview/components/FlexScreen/hooks/utils.ts index ca45dd49..fa498931 100644 --- a/src/views/StockOverview/components/FlexScreen/hooks/utils.ts +++ b/src/views/StockOverview/components/FlexScreen/hooks/utils.ts @@ -2,7 +2,7 @@ * 实时行情相关工具函数 */ -import type { Exchange, OrderBookLevel } from '../types'; +import type { Exchange } from '../types'; /** * 判断证券代码属于哪个交易所 @@ -75,23 +75,6 @@ export const normalizeCode = (code: string): string => { return code.split('.')[0]; }; -/** - * 从深交所 bids/asks 数组提取价格和量数组 - * 格式:[{price, volume}, ...] - * @param orderBook - 盘口数组 - * @returns { prices, volumes } - */ -export const extractOrderBook = ( - orderBook: OrderBookLevel[] | undefined -): { prices: number[]; volumes: number[] } => { - if (!orderBook || !Array.isArray(orderBook) || orderBook.length === 0) { - return { prices: [], volumes: [] }; - } - const prices = orderBook.map(item => item.price || 0); - const volumes = orderBook.map(item => item.volume || 0); - return { prices, volumes }; -}; - /** * 计算涨跌幅 * @param price - 当前价 diff --git a/src/views/StockOverview/components/FlexScreen/types.ts b/src/views/StockOverview/components/FlexScreen/types.ts index 7ba15587..54fb14c6 100644 --- a/src/views/StockOverview/components/FlexScreen/types.ts +++ b/src/views/StockOverview/components/FlexScreen/types.ts @@ -1,5 +1,6 @@ /** * 灵活屏组件类型定义 + * 基于深交所 WebSocket API v4.0 (SZSE_WEBSOCKET_API.md) */ // ==================== WebSocket 相关类型 ==================== @@ -7,18 +8,15 @@ /** 交易所标识 */ export type Exchange = 'SSE' | 'SZSE'; +/** 深交所频道类型 */ +export type SZSEChannel = 'stock' | 'index' | 'bond' | 'hkstock'; + /** WebSocket 连接状态 */ export interface ConnectionStatus { SSE: boolean; SZSE: boolean; } -/** 盘口档位数据 */ -export interface OrderBookLevel { - price: number; - volume: number; -} - // ==================== 行情数据类型 ==================== /** 盘后交易数据 */ @@ -59,7 +57,7 @@ export interface StockQuoteData extends BaseQuoteData { askPrices: number[]; askVolumes: number[]; tradingPhase?: string; - afterhours?: AfterhoursData; // 盘后交易数据 + afterhours?: AfterhoursData; } /** 指数行情数据 */ @@ -140,191 +138,180 @@ export interface SSEMessage { // ==================== 深交所 WebSocket 消息类型 ==================== // API 文档: SZSE_WEBSOCKET_API.md -// 与上交所 API 保持一致的设计 -/** 深交所数据类别(对应 channels) */ -export type SZSECategory = 'stock' | 'bond' | 'fund'; - -/** 深交所股票行情数据(新 API 格式) */ +/** + * 深交所股票行情数据 (消息类型 300111) + * 字段名与 API 文档保持一致 + */ export interface SZSEStockData { security_id: string; - md_stream_id?: string; // MDStreamID: 010 + md_stream_id?: string; orig_time?: number; channel_no?: number; - trading_phase_code?: string; // 新字段名 - trading_phase?: string; // 兼容旧字段名 - prev_close_px: number; // 新字段名 - prev_close?: number; // 兼容旧字段名 + trading_phase_code?: string; + prev_close_px: number; open_px: number; high_px: number; low_px: number; last_px: number; - upper_limit_px?: number; // 新字段名 - upper_limit?: number; // 兼容旧字段名 - lower_limit_px?: number; // 新字段名 - lower_limit?: number; // 兼容旧字段名 + upper_limit_px?: number; + lower_limit_px?: number; num_trades?: number; - total_volume_trade?: number; // 新字段名 (成交量) - total_value_trade?: number; // 新字段名 (成交额) - volume?: number; // 兼容旧字段名 - amount?: number; // 兼容旧字段名 - // 新 API 格式:直接是数组 - bid_prices?: number[]; - bid_volumes?: number[]; - ask_prices?: number[]; - ask_volumes?: number[]; - // 兼容旧格式 - bids?: OrderBookLevel[]; - asks?: OrderBookLevel[]; + total_volume_trade: number; + total_value_trade: number; + bid_prices: number[]; + bid_volumes: number[]; + ask_prices: number[]; + ask_volumes: number[]; update_time?: string; } -/** 深交所指数行情数据 */ +/** + * 深交所指数行情数据 (消息类型 309011) + */ export interface SZSEIndexData { security_id: string; - orig_time?: number; - channel_no?: number; - trading_phase?: string; + md_stream_id?: string; + prev_close: number; + num_trades?: number; + volume: number; + amount: number; current_index: number; open_index: number; high_index: number; low_index: number; close_index?: number; - prev_close: number; - volume: number; - amount: number; - num_trades?: number; + update_time?: string; } -/** 深交所债券行情数据 */ +/** + * 深交所债券行情数据 (消息类型 300211) + */ export interface SZSEBondData { security_id: string; - orig_time?: number; - channel_no?: number; - trading_phase?: string; - last_px: number; + md_stream_id?: string; + trading_phase_code?: string; + prev_close: number; open_px: number; high_px: number; low_px: number; - prev_close: number; + last_px: number; weighted_avg_px?: number; + num_trades?: number; volume: number; amount: number; - num_trades?: number; - auction_volume?: number; - auction_amount?: number; + update_time?: string; } -/** 深交所港股行情数据 */ +/** + * 深交所港股行情数据 (消息类型 306311) + */ export interface SZSEHKStockData { security_id: string; - orig_time?: number; - channel_no?: number; - trading_phase?: string; - last_px: number; + md_stream_id?: string; + trading_phase_code?: string; + prev_close: number; open_px: number; high_px: number; low_px: number; - prev_close: number; - nominal_px?: number; - reference_px?: number; + last_px: number; + nominal_px?: number; // 按盘价 + num_trades?: number; volume: number; amount: number; - num_trades?: number; - vcm_start_time?: number; - vcm_end_time?: number; - bids?: OrderBookLevel[]; - asks?: OrderBookLevel[]; + bid_prices: number[]; + bid_volumes: number[]; + ask_prices: number[]; + ask_volumes: number[]; + update_time?: string; } -/** 深交所盘后交易数据 */ -export interface SZSEAfterhoursData { - security_id: string; - orig_time?: number; - channel_no?: number; - trading_phase?: string; - prev_close: number; - bid_px: number; - bid_size: number; - offer_px: number; - offer_size: number; - volume: number; - amount: number; - num_trades?: number; -} - -/** 深交所实时消息(新 API 格式:type 直接是 'stock' | 'index' | 'bond' | 'fund') */ +/** + * 深交所实时推送消息 (批量格式) + * type 直接表示频道类型 + */ export interface SZSERealtimeMessage { - type: 'stock' | 'index' | 'bond' | 'fund' | 'hkstock' | 'realtime'; // 新 API 直接用 type='stock' 等 - category?: SZSECategory; // 旧 API 使用 category - msg_type?: number; + type: 'stock' | 'index' | 'bond' | 'hkstock'; + data: Record; timestamp: string; - // 新 API 批量格式:data 是 { code: quote, ... } 字典 - // 旧 API 单条格式:data 是单个行情对象 - data: SZSEStockData | SZSEIndexData | SZSEBondData | SZSEHKStockData | SZSEAfterhoursData | Record; } -/** 深交所快照消息 */ +/** + * 深交所快照响应消息 + */ export interface SZSESnapshotMessage { type: 'snapshot'; + data: SZSEStockData | SZSEIndexData | SZSEBondData | SZSEHKStockData; timestamp?: string; - data: SZSEStockData | { - // 兼容旧格式的批量快照 - stocks?: SZSEStockData[]; - indexes?: SZSEIndexData[]; - bonds?: SZSEBondData[]; - }; } -/** 深交所欢迎消息 */ -export interface SZSEWelcomeMessage { - type: 'welcome'; - message: string; - timestamp: string; - usage?: Record; - categories?: string[]; -} - -/** 深交所订阅确认消息 */ +/** + * 深交所订阅确认消息 + */ export interface SZSESubscribedMessage { type: 'subscribed'; - channels?: string[]; // 新 API 格式 - codes?: string[]; // 新 API 格式 - securities?: string[]; // 兼容旧格式 - categories?: string[]; // 兼容旧格式 - all?: boolean; - incremental_only?: boolean; - message?: string; + channels: SZSEChannel[]; + codes: string[]; } -/** 深交所取消订阅确认消息 */ +/** + * 深交所取消订阅确认消息 + */ export interface SZSEUnsubscribedMessage { type: 'unsubscribed'; - channels?: string[]; // 新 API 格式 - codes?: string[]; // 新 API 格式 - securities?: string[]; // 兼容旧格式 - categories?: string[]; // 兼容旧格式 - remaining_securities?: string[]; - remaining_categories?: string[]; + channels: SZSEChannel[]; + codes: string[]; } -/** 深交所错误消息 */ +/** + * 深交所订阅状态响应 + */ +export interface SZSEStatusMessage { + type: 'status'; + channels: SZSEChannel[]; + codes: string[]; + filter_active: boolean; +} + +/** + * 深交所代码列表响应 (单频道) + */ +export interface SZSECodesListSingleMessage { + type: 'codes_list'; + category: SZSEChannel; + codes: string[]; + count: number; +} + +/** + * 深交所代码列表响应 (全部频道) + */ +export interface SZSECodesListAllMessage { + type: 'codes_list'; + data: Record; +} + +/** + * 深交所错误消息 + */ export interface SZSEErrorMessage { type: 'error'; message: string; } -/** 深交所消息类型 */ +/** + * 深交所消息联合类型 + */ export type SZSEMessage = | SZSERealtimeMessage | SZSESnapshotMessage - | SZSEWelcomeMessage | SZSESubscribedMessage | SZSEUnsubscribedMessage + | SZSEStatusMessage + | SZSECodesListSingleMessage + | SZSECodesListAllMessage | SZSEErrorMessage - | { type: 'pong'; timestamp?: string } - | { type: 'query_result'; security_id: string; found: boolean; data: unknown } - | { type: 'query_batch_result'; count: number; found: number; data: Record }; + | { type: 'pong' }; // ==================== 组件 Props 类型 ==================== @@ -377,4 +364,8 @@ export interface UseRealtimeQuoteReturn { connected: ConnectionStatus; subscribe: (code: string) => void; unsubscribe: (code: string) => void; + /** 获取订阅状态 (仅深交所) */ + getStatus: () => void; + /** 获取单只股票快照 (仅深交所) */ + getSnapshot: (code: string) => void; }