From dae1a539ac6ef15e85b1a13928b6fa4ad5d9bb86 Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Thu, 11 Dec 2025 11:18:12 +0800 Subject: [PATCH] update pay ui --- .../FlexScreen/hooks/useRealtimeQuote.ts | 247 ++++++++++++++---- .../components/FlexScreen/types.ts | 67 +++-- 2 files changed, 239 insertions(+), 75 deletions(-) diff --git a/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts b/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts index 53e7e617..aa4b1df6 100644 --- a/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts +++ b/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts @@ -79,14 +79,100 @@ const handleSSEMessage = ( }; /** - * 处理深交所实时消息 (realtime) + * 从深交所数据中提取盘口价格和量 + * 新 API 格式:直接使用 bid_prices/bid_volumes/ask_prices/ask_volumes 数组 + * 旧 API 格式:使用 bids/asks 对象数组 + */ +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 格式:type='stock') + */ +const handleSZSEStockMessage = ( + data: SZSEStockData, + timestamp: string, + subscribedCodes: Set, + prevQuotes: QuotesMap +): QuotesMap | null => { + const rawCode = data.security_id; + const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`; + + if (!subscribedCodes.has(rawCode) && !subscribedCodes.has(fullCode)) { + return null; + } + + const { bidPrices, bidVolumes, askPrices, askVolumes } = extractSZSEOrderBook(data); + const { prevClose, volume, amount, upperLimit, lowerLimit, tradingPhase } = extractSZSEPrices(data); + + const updated: QuotesMap = { ...prevQuotes }; + updated[fullCode] = { + code: fullCode, + name: prevQuotes[fullCode]?.name || '', + price: data.last_px, + prevClose, + open: data.open_px, + high: data.high_px, + low: data.low_px, + volume, + amount, + numTrades: data.num_trades, + upperLimit, + lowerLimit, + change: data.last_px - prevClose, + changePct: calcChangePct(data.last_px, prevClose), + bidPrices, + bidVolumes, + askPrices, + askVolumes, + tradingPhase, + updateTime: data.update_time || timestamp, + exchange: 'SZSE', + } as QuoteData; + + return updated; +}; + +/** + * 处理深交所实时消息 (兼容新旧 API) + * 新 API: type='stock'/'bond'/'fund', data 直接是行情对象 + * 旧 API: type='realtime', category='stock'/'bond'/etc */ const handleSZSERealtimeMessage = ( msg: SZSERealtimeMessage, subscribedCodes: Set, prevQuotes: QuotesMap ): QuotesMap | null => { - const { category, data, timestamp } = msg; + const { type, category, data, timestamp } = msg; const rawCode = data.security_id; const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`; @@ -96,33 +182,36 @@ const handleSZSERealtimeMessage = ( const updated: QuotesMap = { ...prevQuotes }; - switch (category) { + // 确定实际的类别:新 API 直接用 type,旧 API 用 category + const actualCategory = (type === 'realtime' ? category : type) as string; + + switch (actualCategory) { case 'stock': { const stockData = data as SZSEStockData; - const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(stockData.bids); - const { prices: askPrices, volumes: askVolumes } = extractOrderBook(stockData.asks); + 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: stockData.prev_close, + prevClose, open: stockData.open_px, high: stockData.high_px, low: stockData.low_px, - volume: stockData.volume, - amount: stockData.amount, + volume, + amount, numTrades: stockData.num_trades, - upperLimit: stockData.upper_limit, - lowerLimit: stockData.lower_limit, - change: stockData.last_px - stockData.prev_close, - changePct: calcChangePct(stockData.last_px, stockData.prev_close), + upperLimit, + lowerLimit, + change: stockData.last_px - prevClose, + changePct: calcChangePct(stockData.last_px, prevClose), bidPrices, bidVolumes, askPrices, askVolumes, - tradingPhase: stockData.trading_phase, - updateTime: timestamp, + tradingPhase, + updateTime: stockData.update_time || timestamp, exchange: 'SZSE', } as QuoteData; break; @@ -155,13 +244,15 @@ const handleSZSERealtimeMessage = ( break; } - case 'bond': { + 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: bondData.prev_close, + prevClose, open: bondData.open_px, high: bondData.high_px, low: bondData.low_px, @@ -169,8 +260,8 @@ const handleSZSERealtimeMessage = ( amount: bondData.amount, numTrades: bondData.num_trades, weightedAvgPx: bondData.weighted_avg_px, - change: bondData.last_px - bondData.prev_close, - changePct: calcChangePct(bondData.last_px, bondData.prev_close), + change: bondData.last_px - prevClose, + changePct: calcChangePct(bondData.last_px, prevClose), bidPrices: [], bidVolumes: [], askPrices: [], @@ -280,45 +371,90 @@ const handleSZSERealtimeMessage = ( /** * 处理深交所快照消息 (snapshot) - * 订阅后首次返回的批量数据 + * 新 API: data 是单个股票对象 { security_id, last_px, ... } + * 旧 API: data 是批量数据 { stocks: [...], indexes: [...], bonds: [...] } */ const handleSZSESnapshotMessage = ( msg: SZSESnapshotMessage, subscribedCodes: Set, prevQuotes: QuotesMap ): QuotesMap | null => { - const { stocks = [], indexes = [], bonds = [] } = msg.data || {}; 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 { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(s.bids); - const { prices: askPrices, volumes: askVolumes } = extractOrderBook(s.asks); + 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: s.prev_close, + prevClose, open: s.open_px, high: s.high_px, low: s.low_px, - volume: s.volume, - amount: s.amount, + volume, + amount, numTrades: s.num_trades, - upperLimit: s.upper_limit, - lowerLimit: s.lower_limit, - change: s.last_px - s.prev_close, - changePct: calcChangePct(s.last_px, s.prev_close), + upperLimit, + lowerLimit, + change: s.last_px - prevClose, + changePct: calcChangePct(s.last_px, prevClose), bidPrices, bidVolumes, askPrices, askVolumes, + tradingPhase, exchange: 'SZSE', } as QuoteData; } @@ -358,18 +494,19 @@ const handleSZSESnapshotMessage = ( 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: b.prev_close, + prevClose, open: b.open_px, high: b.high_px, low: b.low_px, volume: b.volume, amount: b.amount, - change: b.last_px - b.prev_close, - changePct: calcChangePct(b.last_px, b.prev_close), + change: b.last_px - prevClose, + changePct: calcChangePct(b.last_px, prevClose), bidPrices: [], bidVolumes: [], askPrices: [], @@ -419,27 +556,25 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = heartbeatRefs.current[exchange] = setInterval(() => { const ws = wsRefs.current[exchange]; if (ws && ws.readyState === WebSocket.OPEN) { - // 上交所使用 action: 'ping',深交所使用 type: 'ping' - const pingMsg = exchange === 'SSE' - ? { action: 'ping' } - : { type: 'ping' }; - ws.send(JSON.stringify(pingMsg)); + // 上交所和深交所(新 API)都使用 action: 'ping' + ws.send(JSON.stringify({ action: 'ping' })); } }, HEARTBEAT_INTERVAL); }, [stopHeartbeat]); /** - * 发送深交所订阅请求 - * 格式:{ type: 'subscribe', securities: ['000001', '000002'] } + * 发送深交所订阅请求(新 API 格式) + * 格式:{ action: 'subscribe', channels: ['stock'], codes: ['000001', '000002'] } */ const sendSZSESubscribe = useCallback((baseCodes: string[]) => { const ws = wsRefs.current.SZSE; if (ws && ws.readyState === WebSocket.OPEN && baseCodes.length > 0) { ws.send(JSON.stringify({ - type: 'subscribe', - securities: baseCodes, + action: 'subscribe', + channels: ['stock'], // 订阅股票频道 + codes: baseCodes, })); - logger.info('FlexScreen', `SZSE 发送订阅请求`, { securities: baseCodes }); + logger.info('FlexScreen', `SZSE 发送订阅请求`, { codes: baseCodes }); } }, []); @@ -473,7 +608,7 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = setQuotes(prev => ({ ...prev, ...result })); } } else { - // 深交所消息处理 + // 深交所消息处理(支持新旧两种 API 格式) switch (msg.type) { case 'welcome': // 收到欢迎消息,深交所 WebSocket 就绪 @@ -494,8 +629,10 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = break; case 'subscribed': - // 订阅成功确认 + // 订阅成功确认(兼容新旧格式) logger.info('FlexScreen', 'SZSE 订阅成功', { + channels: anyMsg.channels, + codes: anyMsg.codes, securities: anyMsg.securities, categories: anyMsg.categories, }); @@ -519,7 +656,21 @@ 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' 作为消息类型 + case 'stock': + case 'bond': + case 'fund': setQuotes(prev => { const result = handleSZSERealtimeMessage( msg as SZSERealtimeMessage, @@ -700,12 +851,12 @@ 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) { ws.send(JSON.stringify({ - type: 'unsubscribe', - securities: [baseCode], + action: 'unsubscribe', + codes: [baseCode], })); } diff --git a/src/views/StockOverview/components/FlexScreen/types.ts b/src/views/StockOverview/components/FlexScreen/types.ts index 7430b842..317dcfd3 100644 --- a/src/views/StockOverview/components/FlexScreen/types.ts +++ b/src/views/StockOverview/components/FlexScreen/types.ts @@ -139,36 +139,44 @@ export interface SSEMessage { } // ==================== 深交所 WebSocket 消息类型 ==================== +// API 文档: SZSE_WEBSOCKET_API.md +// 与上交所 API 保持一致的设计 -/** 深交所数据类别 */ -export type SZSECategory = - | 'stock' // 300111 股票快照 - | 'bond' // 300211 债券快照 - | 'afterhours_block' // 300611 盘后定价大宗交易 - | 'afterhours_trading' // 303711 盘后定价交易 - | 'hk_stock' // 306311 港股快照 - | 'index' // 309011 指数快照 - | 'volume_stats' // 309111 成交量统计 - | 'fund_nav'; // 309211 基金净值 +/** 深交所数据类别(对应 channels) */ +export type SZSECategory = 'stock' | 'bond' | 'fund'; -/** 深交所股票行情数据 */ +/** 深交所股票行情数据(新 API 格式) */ export interface SZSEStockData { security_id: string; + md_stream_id?: string; // MDStreamID: 010 orig_time?: number; channel_no?: number; - trading_phase?: string; - last_px: number; + trading_phase_code?: string; // 新字段名 + trading_phase?: string; // 兼容旧字段名 + prev_close_px: number; // 新字段名 + prev_close?: number; // 兼容旧字段名 open_px: number; high_px: number; low_px: number; - prev_close: number; - volume: number; - amount: number; + last_px: number; + upper_limit_px?: number; // 新字段名 + upper_limit?: number; // 兼容旧字段名 + lower_limit_px?: number; // 新字段名 + lower_limit?: number; // 兼容旧字段名 num_trades?: number; - upper_limit?: number; - lower_limit?: 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[]; + update_time?: string; } /** 深交所指数行情数据 */ @@ -245,10 +253,10 @@ export interface SZSEAfterhoursData { num_trades?: number; } -/** 深交所实时消息 */ +/** 深交所实时消息(新 API 格式:type 直接是 'stock' | 'bond' | 'fund') */ export interface SZSERealtimeMessage { - type: 'realtime'; - category: SZSECategory; + type: 'stock' | 'bond' | 'fund' | 'realtime'; // 新 API 直接用 type='stock' 等 + category?: SZSECategory; // 旧 API 使用 category msg_type?: number; timestamp: string; data: SZSEStockData | SZSEIndexData | SZSEBondData | SZSEHKStockData | SZSEAfterhoursData; @@ -257,8 +265,9 @@ export interface SZSERealtimeMessage { /** 深交所快照消息 */ export interface SZSESnapshotMessage { type: 'snapshot'; - timestamp: string; - data: { + timestamp?: string; + data: SZSEStockData | { + // 兼容旧格式的批量快照 stocks?: SZSEStockData[]; indexes?: SZSEIndexData[]; bonds?: SZSEBondData[]; @@ -277,8 +286,10 @@ export interface SZSEWelcomeMessage { /** 深交所订阅确认消息 */ export interface SZSESubscribedMessage { type: 'subscribed'; - securities?: string[]; - categories?: string[]; + channels?: string[]; // 新 API 格式 + codes?: string[]; // 新 API 格式 + securities?: string[]; // 兼容旧格式 + categories?: string[]; // 兼容旧格式 all?: boolean; incremental_only?: boolean; message?: string; @@ -287,8 +298,10 @@ export interface SZSESubscribedMessage { /** 深交所取消订阅确认消息 */ export interface SZSEUnsubscribedMessage { type: 'unsubscribed'; - securities?: string[]; - categories?: string[]; + channels?: string[]; // 新 API 格式 + codes?: string[]; // 新 API 格式 + securities?: string[]; // 兼容旧格式 + categories?: string[]; // 兼容旧格式 remaining_securities?: string[]; remaining_categories?: string[]; }