From ff42b17119428242262e0c83c56882558cfb5c5c Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Thu, 11 Dec 2025 11:56:24 +0800 Subject: [PATCH 1/5] update pay ui --- .../components/MiniTimelineChart.tsx | 34 +++++- .../FlexScreen/hooks/useRealtimeQuote.ts | 104 ++++++++++++------ .../components/FlexScreen/types.ts | 8 +- 3 files changed, 102 insertions(+), 44 deletions(-) diff --git a/src/views/StockOverview/components/FlexScreen/components/MiniTimelineChart.tsx b/src/views/StockOverview/components/FlexScreen/components/MiniTimelineChart.tsx index a22d2a61..86fefb09 100644 --- a/src/views/StockOverview/components/FlexScreen/components/MiniTimelineChart.tsx +++ b/src/views/StockOverview/components/FlexScreen/components/MiniTimelineChart.tsx @@ -65,13 +65,20 @@ const MiniTimelineChart: React.FC = ({ const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // 是否首次加载 + const isFirstLoad = useRef(true); + // 用 ref 追踪是否有数据(避免闭包问题) + const hasDataRef = useRef(false); + // 获取分钟数据 useEffect(() => { if (!code) return; const fetchData = async (): Promise => { - setLoading(true); - setError(null); + // 只在首次加载时显示 loading 状态 + if (isFirstLoad.current) { + setLoading(true); + } try { const apiPath = isIndex @@ -81,23 +88,40 @@ const MiniTimelineChart: React.FC = ({ const response = await fetch(apiPath); const result: KLineApiResponse = await response.json(); - if (result.success !== false && result.data) { + if (result.success !== false && result.data && result.data.length > 0) { // 格式化数据 const formatted: TimelineDataPoint[] = result.data.map(item => ({ time: item.time || item.timestamp || '', price: item.close || item.price || 0, })); setTimelineData(formatted); + hasDataRef.current = true; + setError(null); // 清除之前的错误 } else { - setError(result.error || '暂无数据'); + // 只有在没有原有数据时才设置错误(保留原有数据) + if (!hasDataRef.current) { + setError(result.error || '暂无数据'); + } + // 有原有数据时,静默失败,保持显示原有数据 } } catch (e) { - setError('加载失败'); + // 只有在没有原有数据时才设置错误(保留原有数据) + if (!hasDataRef.current) { + setError('加载失败'); + } + // 有原有数据时,静默失败,保持显示原有数据 } finally { setLoading(false); + isFirstLoad.current = false; } }; + // 重置首次加载标记(code 变化时) + isFirstLoad.current = true; + hasDataRef.current = false; + setTimelineData([]); // 切换股票时清空数据 + setError(null); + fetchData(); // 交易时间内每分钟刷新 diff --git a/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts b/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts index d344af71..3db861c4 100644 --- a/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts +++ b/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts @@ -117,14 +117,14 @@ const extractSZSEPrices = (stockData: SZSEStockData) => { /** * 处理深交所批量行情消息(新 API 格式,与 SSE 一致) - * 格式:{ type: 'stock', data: { '000001': {...}, '000002': {...} }, timestamp: '...' } + * 格式:{ type: 'stock'/'index', data: { '000001': {...}, '399001': {...} }, timestamp: '...' } */ const handleSZSEBatchMessage = ( msg: SZSERealtimeMessage, subscribedCodes: Set, prevQuotes: QuotesMap ): QuotesMap | null => { - const { data, timestamp } = msg; + const { type, data, timestamp } = msg; // 新 API 格式:data 是 { code: quote, ... } 的字典 if (!data || typeof data !== 'object') { @@ -133,14 +133,15 @@ const handleSZSEBatchMessage = ( 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 stockData = quote as any; - if (!stockData || typeof stockData !== 'object') return; + const quoteData = quote as any; + if (!quoteData || typeof quoteData !== 'object') return; - const rawCode = stockData.security_id || code; + const rawCode = quoteData.security_id || code; const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`; // 只处理已订阅的代码 @@ -149,32 +150,62 @@ const handleSZSEBatchMessage = ( } hasUpdate = true; - 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; + if (isIndexType) { + // 指数数据格式 + const prevClose = quoteData.prev_close ?? 0; + const currentIndex = quoteData.current_index ?? quoteData.last_px ?? 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.volume ?? 0, + amount: 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: 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; + } }); return hasUpdate ? updated : null; @@ -182,7 +213,7 @@ const handleSZSEBatchMessage = ( /** * 处理深交所实时消息 (兼容新旧 API) - * 新 API (批量模式): type='stock'/'bond'/'fund', data = { code: quote, ... } + * 新 API (批量模式): type='stock'/'bond'/'fund'/'index', data = { code: quote, ... } * 旧 API (单条模式): type='realtime', category='stock', data = { security_id, ... } */ const handleSZSERealtimeMessage = ( @@ -197,7 +228,7 @@ const handleSZSERealtimeMessage = ( const anyData = data as any; const isBatchFormat = anyData && typeof anyData === 'object' && !anyData.security_id; - if (isBatchFormat && (type === 'stock' || type === 'bond' || type === 'fund')) { + if (isBatchFormat && (type === 'stock' || type === 'bond' || type === 'fund' || type === 'index')) { return handleSZSEBatchMessage(msg, subscribedCodes, prevQuotes); } @@ -597,14 +628,14 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = /** * 发送深交所订阅请求(新 API 格式) - * 格式:{ action: 'subscribe', channels: ['stock'], codes: ['000001', '000002'] } + * 格式:{ action: 'subscribe', channels: ['stock', 'index'], 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({ action: 'subscribe', - channels: ['stock'], // 订阅股票频道 + channels: ['stock', 'index'], // 订阅股票和指数频道 codes: baseCodes, })); logger.info('FlexScreen', `SZSE 发送订阅请求`, { codes: baseCodes }); @@ -700,10 +731,11 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn = }); break; - // 新 API:直接使用 type='stock'/'bond'/'fund' 作为消息类型 + // 新 API:直接使用 type='stock'/'bond'/'fund'/'index' 作为消息类型 case 'stock': case 'bond': case 'fund': + case 'index': setQuotes(prev => { const result = handleSZSERealtimeMessage( msg as SZSERealtimeMessage, diff --git a/src/views/StockOverview/components/FlexScreen/types.ts b/src/views/StockOverview/components/FlexScreen/types.ts index 317dcfd3..7ba15587 100644 --- a/src/views/StockOverview/components/FlexScreen/types.ts +++ b/src/views/StockOverview/components/FlexScreen/types.ts @@ -253,13 +253,15 @@ export interface SZSEAfterhoursData { num_trades?: number; } -/** 深交所实时消息(新 API 格式:type 直接是 'stock' | 'bond' | 'fund') */ +/** 深交所实时消息(新 API 格式:type 直接是 'stock' | 'index' | 'bond' | 'fund') */ export interface SZSERealtimeMessage { - type: 'stock' | 'bond' | 'fund' | 'realtime'; // 新 API 直接用 type='stock' 等 + type: 'stock' | 'index' | 'bond' | 'fund' | 'hkstock' | 'realtime'; // 新 API 直接用 type='stock' 等 category?: SZSECategory; // 旧 API 使用 category msg_type?: number; timestamp: string; - data: SZSEStockData | SZSEIndexData | SZSEBondData | SZSEHKStockData | SZSEAfterhoursData; + // 新 API 批量格式:data 是 { code: quote, ... } 字典 + // 旧 API 单条格式:data 是单个行情对象 + data: SZSEStockData | SZSEIndexData | SZSEBondData | SZSEHKStockData | SZSEAfterhoursData | Record; } /** 深交所快照消息 */ From 39ad523dada3e1481866d4b1350990f60200a074 Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Thu, 11 Dec 2025 13:16:03 +0800 Subject: [PATCH 2/5] update pay ui --- .../FlexScreen/hooks/useRealtimeQuote.ts | 31 +++++++++++-------- .../components/FlexScreen/index.tsx | 2 +- src/views/StockOverview/index.js | 2 +- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts b/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts index 3db861c4..9b4ad3c4 100644 --- a/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts +++ b/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts @@ -152,8 +152,8 @@ const handleSZSEBatchMessage = ( hasUpdate = true; if (isIndexType) { - // 指数数据格式 - const prevClose = quoteData.prev_close ?? 0; + // 指数数据格式(兼容多种字段名) + const prevClose = quoteData.prev_close_px ?? quoteData.prev_close ?? quoteData.prevClose ?? 0; const currentIndex = quoteData.current_index ?? quoteData.last_px ?? 0; updated[fullCode] = { @@ -165,8 +165,8 @@ const handleSZSEBatchMessage = ( high: quoteData.high_index ?? quoteData.high_px ?? 0, low: quoteData.low_index ?? quoteData.low_px ?? 0, close: quoteData.close_index, - volume: quoteData.volume ?? 0, - amount: quoteData.amount ?? 0, + 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), @@ -283,20 +283,25 @@ const handleSZSERealtimeMessage = ( 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: indexData.current_index, - prevClose: indexData.prev_close, - open: indexData.open_index, - high: indexData.high_index, - low: indexData.low_index, + 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: indexData.volume, - amount: indexData.amount, + volume: anyIndexData.total_volume_trade ?? indexData.volume ?? 0, + amount: anyIndexData.total_value_trade ?? indexData.amount ?? 0, numTrades: indexData.num_trades, - change: indexData.current_index - indexData.prev_close, - changePct: calcChangePct(indexData.current_index, indexData.prev_close), + change: currentIndex - prevClose, + changePct: calcChangePct(currentIndex, prevClose), bidPrices: [], bidVolumes: [], askPrices: [], diff --git a/src/views/StockOverview/components/FlexScreen/index.tsx b/src/views/StockOverview/components/FlexScreen/index.tsx index c73e1e33..8a56f7b7 100644 --- a/src/views/StockOverview/components/FlexScreen/index.tsx +++ b/src/views/StockOverview/components/FlexScreen/index.tsx @@ -128,7 +128,7 @@ const FlexScreen: React.FC = () => { const borderColor = 'rgba(255, 255, 255, 0.08)'; const textColor = 'rgba(255, 255, 255, 0.95)'; const subTextColor = 'rgba(255, 255, 255, 0.6)'; - const searchBg = 'rgba(255, 255, 255, 0.05)'; + const searchBg = 'rgba(255, 255, 255, 0.12)'; // 调亮搜索框背景 const hoverBg = 'rgba(255, 255, 255, 0.08)'; const accentColor = '#8b5cf6'; diff --git a/src/views/StockOverview/index.js b/src/views/StockOverview/index.js index dbd5e67d..4fad168d 100644 --- a/src/views/StockOverview/index.js +++ b/src/views/StockOverview/index.js @@ -108,7 +108,7 @@ const StockOverview = () => { const cardBg = 'rgba(255, 255, 255, 0.03)'; // 玻璃态卡片背景 const borderColor = 'rgba(255, 255, 255, 0.08)'; // 边框 const hoverBg = 'rgba(255, 255, 255, 0.06)'; // 悬停背景 - const searchBg = 'rgba(255, 255, 255, 0.05)'; // 搜索框背景 + const searchBg = 'rgba(255, 255, 255, 0.15)'; // 搜索框背景(调亮) const textColor = 'rgba(255, 255, 255, 0.95)'; // 主文字 const subTextColor = 'rgba(255, 255, 255, 0.6)'; // 次要文字 const goldColor = '#8b5cf6'; // 使用紫色作为强调色 From 5f238441604d84f4127386a2d621b7d504c8eb4f Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Thu, 11 Dec 2025 13:39:45 +0800 Subject: [PATCH 3/5] 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; } From 3cc7f2ca6e576aa9be999a3a4f1d496080c75a27 Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Thu, 11 Dec 2025 13:53:23 +0800 Subject: [PATCH 4/5] update pay ui --- .../components/AlertDetailDrawer.js | 523 ++++++++++++++++++ .../components/ConceptAlertList.js | 40 +- .../components/IndexMinuteChart.js | 70 ++- .../HotspotOverview/components/index.js | 1 + .../components/HotspotOverview/index.js | 25 +- .../HotspotOverview/utils/chartHelpers.js | 131 ++++- 6 files changed, 736 insertions(+), 54 deletions(-) create mode 100644 src/views/StockOverview/components/HotspotOverview/components/AlertDetailDrawer.js diff --git a/src/views/StockOverview/components/HotspotOverview/components/AlertDetailDrawer.js b/src/views/StockOverview/components/HotspotOverview/components/AlertDetailDrawer.js new file mode 100644 index 00000000..7244b4c5 --- /dev/null +++ b/src/views/StockOverview/components/HotspotOverview/components/AlertDetailDrawer.js @@ -0,0 +1,523 @@ +/** + * 异动详情右边栏抽屉组件 + * 点击分时图上的异动标记后显示,展示该时间段的所有异动详情 + */ +import React, { useState, useCallback, useEffect } from 'react'; +import { + Drawer, + DrawerBody, + DrawerHeader, + DrawerOverlay, + DrawerContent, + DrawerCloseButton, + Box, + VStack, + HStack, + Text, + Badge, + Icon, + Collapse, + Spinner, + Divider, + Tooltip, + Flex, +} from '@chakra-ui/react'; +import { keyframes, css } from '@emotion/react'; +import { + Clock, + Zap, + TrendingUp, + TrendingDown, + ChevronDown, + ChevronRight, + BarChart3, + Flame, + Target, + Activity, + Rocket, + Waves, + Gauge, + Sparkles, + ExternalLink, +} from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import axios from 'axios'; +import { colors, glassEffect } from '../../../theme/glassTheme'; +import { + ALERT_TYPE_CONFIG, + getAlertTypeLabel, + getAlertTypeDescription, + getScoreColor, + formatScore, +} from '../utils/chartHelpers'; +import MiniTimelineChart from '@components/Charts/Stock/MiniTimelineChart'; + +// 动画 +const pulseGlow = keyframes` + 0%, 100% { opacity: 0.6; } + 50% { opacity: 1; } +`; + +/** + * 获取异动类型图标 + */ +const getAlertIcon = (alertType) => { + const iconMap = { + surge_up: TrendingUp, + surge: Zap, + surge_down: TrendingDown, + volume_surge_up: Activity, + shrink_surge_up: Rocket, + volume_oscillation: Waves, + limit_up: Flame, + rank_jump: Target, + volume_spike: BarChart3, + }; + return iconMap[alertType] || Zap; +}; + +/** + * 单个异动详情卡片 + */ +const AlertDetailCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => { + const navigate = useNavigate(); + const alertConfig = ALERT_TYPE_CONFIG[alert.alert_type] || ALERT_TYPE_CONFIG.surge; + const isUp = alert.alert_type !== 'surge_down'; + const AlertIcon = getAlertIcon(alert.alert_type); + + const handleStockClick = (e, stockCode) => { + e.stopPropagation(); + navigate(`/company?scode=${stockCode}`); + }; + + const handleConceptClick = (e) => { + e.stopPropagation(); + if (alert.concept_id) { + navigate(`/concept/${alert.concept_id}`); + } + }; + + return ( + + {/* 顶部渐变条 */} + + + {/* 主内容区 - 可点击展开 */} + + {/* 第一行:展开箭头 + 概念名称 + 评分 */} + + + + + + + + + + {alert.concept_name} + + + + + + + + + {alert.time} + + + + + {/* 评分 */} + + + + {formatScore(alert.final_score)} + + + + + + {/* 第二行:类型标签 + Alpha + 其他指标 */} + + + + {getAlertTypeLabel(alert.alert_type)} + + + + {alert.alpha != null && ( + + Alpha + = 0 ? colors.market.up : colors.market.down} + > + {alert.alpha >= 0 ? '+' : ''}{alert.alpha.toFixed(2)}% + + + )} + + {(alert.limit_up_ratio || 0) > 0.03 && ( + + + + {Math.round(alert.limit_up_ratio * 100)}% + + + )} + + {alert.is_v2 && alert.confirm_ratio != null && ( + + 确认 + = 0.8 ? '#52c41a' : alert.confirm_ratio >= 0.6 ? '#faad14' : '#ff4d4f'} + > + {Math.round(alert.confirm_ratio * 100)}% + + + )} + + + + {/* 展开内容 - 相关股票 */} + + + {loadingStocks ? ( + + + 加载相关股票... + + ) : stocks && stocks.length > 0 ? ( + + {/* 统计信息 */} + {(() => { + const validStocks = stocks.filter(s => s.change_pct != null && !isNaN(s.change_pct)); + if (validStocks.length === 0) return null; + const avgChange = validStocks.reduce((sum, s) => sum + s.change_pct, 0) / validStocks.length; + const upCount = validStocks.filter(s => s.change_pct > 0).length; + const downCount = validStocks.filter(s => s.change_pct < 0).length; + return ( + + + 均涨: + = 0 ? colors.market.up : colors.market.down}> + {avgChange >= 0 ? '+' : ''}{avgChange.toFixed(2)}% + + + + {upCount}涨 + / + {downCount}跌 + + + ); + })()} + + {/* 股票列表 */} + + + {stocks.slice(0, 15).map((stock, idx) => { + const changePct = stock.change_pct; + const hasChange = changePct != null && !isNaN(changePct); + const stockCode = stock.code || stock.stock_code; + const stockName = stock.name || stock.stock_name || '-'; + + return ( + handleStockClick(e, stockCode)} + _hover={{ bg: 'rgba(255, 255, 255, 0.05)' }} + transition="background 0.15s" + justify="space-between" + > + + + {stockName} + + + {stockCode} + + + 0 ? colors.market.up : + hasChange && changePct < 0 ? colors.market.down : + colors.text.muted + } + > + {hasChange ? `${changePct > 0 ? '+' : ''}${changePct.toFixed(2)}%` : '-'} + + + ); + })} + + + + {stocks.length > 15 && ( + + 共 {stocks.length} 只相关股票,显示前 15 只 + + )} + + ) : ( + + 暂无相关股票数据 + + )} + + + + ); +}; + +/** + * 异动详情抽屉主组件 + */ +const AlertDetailDrawer = ({ isOpen, onClose, alertData }) => { + const [expandedAlertId, setExpandedAlertId] = useState(null); + const [conceptStocks, setConceptStocks] = useState({}); + const [loadingConcepts, setLoadingConcepts] = useState({}); + + const { alerts = [], timeRange, alertCount } = alertData || {}; + + // 重置状态当抽屉关闭或数据变化 + useEffect(() => { + if (!isOpen) { + setExpandedAlertId(null); + } + }, [isOpen]); + + // 获取概念相关股票 + const fetchConceptStocks = useCallback(async (conceptId) => { + if (loadingConcepts[conceptId] || conceptStocks[conceptId]) return; + + setLoadingConcepts(prev => ({ ...prev, [conceptId]: true })); + + try { + const response = await axios.get(`/api/concept/${encodeURIComponent(conceptId)}/stocks`); + if (response.data?.success && response.data?.data?.stocks) { + setConceptStocks(prev => ({ + ...prev, + [conceptId]: response.data.data.stocks + })); + } else { + setConceptStocks(prev => ({ ...prev, [conceptId]: [] })); + } + } catch (error) { + console.error('获取概念股票失败:', error); + setConceptStocks(prev => ({ ...prev, [conceptId]: [] })); + } finally { + setLoadingConcepts(prev => ({ ...prev, [conceptId]: false })); + } + }, [loadingConcepts, conceptStocks]); + + // 处理展开/收起 + const handleToggle = useCallback((alert) => { + const alertId = `${alert.concept_id}-${alert.time}`; + if (expandedAlertId === alertId) { + setExpandedAlertId(null); + } else { + setExpandedAlertId(alertId); + if (alert.concept_id) { + fetchConceptStocks(alert.concept_id); + } + } + }, [expandedAlertId, fetchConceptStocks]); + + // 按分数排序 + const sortedAlerts = [...alerts].sort((a, b) => + (b.final_score || 0) - (a.final_score || 0) + ); + + return ( + + + + + + {/* 头部 */} + + + + + + + + 异动详情 + + + + {/* 时间段和数量信息 */} + + + + + {timeRange || '未知时段'} + + + + + + {alertCount || alerts.length} 个异动 + + + + + + + {/* 内容区 */} + + {alerts.length === 0 ? ( + + + 暂无异动数据 + + ) : ( + + {sortedAlerts.map((alert, idx) => { + const alertId = `${alert.concept_id}-${alert.time}`; + return ( + handleToggle(alert)} + stocks={conceptStocks[alert.concept_id]} + loadingStocks={loadingConcepts[alert.concept_id]} + /> + ); + })} + + )} + + + + ); +}; + +export default AlertDetailDrawer; diff --git a/src/views/StockOverview/components/HotspotOverview/components/ConceptAlertList.js b/src/views/StockOverview/components/HotspotOverview/components/ConceptAlertList.js index 930027e0..9842b75d 100644 --- a/src/views/StockOverview/components/HotspotOverview/components/ConceptAlertList.js +++ b/src/views/StockOverview/components/HotspotOverview/components/ConceptAlertList.js @@ -11,7 +11,6 @@ import { Badge, Icon, Tooltip, - useColorModeValue, Flex, Collapse, Spinner, @@ -84,10 +83,10 @@ const getAlertIcon = (alertType) => { }; /** - * 指标提示组件 - 带详细说明 + * 指标提示组件 - 带详细说明(深色主题) */ const MetricTooltip = ({ metricKey, children }) => { - const tooltipBg = useColorModeValue('gray.800', 'gray.700'); + const tooltipBg = 'rgba(15, 15, 25, 0.95)'; const config = METRIC_CONFIG[metricKey]; if (!config) return children; @@ -117,10 +116,10 @@ const MetricTooltip = ({ metricKey, children }) => { }; /** - * 迷你进度条组件 + * 迷你进度条组件(深色主题) */ const MiniProgressBar = ({ value, maxValue = 100, color, width = '40px', showGlow = false }) => { - const bgColor = useColorModeValue('gray.200', 'gray.700'); + const bgColor = 'rgba(255, 255, 255, 0.1)'; const percent = Math.min((value / maxValue) * 100, 100); return ( @@ -147,10 +146,10 @@ const MiniProgressBar = ({ value, maxValue = 100, color, width = '40px', showGlo }; /** - * Z-Score 双向进度条组件 + * Z-Score 双向进度条组件(深色主题) */ const ZScoreBar = ({ value, color }) => { - const bgColor = useColorModeValue('gray.200', 'gray.700'); + const bgColor = 'rgba(255, 255, 255, 0.1)'; const absValue = Math.abs(value || 0); const percent = Math.min(absValue / 4 * 50, 50); const isPositive = (value || 0) >= 0; @@ -176,7 +175,7 @@ const ZScoreBar = ({ value, color }) => { transform="translateX(-50%)" w="2px" h="6px" - bg={useColorModeValue('gray.400', 'gray.500')} + bg="rgba(255, 255, 255, 0.3)" borderRadius="full" /> @@ -209,20 +208,20 @@ const TriggeredRuleBadge = ({ rule }) => { }; /** - * 科技感异动卡片 + * 科技感异动卡片 - 统一使用深色主题 */ const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => { const navigate = useNavigate(); - // 颜色主题 - const cardBg = useColorModeValue('white', '#0d0d0d'); - const hoverBg = useColorModeValue('gray.50', '#1a1a1a'); - const borderColor = useColorModeValue('gray.200', '#2d2d2d'); - const expandedBg = useColorModeValue('gray.50', '#111111'); - const tableBg = useColorModeValue('gray.50', '#0a0a0a'); - const popoverBg = useColorModeValue('white', '#1a1a1a'); - const textColor = useColorModeValue('gray.800', 'white'); - const subTextColor = useColorModeValue('gray.500', 'gray.400'); + // 统一深色主题配色(与 glassTheme 保持一致) + const cardBg = 'rgba(255, 255, 255, 0.03)'; + const hoverBg = 'rgba(255, 255, 255, 0.06)'; + const borderColor = 'rgba(255, 255, 255, 0.08)'; + const expandedBg = 'rgba(0, 0, 0, 0.2)'; + const tableBg = 'rgba(255, 255, 255, 0.02)'; + const popoverBg = 'rgba(15, 15, 25, 0.95)'; + const textColor = 'rgba(255, 255, 255, 0.95)'; + const subTextColor = 'rgba(255, 255, 255, 0.6)'; const alertConfig = ALERT_TYPE_CONFIG[alert.alert_type] || ALERT_TYPE_CONFIG.surge; const isUp = alert.alert_type !== 'surge_down'; @@ -681,8 +680,9 @@ const ConceptAlertList = ({ const [conceptStocks, setConceptStocks] = useState({}); const [loadingConcepts, setLoadingConcepts] = useState({}); - const subTextColor = useColorModeValue('gray.500', 'gray.400'); - const emptyBg = useColorModeValue('gray.50', '#111111'); + // 统一深色主题配色 + const subTextColor = 'rgba(255, 255, 255, 0.6)'; + const emptyBg = 'rgba(255, 255, 255, 0.02)'; // 获取概念相关股票 - 使用 ref 避免依赖循环 const fetchConceptStocks = useCallback(async (conceptId) => { diff --git a/src/views/StockOverview/components/HotspotOverview/components/IndexMinuteChart.js b/src/views/StockOverview/components/HotspotOverview/components/IndexMinuteChart.js index c78e0e86..3e8e410a 100644 --- a/src/views/StockOverview/components/HotspotOverview/components/IndexMinuteChart.js +++ b/src/views/StockOverview/components/HotspotOverview/components/IndexMinuteChart.js @@ -1,26 +1,28 @@ /** * 指数分时图组件 - * 展示大盘分时走势,支持概念异动标注 + * 展示大盘分时走势,支持概念异动标注(按10分钟分组) */ import React, { useRef, useEffect, useCallback, useMemo } from 'react'; -import { Box, useColorModeValue } from '@chakra-ui/react'; +import { Box } from '@chakra-ui/react'; import * as echarts from 'echarts'; -import { getAlertMarkPoints } from '../utils/chartHelpers'; +import { getAlertMarkPointsGrouped } from '../utils/chartHelpers'; +import { colors, glassEffect } from '../../../theme/glassTheme'; /** * @param {Object} props * @param {Object} props.indexData - 指数数据 { timeline, prev_close, name, ... } * @param {Array} props.alerts - 异动数据数组 - * @param {Function} props.onAlertClick - 点击异动标注的回调 + * @param {Function} props.onAlertClick - 点击异动标注的回调(传递该时间段所有异动) * @param {string} props.height - 图表高度 */ const IndexMinuteChart = ({ indexData, alerts = [], onAlertClick, height = '350px' }) => { const chartRef = useRef(null); const chartInstance = useRef(null); - const textColor = useColorModeValue('gray.800', 'white'); - const subTextColor = useColorModeValue('gray.600', 'gray.400'); - const gridLineColor = useColorModeValue('#eee', '#333'); + // 使用 glassTheme 的深色主题颜色 + const textColor = colors.text.primary; + const subTextColor = colors.text.secondary; + const gridLineColor = 'rgba(255, 255, 255, 0.08)'; // 计算图表配置 const chartOption = useMemo(() => { @@ -44,8 +46,8 @@ const IndexMinuteChart = ({ indexData, alerts = [], onAlertClick, height = '350p const yAxisMin = priceMin - priceRange * 0.1; const yAxisMax = priceMax + priceRange * 0.25; // 上方留更多空间给标注 - // 准备异动标注 - const markPoints = getAlertMarkPoints(alerts, times, prices, priceMax); + // 准备异动标注 - 按10分钟分组 + const markPoints = getAlertMarkPointsGrouped(alerts, times, prices, priceMax, 10); // 渐变色 - 根据涨跌 const latestChangePct = changePcts[changePcts.length - 1] || 0; @@ -67,8 +69,17 @@ const IndexMinuteChart = ({ indexData, alerts = [], onAlertClick, height = '350p trigger: 'axis', axisPointer: { type: 'cross', - crossStyle: { color: '#999' }, + crossStyle: { color: 'rgba(255, 255, 255, 0.3)' }, + lineStyle: { color: 'rgba(139, 92, 246, 0.5)' }, }, + backgroundColor: 'rgba(15, 15, 25, 0.95)', + borderColor: 'rgba(139, 92, 246, 0.3)', + borderWidth: 1, + padding: 0, + textStyle: { + color: colors.text.primary, + }, + extraCssText: 'backdrop-filter: blur(12px); border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.4);', formatter: (params) => { if (!params || params.length === 0) return ''; @@ -79,19 +90,19 @@ const IndexMinuteChart = ({ indexData, alerts = [], onAlertClick, height = '350p const volume = volumes[dataIndex]; let html = ` -
-
${time}
-
指数: ${price?.toFixed(2)}
-
涨跌: ${changePct >= 0 ? '+' : ''}${changePct?.toFixed(2)}%
-
成交量: ${(volume / 10000).toFixed(0)}万手
+
+
${time}
+
指数: ${price?.toFixed(2)}
+
涨跌: ${changePct >= 0 ? '+' : ''}${changePct?.toFixed(2)}%
+
成交量: ${(volume / 10000).toFixed(0)}万手
`; // 检查是否有异动 const alertsAtTime = alerts.filter((a) => a.time === time); if (alertsAtTime.length > 0) { - html += '
'; - html += `
📍 概念异动 (${alertsAtTime.length})
`; + html += '
'; + html += `
📍 概念异动 (${alertsAtTime.length})
`; alertsAtTime.slice(0, 5).forEach((alert) => { const typeLabel = { surge: '异动', @@ -104,13 +115,13 @@ const IndexMinuteChart = ({ indexData, alerts = [], onAlertClick, height = '350p rank_jump: '排名跃升', volume_spike: '放量', }[alert.alert_type] || alert.alert_type; - const typeColor = alert.alert_type === 'surge_down' ? '#52c41a' : '#ff4d4f'; + const typeColor = alert.alert_type === 'surge_down' ? '#4ade80' : '#f87171'; const alpha = alert.alpha ? ` α${alert.alpha > 0 ? '+' : ''}${alert.alpha.toFixed(1)}%` : ''; const score = alert.final_score ? ` [${Math.round(alert.final_score)}分]` : ''; - html += `
• ${alert.concept_name} (${typeLabel}${alpha}${score})
`; + html += `
• ${alert.concept_name} (${typeLabel}${alpha}${score})
`; }); if (alertsAtTime.length > 5) { - html += `
还有 ${alertsAtTime.length - 5} 个异动...
`; + html += `
还有 ${alertsAtTime.length - 5} 个异动...
`; } html += '
'; } @@ -223,19 +234,18 @@ const IndexMinuteChart = ({ indexData, alerts = [], onAlertClick, height = '350p chartInstance.current.setOption(chartOption, true); - // 点击事件 - 支持多个异动 + // 点击事件 - 传递该时间段所有异动数据 if (onAlertClick) { chartInstance.current.off('click'); chartInstance.current.on('click', 'series.line.markPoint', (params) => { - if (params.data && params.data.alertData) { - const alertData = params.data.alertData; - // 如果是数组(多个异动),传递第一个(最高分) - // 调用方可以从 alertData 中获取所有异动 - if (Array.isArray(alertData)) { - onAlertClick(alertData[0]); - } else { - onAlertClick(alertData); - } + if (params.data) { + // 传递完整的标记点数据,包含 alertData(所有异动)、timeRange、alertCount 等 + onAlertClick({ + alerts: params.data.alertData || [], + timeRange: params.data.timeRange, + alertCount: params.data.alertCount || 1, + time: params.data.time, + }); } }); } diff --git a/src/views/StockOverview/components/HotspotOverview/components/index.js b/src/views/StockOverview/components/HotspotOverview/components/index.js index 2401b9bd..91c8c1a5 100644 --- a/src/views/StockOverview/components/HotspotOverview/components/index.js +++ b/src/views/StockOverview/components/HotspotOverview/components/index.js @@ -1,3 +1,4 @@ export { default as IndexMinuteChart } from './IndexMinuteChart'; export { default as ConceptAlertList } from './ConceptAlertList'; export { default as AlertSummary } from './AlertSummary'; +export { default as AlertDetailDrawer } from './AlertDetailDrawer'; diff --git a/src/views/StockOverview/components/HotspotOverview/index.js b/src/views/StockOverview/components/HotspotOverview/index.js index cf20bc6f..fb641b01 100644 --- a/src/views/StockOverview/components/HotspotOverview/index.js +++ b/src/views/StockOverview/components/HotspotOverview/index.js @@ -23,6 +23,7 @@ import { IconButton, Collapse, SimpleGrid, + useDisclosure, } from '@chakra-ui/react'; import { keyframes, css } from '@emotion/react'; import { @@ -40,7 +41,7 @@ import { } from 'lucide-react'; import { useHotspotData } from './hooks'; -import { IndexMinuteChart, ConceptAlertList, AlertSummary } from './components'; +import { IndexMinuteChart, ConceptAlertList, AlertSummary, AlertDetailDrawer } from './components'; import { ALERT_TYPE_CONFIG, getAlertTypeLabel } from './utils/chartHelpers'; import { glassEffect, @@ -200,6 +201,10 @@ const HotspotOverview = ({ selectedDate }) => { const [selectedAlert, setSelectedAlert] = useState(null); const [showDetailList, setShowDetailList] = useState(false); const [autoExpandAlertKey, setAutoExpandAlertKey] = useState(null); + const [drawerAlertData, setDrawerAlertData] = useState(null); + + // 右边栏抽屉控制 + const { isOpen: isDrawerOpen, onOpen: onDrawerOpen, onClose: onDrawerClose } = useDisclosure(); // 获取数据 const { loading, error, data } = useHotspotData(selectedDate); @@ -212,7 +217,14 @@ const HotspotOverview = ({ selectedDate }) => { const sectionBg = glassEffect.light.bg; const scrollbarColor = 'rgba(139, 92, 246, 0.3)'; - // 点击异动标注 - 自动展开详细列表并选中 + // 点击分时图上的异动标注 - 打开右边栏抽屉显示详情 + const handleChartAlertClick = useCallback((alertGroupData) => { + // alertGroupData 包含 { alerts, timeRange, alertCount, time } + setDrawerAlertData(alertGroupData); + onDrawerOpen(); + }, [onDrawerOpen]); + + // 点击底部异动卡片 - 展开详细列表并选中 const handleAlertClick = useCallback((alert) => { setSelectedAlert(alert); // 自动展开详细列表并设置需要展开的项 @@ -637,7 +649,7 @@ const HotspotOverview = ({ selectedDate }) => { @@ -790,6 +802,13 @@ const HotspotOverview = ({ selectedDate }) => { )} + + {/* 异动详情右边栏抽屉 */} + ); }; diff --git a/src/views/StockOverview/components/HotspotOverview/utils/chartHelpers.js b/src/views/StockOverview/components/HotspotOverview/utils/chartHelpers.js index b9539054..ff61dd1b 100644 --- a/src/views/StockOverview/components/HotspotOverview/utils/chartHelpers.js +++ b/src/views/StockOverview/components/HotspotOverview/utils/chartHelpers.js @@ -281,7 +281,136 @@ export const getAlertTypeColor = (alertType) => { }; /** - * 生成图表标注点数据 - 支持同一时间多个异动折叠显示 + * 将时间字符串转换为分钟数 + * @param {string} timeStr - 时间字符串,如 "09:30" + * @returns {number} 分钟数 + */ +const timeToMinutes = (timeStr) => { + if (!timeStr) return 0; + const [hours, minutes] = timeStr.split(':').map(Number); + return hours * 60 + minutes; +}; + +/** + * 获取时间所属的分组区间 + * @param {string} timeStr - 时间字符串 + * @param {number} intervalMinutes - 分组间隔(分钟) + * @returns {string} 时间区间,如 "09:30-09:40" + */ +const getTimeGroup = (timeStr, intervalMinutes = 10) => { + const minutes = timeToMinutes(timeStr); + const groupStart = Math.floor(minutes / intervalMinutes) * intervalMinutes; + const groupEnd = groupStart + intervalMinutes; + + const startHour = Math.floor(groupStart / 60); + const startMin = groupStart % 60; + const endHour = Math.floor(groupEnd / 60); + const endMin = groupEnd % 60; + + const formatTime = (h, m) => `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`; + return `${formatTime(startHour, startMin)}-${formatTime(endHour, endMin)}`; +}; + +/** + * 生成图表标注点数据 - 按时间间隔分组 + * @param {Array} alerts - 异动数据数组 + * @param {Array} times - 时间数组 + * @param {Array} prices - 价格数组 + * @param {number} priceMax - 最高价格 + * @param {number} intervalMinutes - 分组间隔(分钟),默认10分钟 + * @returns {Array} ECharts markPoint data + */ +export const getAlertMarkPointsGrouped = (alerts, times, prices, priceMax, intervalMinutes = 10) => { + if (!alerts || alerts.length === 0) return []; + + // 1. 按时间间隔分组 + const alertsByGroup = {}; + alerts.forEach(alert => { + const group = getTimeGroup(alert.time, intervalMinutes); + if (!alertsByGroup[group]) { + alertsByGroup[group] = []; + } + alertsByGroup[group].push(alert); + }); + + // 2. 对每个分组内的异动按分数排序 + Object.keys(alertsByGroup).forEach(group => { + alertsByGroup[group].sort((a, b) => + (b.final_score || b.importance_score || 0) - (a.final_score || a.importance_score || 0) + ); + }); + + // 3. 生成标记点 + return Object.entries(alertsByGroup).map(([timeRange, groupAlerts]) => { + // 找到该分组中间时间点对应的坐标 + const midTime = groupAlerts[Math.floor(groupAlerts.length / 2)]?.time; + const timeIndex = times.indexOf(midTime); + const price = timeIndex >= 0 ? prices[timeIndex] : priceMax; + + const alertCount = groupAlerts.length; + const topAlert = groupAlerts[0]; + const hasMultiple = alertCount > 1; + + // 使用最高分异动的样式 + const { color, gradient } = getAlertStyle( + topAlert.alert_type, + topAlert.final_score / 100 || topAlert.importance_score || 0.5 + ); + + // 生成显示标签 + const [startTime] = timeRange.split('-'); + const label = hasMultiple ? `${startTime} (${alertCount})` : topAlert.concept_name?.substring(0, 4) || startTime; + + const isDown = topAlert.alert_type === 'surge_down'; + const symbolSize = hasMultiple ? 45 + Math.min(alertCount * 2, 15) : 35; + + return { + name: timeRange, + coord: [midTime || times[0], price], + value: label, + symbol: 'pin', + symbolSize, + itemStyle: { + color: { + type: 'radial', + x: 0.5, y: 0.5, r: 0.8, + colorStops: [ + { offset: 0, color: gradient[0] }, + { offset: 0.7, color: gradient[1] }, + { offset: 1, color: `${color}88` }, + ], + }, + borderColor: hasMultiple ? '#ffffff' : 'rgba(255,255,255,0.8)', + borderWidth: hasMultiple ? 3 : 2, + shadowBlur: hasMultiple ? 20 : 10, + shadowColor: `${color}${hasMultiple ? 'aa' : '66'}`, + }, + label: { + show: true, + position: isDown ? 'bottom' : 'top', + formatter: label, + fontSize: hasMultiple ? 11 : 10, + fontWeight: hasMultiple ? 700 : 500, + color: 'rgba(255, 255, 255, 0.95)', + backgroundColor: 'rgba(15, 15, 25, 0.9)', + padding: hasMultiple ? [5, 10] : [3, 6], + borderRadius: 6, + borderColor: `${color}80`, + borderWidth: 1, + shadowBlur: 8, + shadowColor: `${color}40`, + }, + // 存储该时间段所有异动数据 + alertData: groupAlerts, + alertCount, + timeRange, + time: midTime, + }; + }); +}; + +/** + * 生成图表标注点数据 - 支持同一时间多个异动折叠显示(原有函数保留) * @param {Array} alerts - 异动数据数组 * @param {Array} times - 时间数组 * @param {Array} prices - 价格数组 From 429c2a45316b71f39ead4e0ff7bbd3e9fa7fd69c Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Thu, 11 Dec 2025 14:10:59 +0800 Subject: [PATCH 5/5] update pay ui --- src/mocks/handlers/concept.js | 8 +- src/services/eventService.js | 7 +- .../components/InvestmentCalendarChakra.js | 5 +- .../components/AlertDetailDrawer.js | 46 +++++-- .../components/HotspotOverview/index.js | 113 +++++------------- 5 files changed, 81 insertions(+), 98 deletions(-) diff --git a/src/mocks/handlers/concept.js b/src/mocks/handlers/concept.js index 8a516bb4..1aa7e524 100644 --- a/src/mocks/handlers/concept.js +++ b/src/mocks/handlers/concept.js @@ -307,7 +307,13 @@ export const conceptHandlers = [ const count = Math.min(limit, stockPool.length); for (let i = 0; i < count; i++) { const stock = stockPool[i]; - const suffix = stock.code.startsWith('6') ? '.SH' : '.SZ'; + // 根据股票代码判断交易所后缀 + let suffix = '.SZ'; + if (stock.code.startsWith('6')) { + suffix = '.SH'; + } else if (stock.code.startsWith('8') || stock.code.startsWith('9') || stock.code.startsWith('4')) { + suffix = '.BJ'; + } stocks.push({ stock_code: `${stock.code}${suffix}`, code: `${stock.code}${suffix}`, diff --git a/src/services/eventService.js b/src/services/eventService.js index 5f8d386c..a43c4919 100755 --- a/src/services/eventService.js +++ b/src/services/eventService.js @@ -17,9 +17,9 @@ const formatStockCode = (code) => { // 根据股票代码规则添加后缀 // 6开头 -> 上海 .SH - // 0、3开头 -> 深圳 .SZ // 688开头 -> 科创板(上海).SH - // 8开头(北交所)-> .BJ(暂不处理,大部分场景不需要) + // 0、3开头 -> 深圳 .SZ + // 8、9、4开头 -> 北交所 .BJ const firstChar = code.charAt(0); const prefix = code.substring(0, 3); @@ -27,6 +27,9 @@ const formatStockCode = (code) => { return `${code}.SH`; } else if (firstChar === '0' || firstChar === '3') { return `${code}.SZ`; + } else if (firstChar === '8' || firstChar === '9' || firstChar === '4') { + // 北交所股票 + return `${code}.BJ`; } // 默认返回原代码(可能是指数或其他) diff --git a/src/views/Dashboard/components/InvestmentCalendarChakra.js b/src/views/Dashboard/components/InvestmentCalendarChakra.js index 5c5a10fd..4f1a72e9 100644 --- a/src/views/Dashboard/components/InvestmentCalendarChakra.js +++ b/src/views/Dashboard/components/InvestmentCalendarChakra.js @@ -285,8 +285,9 @@ export default function InvestmentCalendarChakra() { stockCode = `${stockCode}.SH`; } else if (stockCode.startsWith('0') || stockCode.startsWith('3')) { stockCode = `${stockCode}.SZ`; - } else if (stockCode.startsWith('688')) { - stockCode = `${stockCode}.SH`; + } else if (stockCode.startsWith('8') || stockCode.startsWith('9') || stockCode.startsWith('4')) { + // 北交所股票 + stockCode = `${stockCode}.BJ`; } } diff --git a/src/views/StockOverview/components/HotspotOverview/components/AlertDetailDrawer.js b/src/views/StockOverview/components/HotspotOverview/components/AlertDetailDrawer.js index 7244b4c5..ea3b213e 100644 --- a/src/views/StockOverview/components/HotspotOverview/components/AlertDetailDrawer.js +++ b/src/views/StockOverview/components/HotspotOverview/components/AlertDetailDrawer.js @@ -18,9 +18,13 @@ import { Icon, Collapse, Spinner, - Divider, Tooltip, Flex, + Popover, + PopoverTrigger, + PopoverContent, + PopoverBody, + Portal, } from '@chakra-ui/react'; import { keyframes, css } from '@emotion/react'; import { @@ -310,14 +314,38 @@ const AlertDetailCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) justify="space-between" > - - {stockName} - + {/* 股票名称 - 带迷你分时图悬停 */} + + + + {stockName} + + + + e.stopPropagation()} + > + + + {stockName} 分时走势 + + + + + + + + {stockCode} diff --git a/src/views/StockOverview/components/HotspotOverview/index.js b/src/views/StockOverview/components/HotspotOverview/index.js index fb641b01..f93b4535 100644 --- a/src/views/StockOverview/components/HotspotOverview/index.js +++ b/src/views/StockOverview/components/HotspotOverview/index.js @@ -20,8 +20,6 @@ import { Flex, Spacer, Tooltip, - IconButton, - Collapse, SimpleGrid, useDisclosure, } from '@chakra-ui/react'; @@ -30,8 +28,6 @@ import { Flame, List, LineChart, - ChevronDown, - ChevronUp, Info, Zap, AlertCircle, @@ -41,7 +37,7 @@ import { } from 'lucide-react'; import { useHotspotData } from './hooks'; -import { IndexMinuteChart, ConceptAlertList, AlertSummary, AlertDetailDrawer } from './components'; +import { IndexMinuteChart, AlertDetailDrawer } from './components'; import { ALERT_TYPE_CONFIG, getAlertTypeLabel } from './utils/chartHelpers'; import { glassEffect, @@ -199,8 +195,6 @@ const CompactAlertCard = ({ alert, onClick, isSelected }) => { */ const HotspotOverview = ({ selectedDate }) => { const [selectedAlert, setSelectedAlert] = useState(null); - const [showDetailList, setShowDetailList] = useState(false); - const [autoExpandAlertKey, setAutoExpandAlertKey] = useState(null); const [drawerAlertData, setDrawerAlertData] = useState(null); // 右边栏抽屉控制 @@ -224,14 +218,18 @@ const HotspotOverview = ({ selectedDate }) => { onDrawerOpen(); }, [onDrawerOpen]); - // 点击底部异动卡片 - 展开详细列表并选中 - const handleAlertClick = useCallback((alert) => { + // 点击底部异动卡片 - 打开右边栏抽屉显示单个异动详情 + const handleCardAlertClick = useCallback((alert) => { setSelectedAlert(alert); - // 自动展开详细列表并设置需要展开的项 - setShowDetailList(true); - const alertKey = `${alert.concept_id}-${alert.time}`; - setAutoExpandAlertKey(alertKey); - }, []); + // 构造单个异动的数据格式 + setDrawerAlertData({ + alerts: [alert], + timeRange: alert.time, + alertCount: 1, + time: alert.time, + }); + onDrawerOpen(); + }, [onDrawerOpen]); // 渲染加载状态 - Glassmorphism 风格 if (loading) { @@ -657,40 +655,23 @@ const HotspotOverview = ({ selectedDate }) => { {/* 异动列表 - Glassmorphism 横向滚动 */} {alerts.length > 0 && ( - - - - - - 异动记录 - (点击卡片查看个股详情) - - - } - size="sm" - variant="ghost" - borderRadius="12px" - color={colors.text.secondary} - _hover={{ - bg: 'rgba(255,255,255,0.05)', - color: textColor, - }} - onClick={() => setShowDetailList(!showDetailList)} - aria-label="切换详细列表" + + + - - + + 异动记录 + (点击卡片查看详情) + {/* 横向滚动卡片 */} { ))} - - {/* 详细列表(可展开) - Glassmorphism */} - - - {/* 背景光晕 */} - - setAutoExpandAlertKey(null)} - /> - - )}