From da44dcd52230f91141f349e8dd37db3207ab60f9 Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Wed, 10 Dec 2025 11:19:02 +0800 Subject: [PATCH] update pay ui --- ...TimelineChart.js => MiniTimelineChart.tsx} | 63 +- .../{OrderBookPanel.js => OrderBookPanel.tsx} | 62 +- .../{QuoteTile.js => QuoteTile.tsx} | 82 ++- .../components/{index.js => index.ts} | 4 + .../components/FlexScreen/hooks/constants.ts | 17 + .../components/FlexScreen/hooks/index.js | 1 - .../components/FlexScreen/hooks/index.ts | 7 + .../FlexScreen/hooks/useRealtimeQuote.js | 692 ------------------ .../FlexScreen/hooks/useRealtimeQuote.ts | 594 +++++++++++++++ .../components/FlexScreen/hooks/utils.ts | 83 +++ .../FlexScreen/{index.js => index.tsx} | 122 +-- .../components/FlexScreen/types.ts | 322 ++++++++ 12 files changed, 1229 insertions(+), 820 deletions(-) rename src/views/StockOverview/components/FlexScreen/components/{MiniTimelineChart.js => MiniTimelineChart.tsx} (80%) rename src/views/StockOverview/components/FlexScreen/components/{OrderBookPanel.js => OrderBookPanel.tsx} (84%) rename src/views/StockOverview/components/FlexScreen/components/{QuoteTile.js => QuoteTile.tsx} (72%) rename src/views/StockOverview/components/FlexScreen/components/{index.js => index.ts} (85%) create mode 100644 src/views/StockOverview/components/FlexScreen/hooks/constants.ts delete mode 100644 src/views/StockOverview/components/FlexScreen/hooks/index.js create mode 100644 src/views/StockOverview/components/FlexScreen/hooks/index.ts delete mode 100644 src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.js create mode 100644 src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts create mode 100644 src/views/StockOverview/components/FlexScreen/hooks/utils.ts rename src/views/StockOverview/components/FlexScreen/{index.js => index.tsx} (83%) create mode 100644 src/views/StockOverview/components/FlexScreen/types.ts diff --git a/src/views/StockOverview/components/FlexScreen/components/MiniTimelineChart.js b/src/views/StockOverview/components/FlexScreen/components/MiniTimelineChart.tsx similarity index 80% rename from src/views/StockOverview/components/FlexScreen/components/MiniTimelineChart.js rename to src/views/StockOverview/components/FlexScreen/components/MiniTimelineChart.tsx index 46c15e58..0730b24f 100644 --- a/src/views/StockOverview/components/FlexScreen/components/MiniTimelineChart.js +++ b/src/views/StockOverview/components/FlexScreen/components/MiniTimelineChart.tsx @@ -5,16 +5,19 @@ import React, { useEffect, useRef, useState, useMemo } from 'react'; import { Box, Spinner, Center, Text } from '@chakra-ui/react'; import * as echarts from 'echarts'; +import type { ECharts, EChartsOption } from 'echarts'; + +import type { MiniTimelineChartProps, TimelineDataPoint } from '../types'; /** * 生成交易时间刻度(用于 X 轴) * A股交易时间:9:30-11:30, 13:00-15:00 */ -const generateTimeTicks = () => { - const ticks = []; +const generateTimeTicks = (): string[] => { + const ticks: string[] = []; // 上午 for (let h = 9; h <= 11; h++) { - for (let m = (h === 9 ? 30 : 0); m < 60; m++) { + for (let m = h === 9 ? 30 : 0; m < 60; m++) { if (h === 11 && m > 30) break; ticks.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`); } @@ -31,33 +34,42 @@ const generateTimeTicks = () => { const TIME_TICKS = generateTimeTicks(); +/** API 返回的分钟数据结构 */ +interface MinuteKLineItem { + time?: string; + timestamp?: string; + close?: number; + price?: number; +} + +/** API 响应结构 */ +interface KLineApiResponse { + success?: boolean; + data?: MinuteKLineItem[]; + error?: string; +} + /** * MiniTimelineChart 组件 - * @param {Object} props - * @param {string} props.code - 证券代码 - * @param {boolean} props.isIndex - 是否为指数 - * @param {number} props.prevClose - 昨收价 - * @param {number} props.currentPrice - 当前价(实时) - * @param {number} props.height - 图表高度 */ -const MiniTimelineChart = ({ +const MiniTimelineChart: React.FC = ({ code, isIndex = false, prevClose, currentPrice, height = 120, }) => { - const chartRef = useRef(null); - const chartInstance = useRef(null); - const [timelineData, setTimelineData] = useState([]); + const chartRef = useRef(null); + const chartInstance = useRef(null); + const [timelineData, setTimelineData] = useState([]); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const [error, setError] = useState(null); // 获取分钟数据 useEffect(() => { if (!code) return; - const fetchData = async () => { + const fetchData = async (): Promise => { setLoading(true); setError(null); @@ -67,13 +79,13 @@ const MiniTimelineChart = ({ : `/api/stock/${code}/kline?type=minute`; const response = await fetch(apiPath); - const result = await response.json(); + const result: KLineApiResponse = await response.json(); if (result.success !== false && result.data) { // 格式化数据 - const formatted = result.data.map(item => ({ - time: item.time || item.timestamp, - price: item.close || item.price, + const formatted: TimelineDataPoint[] = result.data.map(item => ({ + time: item.time || item.timestamp || '', + price: item.close || item.price || 0, })); setTimelineData(formatted); } else { @@ -93,10 +105,11 @@ const MiniTimelineChart = ({ const hours = now.getHours(); const minutes = now.getMinutes(); const currentMinutes = hours * 60 + minutes; - const isTrading = (currentMinutes >= 570 && currentMinutes <= 690) || - (currentMinutes >= 780 && currentMinutes <= 900); + const isTrading = + (currentMinutes >= 570 && currentMinutes <= 690) || + (currentMinutes >= 780 && currentMinutes <= 900); - let intervalId; + let intervalId: NodeJS.Timeout | undefined; if (isTrading) { intervalId = setInterval(fetchData, 60000); // 1分钟刷新 } @@ -107,7 +120,7 @@ const MiniTimelineChart = ({ }, [code, isIndex]); // 合并实时价格到数据中 - const chartData = useMemo(() => { + const chartData = useMemo((): TimelineDataPoint[] => { if (!timelineData.length) return []; const data = [...timelineData]; @@ -154,7 +167,7 @@ const MiniTimelineChart = ({ const lastPrice = values[values.length - 1] || baseLine; const isUp = lastPrice >= baseLine; - const option = { + const option: EChartsOption = { grid: { top: 5, right: 5, @@ -236,7 +249,7 @@ const MiniTimelineChart = ({ // 窗口 resize 处理 useEffect(() => { - const handleResize = () => { + const handleResize = (): void => { chartInstance.current?.resize(); }; window.addEventListener('resize', handleResize); diff --git a/src/views/StockOverview/components/FlexScreen/components/OrderBookPanel.js b/src/views/StockOverview/components/FlexScreen/components/OrderBookPanel.tsx similarity index 84% rename from src/views/StockOverview/components/FlexScreen/components/OrderBookPanel.js rename to src/views/StockOverview/components/FlexScreen/components/OrderBookPanel.tsx index a3cf92fc..4da68ca0 100644 --- a/src/views/StockOverview/components/FlexScreen/components/OrderBookPanel.js +++ b/src/views/StockOverview/components/FlexScreen/components/OrderBookPanel.tsx @@ -18,12 +18,18 @@ import { Badge, } from '@chakra-ui/react'; +import type { OrderBookPanelProps } from '../types'; + +/** 格式化价格返回值 */ +interface FormattedPrice { + text: string; + color: string; +} + /** * 格式化成交量 - * @param {number} volume - 成交量(股) - * @returns {string} 格式化后的字符串 */ -const formatVolume = (volume) => { +const formatVolume = (volume: number): string => { if (!volume || volume === 0) return '-'; if (volume >= 10000) { return `${(volume / 10000).toFixed(0)}万`; @@ -36,11 +42,8 @@ const formatVolume = (volume) => { /** * 格式化价格 - * @param {number} price - 价格 - * @param {number} prevClose - 昨收价 - * @returns {Object} { text, color } */ -const formatPrice = (price, prevClose) => { +const formatPrice = (price: number, prevClose?: number): FormattedPrice => { if (!price || price === 0) { return { text: '-', color: 'gray.400' }; } @@ -60,10 +63,29 @@ const formatPrice = (price, prevClose) => { return { text, color: 'gray.600' }; }; +/** OrderRow 组件 Props */ +interface OrderRowProps { + label: string; + price: number; + volume: number; + prevClose?: number; + isBid: boolean; + maxVolume: number; + isLimitPrice: boolean; +} + /** * 单行盘口 */ -const OrderRow = ({ label, price, volume, prevClose, isBid, maxVolume, isLimitPrice }) => { +const OrderRow: React.FC = ({ + label, + price, + volume, + prevClose, + isBid, + maxVolume, + isLimitPrice, +}) => { const bgColor = useColorModeValue( isBid ? 'red.50' : 'green.50', isBid ? 'rgba(239, 68, 68, 0.1)' : 'rgba(34, 197, 94, 0.1)' @@ -129,17 +151,8 @@ const OrderRow = ({ label, price, volume, prevClose, isBid, maxVolume, isLimitPr /** * OrderBookPanel 组件 - * @param {Object} props - * @param {number[]} props.bidPrices - 买档价格(最多10档) - * @param {number[]} props.bidVolumes - 买档量 - * @param {number[]} props.askPrices - 卖档价格(最多10档) - * @param {number[]} props.askVolumes - 卖档量 - * @param {number} props.prevClose - 昨收价 - * @param {number} props.upperLimit - 涨停价 - * @param {number} props.lowerLimit - 跌停价 - * @param {number} props.defaultLevels - 默认显示档数(5 或 10) */ -const OrderBookPanel = ({ +const OrderBookPanel: React.FC = ({ bidPrices = [], bidVolumes = [], askPrices = [], @@ -151,6 +164,7 @@ const OrderBookPanel = ({ }) => { const borderColor = useColorModeValue('gray.200', 'gray.700'); const buttonBg = useColorModeValue('gray.100', 'gray.700'); + const bgColor = useColorModeValue('white', '#1a1a1a'); // 可切换显示的档位数 const maxAvailableLevels = Math.max(bidPrices.length, askPrices.length, 1); @@ -163,11 +177,13 @@ const OrderBookPanel = ({ const maxVolume = allVolumes.length > 0 ? Math.max(...allVolumes) : 0; // 判断是否为涨跌停价 - const isUpperLimit = (price) => upperLimit && Math.abs(price - upperLimit) < 0.001; - const isLowerLimit = (price) => lowerLimit && Math.abs(price - lowerLimit) < 0.001; + const isUpperLimit = (price: number): boolean => + !!upperLimit && Math.abs(price - upperLimit) < 0.001; + const isLowerLimit = (price: number): boolean => + !!lowerLimit && Math.abs(price - lowerLimit) < 0.001; // 卖盘(从卖N到卖1,即价格从高到低) - const askRows = []; + const askRows: React.ReactNode[] = []; for (let i = showLevels - 1; i >= 0; i--) { askRows.push( 昨收 {prevClose.toFixed(2)} diff --git a/src/views/StockOverview/components/FlexScreen/components/QuoteTile.js b/src/views/StockOverview/components/FlexScreen/components/QuoteTile.tsx similarity index 72% rename from src/views/StockOverview/components/FlexScreen/components/QuoteTile.js rename to src/views/StockOverview/components/FlexScreen/components/QuoteTile.tsx index f5b5c389..d5a51be5 100644 --- a/src/views/StockOverview/components/FlexScreen/components/QuoteTile.js +++ b/src/views/StockOverview/components/FlexScreen/components/QuoteTile.tsx @@ -13,18 +13,18 @@ import { useColorModeValue, Collapse, Badge, - Flex, - Spacer, } from '@chakra-ui/react'; -import { CloseIcon, ChevronDownIcon, ChevronUpIcon, ExternalLinkIcon } from '@chakra-ui/icons'; +import { CloseIcon, ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons'; import { useNavigate } from 'react-router-dom'; + import MiniTimelineChart from './MiniTimelineChart'; import OrderBookPanel from './OrderBookPanel'; +import type { QuoteTileProps, QuoteData } from '../types'; /** * 格式化价格显示 */ -const formatPrice = (price) => { +const formatPrice = (price?: number): string => { if (!price || isNaN(price)) return '-'; return price.toFixed(2); }; @@ -32,7 +32,7 @@ const formatPrice = (price) => { /** * 格式化涨跌幅 */ -const formatChangePct = (pct) => { +const formatChangePct = (pct?: number): string => { if (!pct || isNaN(pct)) return '0.00%'; const sign = pct > 0 ? '+' : ''; return `${sign}${pct.toFixed(2)}%`; @@ -41,7 +41,7 @@ const formatChangePct = (pct) => { /** * 格式化涨跌额 */ -const formatChange = (change) => { +const formatChange = (change?: number): string => { if (!change || isNaN(change)) return '-'; const sign = change > 0 ? '+' : ''; return `${sign}${change.toFixed(2)}`; @@ -50,7 +50,7 @@ const formatChange = (change) => { /** * 格式化成交额 */ -const formatAmount = (amount) => { +const formatAmount = (amount?: number): string => { if (!amount || isNaN(amount)) return '-'; if (amount >= 100000000) { return `${(amount / 100000000).toFixed(2)}亿`; @@ -63,14 +63,8 @@ const formatAmount = (amount) => { /** * QuoteTile 组件 - * @param {Object} props - * @param {string} props.code - 证券代码 - * @param {string} props.name - 证券名称 - * @param {Object} props.quote - 实时行情数据 - * @param {boolean} props.isIndex - 是否为指数 - * @param {Function} props.onRemove - 移除回调 */ -const QuoteTile = ({ +const QuoteTile: React.FC = ({ code, name, quote = {}, @@ -80,6 +74,9 @@ const QuoteTile = ({ const navigate = useNavigate(); const [expanded, setExpanded] = useState(true); + // 类型断言,确保类型安全 + const quoteData = quote as Partial; + // 颜色主题 const cardBg = useColorModeValue('white', '#1a1a1a'); const borderColor = useColorModeValue('gray.200', '#333'); @@ -88,24 +85,36 @@ const QuoteTile = ({ const subTextColor = useColorModeValue('gray.500', 'gray.400'); // 涨跌色 - const { price, prevClose, change, changePct, amount } = quote; + const { price, prevClose, change, changePct, amount } = quoteData; const priceColor = useColorModeValue( - !prevClose || price === prevClose ? 'gray.800' : - price > prevClose ? 'red.500' : 'green.500', - !prevClose || price === prevClose ? 'gray.200' : - price > prevClose ? 'red.400' : 'green.400' + !prevClose || price === prevClose + ? 'gray.800' + : price && price > prevClose + ? 'red.500' + : 'green.500', + !prevClose || price === prevClose + ? 'gray.200' + : price && price > prevClose + ? 'red.400' + : 'green.400' ); // 涨跌幅背景色 const changeBgColor = useColorModeValue( - !changePct || changePct === 0 ? 'gray.100' : - changePct > 0 ? 'red.100' : 'green.100', - !changePct || changePct === 0 ? 'gray.700' : - changePct > 0 ? 'rgba(239, 68, 68, 0.2)' : 'rgba(34, 197, 94, 0.2)' + !changePct || changePct === 0 + ? 'gray.100' + : changePct > 0 + ? 'red.100' + : 'green.100', + !changePct || changePct === 0 + ? 'gray.700' + : changePct > 0 + ? 'rgba(239, 68, 68, 0.2)' + : 'rgba(34, 197, 94, 0.2)' ); // 跳转到详情页 - const handleNavigate = () => { + const handleNavigate = (): void => { if (isIndex) { // 指数暂无详情页 return; @@ -113,6 +122,15 @@ const QuoteTile = ({ navigate(`/company?scode=${code}`); }; + // 获取盘口数据(带类型安全) + const bidPrices = 'bidPrices' in quoteData ? (quoteData.bidPrices as number[]) : []; + const bidVolumes = 'bidVolumes' in quoteData ? (quoteData.bidVolumes as number[]) : []; + const askPrices = 'askPrices' in quoteData ? (quoteData.askPrices as number[]) : []; + const askVolumes = 'askVolumes' in quoteData ? (quoteData.askVolumes as number[]) : []; + const upperLimit = 'upperLimit' in quoteData ? (quoteData.upperLimit as number | undefined) : undefined; + const lowerLimit = 'lowerLimit' in quoteData ? (quoteData.lowerLimit as number | undefined) : undefined; + const openPrice = 'open' in quoteData ? (quoteData.open as number | undefined) : undefined; + return ( 今开: - {formatPrice(quote.open)} + {formatPrice(openPrice)} 成交额: @@ -248,16 +266,16 @@ const QuoteTile = ({ {!isIndex && ( - 盘口 {quote.bidPrices?.length > 5 ? '(10档)' : '(5档)'} + 盘口 {bidPrices.length > 5 ? '(10档)' : '(5档)'} )} diff --git a/src/views/StockOverview/components/FlexScreen/components/index.js b/src/views/StockOverview/components/FlexScreen/components/index.ts similarity index 85% rename from src/views/StockOverview/components/FlexScreen/components/index.js rename to src/views/StockOverview/components/FlexScreen/components/index.ts index 46c2c9f5..4777c0f1 100644 --- a/src/views/StockOverview/components/FlexScreen/components/index.js +++ b/src/views/StockOverview/components/FlexScreen/components/index.ts @@ -1,3 +1,7 @@ +/** + * 组件导出文件 + */ + export { default as MiniTimelineChart } from './MiniTimelineChart'; export { default as OrderBookPanel } from './OrderBookPanel'; export { default as QuoteTile } from './QuoteTile'; diff --git a/src/views/StockOverview/components/FlexScreen/hooks/constants.ts b/src/views/StockOverview/components/FlexScreen/hooks/constants.ts new file mode 100644 index 00000000..6f1717b3 --- /dev/null +++ b/src/views/StockOverview/components/FlexScreen/hooks/constants.ts @@ -0,0 +1,17 @@ +/** + * WebSocket 配置常量 + */ + +import type { Exchange } from '../types'; + +/** WebSocket 服务地址 */ +export const WS_CONFIG: Record = { + SSE: 'ws://49.232.185.254:8765', // 上交所 + SZSE: 'ws://222.128.1.157:8765', // 深交所 +}; + +/** 心跳间隔 (ms) */ +export const HEARTBEAT_INTERVAL = 30000; + +/** 重连间隔 (ms) */ +export const RECONNECT_INTERVAL = 3000; diff --git a/src/views/StockOverview/components/FlexScreen/hooks/index.js b/src/views/StockOverview/components/FlexScreen/hooks/index.js deleted file mode 100644 index 492690a3..00000000 --- a/src/views/StockOverview/components/FlexScreen/hooks/index.js +++ /dev/null @@ -1 +0,0 @@ -export { useRealtimeQuote } from './useRealtimeQuote'; diff --git a/src/views/StockOverview/components/FlexScreen/hooks/index.ts b/src/views/StockOverview/components/FlexScreen/hooks/index.ts new file mode 100644 index 00000000..ffe3399e --- /dev/null +++ b/src/views/StockOverview/components/FlexScreen/hooks/index.ts @@ -0,0 +1,7 @@ +/** + * Hooks 导出文件 + */ + +export { useRealtimeQuote } from './useRealtimeQuote'; +export * from './constants'; +export * from './utils'; diff --git a/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.js b/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.js deleted file mode 100644 index 476563ed..00000000 --- a/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.js +++ /dev/null @@ -1,692 +0,0 @@ -/** - * 实时行情 Hook - * 管理上交所和深交所 WebSocket 连接,获取实时行情数据 - * - * 上交所 (SSE): ws://49.232.185.254:8765 - 需主动订阅,提供五档行情 - * 深交所 (SZSE): ws://222.128.1.157:8765 - 自动推送,提供十档行情 - * - * 深交所支持的数据类型 (category): - * - stock (300111): 股票快照,含10档买卖盘 - * - bond (300211): 债券快照 - * - afterhours_block (300611): 盘后定价大宗交易 - * - afterhours_trading (303711): 盘后定价交易 - * - hk_stock (306311): 港股快照(深港通) - * - index (309011): 指数快照 - * - volume_stats (309111): 成交量统计 - * - fund_nav (309211): 基金净值 - */ -import { useState, useEffect, useRef, useCallback } from 'react'; -import { logger } from '@utils/logger'; - -// WebSocket 地址配置 -const WS_CONFIG = { - SSE: 'ws://49.232.185.254:8765', // 上交所 - SZSE: 'ws://222.128.1.157:8765', // 深交所 -}; - -// 心跳间隔 (ms) -const HEARTBEAT_INTERVAL = 30000; - -// 重连间隔 (ms) -const RECONNECT_INTERVAL = 3000; - -/** - * 判断证券代码属于哪个交易所 - * @param {string} code - 证券代码(可带或不带后缀) - * @returns {'SSE'|'SZSE'} 交易所标识 - */ -const getExchange = (code) => { - const baseCode = code.split('.')[0]; - - // 6开头为上海股票 - if (baseCode.startsWith('6')) { - return 'SSE'; - } - - // 000开头的6位数可能是上证指数或深圳股票 - if (baseCode.startsWith('000') && baseCode.length === 6) { - // 000001-000999 是上证指数范围,但 000001 也是平安银行 - // 这里需要更精确的判断,暂时把 000 开头当深圳 - return 'SZSE'; - } - - // 399开头是深证指数 - if (baseCode.startsWith('399')) { - return 'SZSE'; - } - - // 0、3开头是深圳股票 - if (baseCode.startsWith('0') || baseCode.startsWith('3')) { - return 'SZSE'; - } - - // 5开头是上海 ETF - if (baseCode.startsWith('5')) { - return 'SSE'; - } - - // 1开头是深圳 ETF/债券 - if (baseCode.startsWith('1')) { - return 'SZSE'; - } - - // 默认上海 - return 'SSE'; -}; - -/** - * 标准化证券代码为无后缀格式 - */ -const normalizeCode = (code) => { - return code.split('.')[0]; -}; - -/** - * 从深交所 bids/asks 数组提取价格和量数组 - * @param {Array} orderBook - [{price, volume}, ...] - * @returns {{ prices: number[], volumes: number[] }} - */ -const extractOrderBook = (orderBook) => { - if (!orderBook || !Array.isArray(orderBook)) { - return { prices: [], volumes: [] }; - } - const prices = orderBook.map(item => item.price || 0); - const volumes = orderBook.map(item => item.volume || 0); - return { prices, volumes }; -}; - -/** - * 实时行情 Hook - * @param {string[]} codes - 订阅的证券代码列表 - * @returns {Object} { quotes, connected, subscribe, unsubscribe } - */ -export const useRealtimeQuote = (codes = []) => { - // 行情数据 { [code]: QuoteData } - const [quotes, setQuotes] = useState({}); - // 连接状态 { SSE: boolean, SZSE: boolean } - const [connected, setConnected] = useState({ SSE: false, SZSE: false }); - - // WebSocket 实例引用 - const wsRefs = useRef({ SSE: null, SZSE: null }); - // 心跳定时器 - const heartbeatRefs = useRef({ SSE: null, SZSE: null }); - // 重连定时器 - const reconnectRefs = useRef({ SSE: null, SZSE: null }); - // 当前订阅的代码(按交易所分组) - const subscribedCodes = useRef({ SSE: new Set(), SZSE: new Set() }); - - /** - * 创建 WebSocket 连接 - */ - const createConnection = useCallback((exchange) => { - // 清理现有连接 - if (wsRefs.current[exchange]) { - wsRefs.current[exchange].close(); - } - - const ws = new WebSocket(WS_CONFIG[exchange]); - wsRefs.current[exchange] = ws; - - ws.onopen = () => { - logger.info('FlexScreen', `${exchange} WebSocket 已连接`); - setConnected(prev => ({ ...prev, [exchange]: true })); - - // 上交所需要主动订阅 - if (exchange === 'SSE') { - const codes = Array.from(subscribedCodes.current.SSE); - if (codes.length > 0) { - ws.send(JSON.stringify({ - action: 'subscribe', - channels: ['stock', 'index'], - codes: codes, - })); - } - } - - // 启动心跳 - startHeartbeat(exchange); - }; - - ws.onmessage = (event) => { - try { - const msg = JSON.parse(event.data); - handleMessage(exchange, msg); - } catch (e) { - logger.warn('FlexScreen', `${exchange} 消息解析失败`, e); - } - }; - - ws.onerror = (error) => { - logger.error('FlexScreen', `${exchange} WebSocket 错误`, error); - }; - - ws.onclose = () => { - logger.info('FlexScreen', `${exchange} WebSocket 断开`); - setConnected(prev => ({ ...prev, [exchange]: false })); - stopHeartbeat(exchange); - - // 自动重连 - scheduleReconnect(exchange); - }; - }, []); - - /** - * 处理 WebSocket 消息 - */ - const handleMessage = useCallback((exchange, msg) => { - // 处理 pong - if (msg.type === 'pong') { - return; - } - - if (exchange === 'SSE') { - // 上交所消息格式 - if (msg.type === 'stock' || msg.type === 'index') { - const data = msg.data || {}; - setQuotes(prev => { - const updated = { ...prev }; - Object.entries(data).forEach(([code, quote]) => { - // 只更新订阅的代码 - if (subscribedCodes.current.SSE.has(code)) { - updated[code] = { - code: quote.security_id, - name: quote.security_name, - price: quote.last_price, - prevClose: quote.prev_close, - open: quote.open_price, - high: quote.high_price, - low: quote.low_price, - volume: quote.volume, - amount: quote.amount, - change: quote.last_price - quote.prev_close, - changePct: quote.prev_close ? ((quote.last_price - quote.prev_close) / quote.prev_close * 100) : 0, - bidPrices: quote.bid_prices || [], - bidVolumes: quote.bid_volumes || [], - askPrices: quote.ask_prices || [], - askVolumes: quote.ask_volumes || [], - updateTime: quote.trade_time, - exchange: 'SSE', - }; - } - }); - return updated; - }); - } - } else if (exchange === 'SZSE') { - // 深交所消息格式(更新后的 API) - if (msg.type === 'realtime') { - const { category, data } = msg; - const code = data.security_id; - - // 只更新订阅的代码 - if (!subscribedCodes.current.SZSE.has(code)) { - return; - } - - if (category === 'stock') { - // 股票行情 - 含 10 档买卖盘 - const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(data.bids); - const { prices: askPrices, volumes: askVolumes } = extractOrderBook(data.asks); - - setQuotes(prev => ({ - ...prev, - [code]: { - code: code, - name: prev[code]?.name || '', - price: data.last_px, - prevClose: data.prev_close, - open: data.open_px, - high: data.high_px, - low: data.low_px, - volume: data.volume, - amount: data.amount, - numTrades: data.num_trades, - upperLimit: data.upper_limit, // 涨停价 - lowerLimit: data.lower_limit, // 跌停价 - change: data.last_px - data.prev_close, - changePct: data.prev_close ? ((data.last_px - data.prev_close) / data.prev_close * 100) : 0, - bidPrices, - bidVolumes, - askPrices, - askVolumes, - tradingPhase: data.trading_phase, - updateTime: msg.timestamp, - exchange: 'SZSE', - }, - })); - } else if (category === 'index') { - // 指数行情 - setQuotes(prev => ({ - ...prev, - [code]: { - code: code, - name: prev[code]?.name || '', - price: data.current_index, - prevClose: data.prev_close, - open: data.open_index, - high: data.high_index, - low: data.low_index, - close: data.close_index, - volume: data.volume, - amount: data.amount, - numTrades: data.num_trades, - change: data.current_index - data.prev_close, - changePct: data.prev_close ? ((data.current_index - data.prev_close) / data.prev_close * 100) : 0, - bidPrices: [], - bidVolumes: [], - askPrices: [], - askVolumes: [], - tradingPhase: data.trading_phase, - updateTime: msg.timestamp, - exchange: 'SZSE', - }, - })); - } else if (category === 'bond') { - // 债券行情 - setQuotes(prev => ({ - ...prev, - [code]: { - code: code, - name: prev[code]?.name || '', - price: data.last_px, - prevClose: data.prev_close, - open: data.open_px, - high: data.high_px, - low: data.low_px, - volume: data.volume, - amount: data.amount, - numTrades: data.num_trades, - weightedAvgPx: data.weighted_avg_px, - change: data.last_px - data.prev_close, - changePct: data.prev_close ? ((data.last_px - data.prev_close) / data.prev_close * 100) : 0, - bidPrices: [], - bidVolumes: [], - askPrices: [], - askVolumes: [], - tradingPhase: data.trading_phase, - updateTime: msg.timestamp, - exchange: 'SZSE', - isBond: true, - }, - })); - } else if (category === 'hk_stock') { - // 港股行情(深港通) - const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(data.bids); - const { prices: askPrices, volumes: askVolumes } = extractOrderBook(data.asks); - - setQuotes(prev => ({ - ...prev, - [code]: { - code: code, - name: prev[code]?.name || '', - price: data.last_px, - prevClose: data.prev_close, - open: data.open_px, - high: data.high_px, - low: data.low_px, - volume: data.volume, - amount: data.amount, - numTrades: data.num_trades, - nominalPx: data.nominal_px, // 按盘价 - referencePx: data.reference_px, // 参考价 - change: data.last_px - data.prev_close, - changePct: data.prev_close ? ((data.last_px - data.prev_close) / data.prev_close * 100) : 0, - bidPrices, - bidVolumes, - askPrices, - askVolumes, - tradingPhase: data.trading_phase, - updateTime: msg.timestamp, - exchange: 'SZSE', - isHK: true, - }, - })); - } else if (category === 'afterhours_block' || category === 'afterhours_trading') { - // 盘后交易 - setQuotes(prev => ({ - ...prev, - [code]: { - ...prev[code], - afterhours: { - bidPx: data.bid_px, - bidSize: data.bid_size, - offerPx: data.offer_px, - offerSize: data.offer_size, - volume: data.volume, - amount: data.amount, - numTrades: data.num_trades, - }, - updateTime: msg.timestamp, - }, - })); - } - // fund_nav 和 volume_stats 暂不处理 - } else if (msg.type === 'snapshot') { - // 深交所初始快照 - const { stocks = [], indexes = [], bonds = [] } = msg.data || {}; - setQuotes(prev => { - const updated = { ...prev }; - - stocks.forEach(s => { - if (subscribedCodes.current.SZSE.has(s.security_id)) { - const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(s.bids); - const { prices: askPrices, volumes: askVolumes } = extractOrderBook(s.asks); - - updated[s.security_id] = { - code: s.security_id, - name: s.security_name || '', - price: s.last_px, - prevClose: s.prev_close, - open: s.open_px, - high: s.high_px, - low: s.low_px, - volume: s.volume, - amount: s.amount, - numTrades: s.num_trades, - upperLimit: s.upper_limit, - lowerLimit: s.lower_limit, - change: s.last_px - s.prev_close, - changePct: s.prev_close ? ((s.last_px - s.prev_close) / s.prev_close * 100) : 0, - bidPrices, - bidVolumes, - askPrices, - askVolumes, - exchange: 'SZSE', - }; - } - }); - - indexes.forEach(i => { - if (subscribedCodes.current.SZSE.has(i.security_id)) { - updated[i.security_id] = { - code: i.security_id, - name: i.security_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: i.prev_close ? ((i.current_index - i.prev_close) / i.prev_close * 100) : 0, - bidPrices: [], - bidVolumes: [], - askPrices: [], - askVolumes: [], - exchange: 'SZSE', - }; - } - }); - - bonds.forEach(b => { - if (subscribedCodes.current.SZSE.has(b.security_id)) { - updated[b.security_id] = { - code: b.security_id, - name: b.security_name || '', - price: b.last_px, - prevClose: b.prev_close, - 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: b.prev_close ? ((b.last_px - b.prev_close) / b.prev_close * 100) : 0, - bidPrices: [], - bidVolumes: [], - askPrices: [], - askVolumes: [], - exchange: 'SZSE', - isBond: true, - }; - } - }); - - return updated; - }); - } - } - }, []); - - /** - * 启动心跳 - */ - const startHeartbeat = useCallback((exchange) => { - stopHeartbeat(exchange); - heartbeatRefs.current[exchange] = setInterval(() => { - const ws = wsRefs.current[exchange]; - if (ws && ws.readyState === WebSocket.OPEN) { - if (exchange === 'SSE') { - ws.send(JSON.stringify({ action: 'ping' })); - } else { - ws.send(JSON.stringify({ type: 'ping' })); - } - } - }, HEARTBEAT_INTERVAL); - }, []); - - /** - * 停止心跳 - */ - const stopHeartbeat = useCallback((exchange) => { - if (heartbeatRefs.current[exchange]) { - clearInterval(heartbeatRefs.current[exchange]); - heartbeatRefs.current[exchange] = null; - } - }, []); - - /** - * 安排重连 - */ - const scheduleReconnect = useCallback((exchange) => { - if (reconnectRefs.current[exchange]) { - return; // 已有重连计划 - } - - reconnectRefs.current[exchange] = setTimeout(() => { - reconnectRefs.current[exchange] = null; - // 只有还有订阅的代码才重连 - if (subscribedCodes.current[exchange].size > 0) { - createConnection(exchange); - } - }, RECONNECT_INTERVAL); - }, [createConnection]); - - /** - * 订阅证券 - */ - const subscribe = useCallback((code) => { - const baseCode = normalizeCode(code); - const exchange = getExchange(code); - - // 添加到订阅列表 - subscribedCodes.current[exchange].add(baseCode); - - // 如果连接已建立,发送订阅消息(仅上交所需要) - const ws = wsRefs.current[exchange]; - if (exchange === 'SSE' && ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ - action: 'subscribe', - channels: ['stock', 'index'], - codes: [baseCode], - })); - } - - // 如果连接未建立,创建连接 - if (!ws || ws.readyState !== WebSocket.OPEN) { - createConnection(exchange); - } - }, [createConnection]); - - /** - * 取消订阅 - */ - const unsubscribe = useCallback((code) => { - const baseCode = normalizeCode(code); - const exchange = getExchange(code); - - // 从订阅列表移除 - subscribedCodes.current[exchange].delete(baseCode); - - // 从 quotes 中移除 - setQuotes(prev => { - const updated = { ...prev }; - delete updated[baseCode]; - return updated; - }); - - // 如果该交易所没有订阅了,关闭连接 - if (subscribedCodes.current[exchange].size === 0) { - const ws = wsRefs.current[exchange]; - if (ws) { - ws.close(); - wsRefs.current[exchange] = null; - } - } - }, []); - - /** - * 初始化订阅 - */ - useEffect(() => { - if (!codes || codes.length === 0) { - return; - } - - // 按交易所分组 - const sseCodesSet = new Set(); - const szseCodesSet = new Set(); - - codes.forEach(code => { - const baseCode = normalizeCode(code); - const exchange = getExchange(code); - if (exchange === 'SSE') { - sseCodesSet.add(baseCode); - } else { - szseCodesSet.add(baseCode); - } - }); - - // 更新订阅列表 - subscribedCodes.current.SSE = sseCodesSet; - subscribedCodes.current.SZSE = szseCodesSet; - - // 建立连接 - if (sseCodesSet.size > 0) { - createConnection('SSE'); - } - if (szseCodesSet.size > 0) { - createConnection('SZSE'); - } - - // 清理 - return () => { - ['SSE', 'SZSE'].forEach(exchange => { - stopHeartbeat(exchange); - if (reconnectRefs.current[exchange]) { - clearTimeout(reconnectRefs.current[exchange]); - } - const ws = wsRefs.current[exchange]; - if (ws) { - ws.close(); - } - }); - }; - }, []); // 只在挂载时执行 - - /** - * 处理 codes 变化 - */ - useEffect(() => { - if (!codes) return; - - // 计算新的订阅列表 - const newSseCodes = new Set(); - const newSzseCodes = new Set(); - - codes.forEach(code => { - const baseCode = normalizeCode(code); - const exchange = getExchange(code); - if (exchange === 'SSE') { - newSseCodes.add(baseCode); - } else { - newSzseCodes.add(baseCode); - } - }); - - // 找出需要新增和删除的代码 - const oldSseCodes = subscribedCodes.current.SSE; - const oldSzseCodes = subscribedCodes.current.SZSE; - - // 更新上交所订阅 - const sseToAdd = [...newSseCodes].filter(c => !oldSseCodes.has(c)); - const sseToRemove = [...oldSseCodes].filter(c => !newSseCodes.has(c)); - - if (sseToAdd.length > 0 || sseToRemove.length > 0) { - subscribedCodes.current.SSE = newSseCodes; - - const ws = wsRefs.current.SSE; - if (ws && ws.readyState === WebSocket.OPEN && sseToAdd.length > 0) { - ws.send(JSON.stringify({ - action: 'subscribe', - channels: ['stock', 'index'], - codes: sseToAdd, - })); - } - - // 如果新增了代码但连接未建立 - if (sseToAdd.length > 0 && (!ws || ws.readyState !== WebSocket.OPEN)) { - createConnection('SSE'); - } - - // 如果没有订阅了,关闭连接 - if (newSseCodes.size === 0 && ws) { - ws.close(); - wsRefs.current.SSE = null; - } - } - - // 更新深交所订阅 - const szseToAdd = [...newSzseCodes].filter(c => !oldSzseCodes.has(c)); - const szseToRemove = [...oldSzseCodes].filter(c => !newSzseCodes.has(c)); - - if (szseToAdd.length > 0 || szseToRemove.length > 0) { - subscribedCodes.current.SZSE = newSzseCodes; - - // 深交所是自动推送,只需要管理连接 - const ws = wsRefs.current.SZSE; - - if (szseToAdd.length > 0 && (!ws || ws.readyState !== WebSocket.OPEN)) { - createConnection('SZSE'); - } - - if (newSzseCodes.size === 0 && ws) { - ws.close(); - wsRefs.current.SZSE = null; - } - } - - // 清理已取消订阅的 quotes - const removedCodes = [...sseToRemove, ...szseToRemove]; - if (removedCodes.length > 0) { - setQuotes(prev => { - const updated = { ...prev }; - removedCodes.forEach(code => { - delete updated[code]; - }); - return updated; - }); - } - }, [codes, createConnection]); - - return { - quotes, - connected, - subscribe, - unsubscribe, - }; -}; - -export default useRealtimeQuote; diff --git a/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts b/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts new file mode 100644 index 00000000..dfb69bfc --- /dev/null +++ b/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts @@ -0,0 +1,594 @@ +/** + * 实时行情 Hook + * 管理上交所和深交所 WebSocket 连接,获取实时行情数据 + * + * 上交所 (SSE): ws://49.232.185.254:8765 - 需主动订阅,提供五档行情 + * 深交所 (SZSE): ws://222.128.1.157:8765 - 自动推送,提供十档行情 + */ + +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 type { + Exchange, + ConnectionStatus, + QuotesMap, + QuoteData, + SSEMessage, + SSEQuoteItem, + SZSEMessage, + SZSERealtimeMessage, + SZSESnapshotMessage, + SZSEStockData, + SZSEIndexData, + SZSEBondData, + SZSEHKStockData, + SZSEAfterhoursData, + UseRealtimeQuoteReturn, +} from '../types'; + +/** + * 处理上交所消息 + */ +const handleSSEMessage = ( + msg: SSEMessage, + subscribedCodes: Set, + prevQuotes: QuotesMap +): QuotesMap | null => { + if (msg.type !== 'stock' && msg.type !== 'index') { + return null; + } + + const data = msg.data || {}; + const updated: QuotesMap = { ...prevQuotes }; + let hasUpdate = false; + + Object.entries(data).forEach(([code, quote]: [string, SSEQuoteItem]) => { + if (subscribedCodes.has(code)) { + hasUpdate = true; + updated[code] = { + code: quote.security_id, + name: quote.security_name, + price: quote.last_price, + prevClose: quote.prev_close, + open: quote.open_price, + high: quote.high_price, + low: quote.low_price, + volume: quote.volume, + amount: quote.amount, + change: quote.last_price - quote.prev_close, + changePct: calcChangePct(quote.last_price, quote.prev_close), + bidPrices: quote.bid_prices || [], + bidVolumes: quote.bid_volumes || [], + askPrices: quote.ask_prices || [], + askVolumes: quote.ask_volumes || [], + updateTime: quote.trade_time, + exchange: 'SSE', + } as QuoteData; + } + }); + + return hasUpdate ? updated : null; +}; + +/** + * 处理深交所实时消息 + */ +const handleSZSERealtimeMessage = ( + msg: SZSERealtimeMessage, + subscribedCodes: Set, + prevQuotes: QuotesMap +): QuotesMap | null => { + const { category, data, timestamp } = msg; + const code = data.security_id; + + if (!subscribedCodes.has(code)) { + return null; + } + + const updated: QuotesMap = { ...prevQuotes }; + + switch (category) { + case 'stock': { + const stockData = data as SZSEStockData; + const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(stockData.bids); + const { prices: askPrices, volumes: askVolumes } = extractOrderBook(stockData.asks); + + updated[code] = { + code, + name: prevQuotes[code]?.name || '', + price: stockData.last_px, + prevClose: stockData.prev_close, + open: stockData.open_px, + high: stockData.high_px, + low: stockData.low_px, + volume: stockData.volume, + amount: stockData.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), + bidPrices, + bidVolumes, + askPrices, + askVolumes, + tradingPhase: stockData.trading_phase, + updateTime: timestamp, + exchange: 'SZSE', + } as QuoteData; + break; + } + + case 'index': { + const indexData = data as SZSEIndexData; + updated[code] = { + code, + name: prevQuotes[code]?.name || '', + price: indexData.current_index, + prevClose: indexData.prev_close, + open: indexData.open_index, + high: indexData.high_index, + low: indexData.low_index, + close: indexData.close_index, + volume: indexData.volume, + amount: indexData.amount, + numTrades: indexData.num_trades, + change: indexData.current_index - indexData.prev_close, + changePct: calcChangePct(indexData.current_index, indexData.prev_close), + bidPrices: [], + bidVolumes: [], + askPrices: [], + askVolumes: [], + tradingPhase: indexData.trading_phase, + updateTime: timestamp, + exchange: 'SZSE', + } as QuoteData; + break; + } + + case 'bond': { + const bondData = data as SZSEBondData; + updated[code] = { + code, + name: prevQuotes[code]?.name || '', + price: bondData.last_px, + prevClose: bondData.prev_close, + 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 - bondData.prev_close, + changePct: calcChangePct(bondData.last_px, bondData.prev_close), + 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[code] = { + code, + name: prevQuotes[code]?.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[code]; + if (existing) { + updated[code] = { + ...existing, + afterhours: { + 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, + }, + updateTime: timestamp, + } as QuoteData; + } + break; + } + + default: + return null; + } + + return updated; +}; + +/** + * 处理深交所快照消息 + */ +const handleSZSESnapshotMessage = ( + msg: SZSESnapshotMessage, + subscribedCodes: Set, + prevQuotes: QuotesMap +): QuotesMap | null => { + const { stocks = [], indexes = [], bonds = [] } = msg.data || {}; + const updated: QuotesMap = { ...prevQuotes }; + let hasUpdate = false; + + stocks.forEach((s: SZSEStockData) => { + if (subscribedCodes.has(s.security_id)) { + hasUpdate = true; + const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(s.bids); + const { prices: askPrices, volumes: askVolumes } = extractOrderBook(s.asks); + + updated[s.security_id] = { + code: s.security_id, + name: '', + price: s.last_px, + prevClose: s.prev_close, + open: s.open_px, + high: s.high_px, + low: s.low_px, + volume: s.volume, + amount: s.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), + bidPrices, + bidVolumes, + askPrices, + askVolumes, + exchange: 'SZSE', + } as QuoteData; + } + }); + + indexes.forEach((i: SZSEIndexData) => { + if (subscribedCodes.has(i.security_id)) { + hasUpdate = true; + updated[i.security_id] = { + code: i.security_id, + 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) => { + if (subscribedCodes.has(b.security_id)) { + hasUpdate = true; + updated[b.security_id] = { + code: b.security_id, + name: '', + price: b.last_px, + prevClose: b.prev_close, + 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), + bidPrices: [], + bidVolumes: [], + askPrices: [], + askVolumes: [], + exchange: 'SZSE', + isBond: true, + } as QuoteData; + } + }); + + return hasUpdate ? updated : null; +}; + +/** + * 实时行情 Hook + * @param codes - 订阅的证券代码列表 + */ +export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn => { + const [quotes, setQuotes] = useState({}); + const [connected, setConnected] = useState({ SSE: false, SZSE: false }); + + const wsRefs = useRef>({ SSE: null, SZSE: null }); + const heartbeatRefs = useRef>({ SSE: null, SZSE: null }); + const reconnectRefs = useRef>({ SSE: null, SZSE: null }); + const subscribedCodes = useRef>>({ + SSE: new Set(), + SZSE: new Set(), + }); + + const stopHeartbeat = useCallback((exchange: Exchange) => { + if (heartbeatRefs.current[exchange]) { + clearInterval(heartbeatRefs.current[exchange]!); + heartbeatRefs.current[exchange] = null; + } + }, []); + + const startHeartbeat = useCallback((exchange: Exchange) => { + stopHeartbeat(exchange); + heartbeatRefs.current[exchange] = setInterval(() => { + const ws = wsRefs.current[exchange]; + if (ws && ws.readyState === WebSocket.OPEN) { + const msg = exchange === 'SSE' ? { action: 'ping' } : { type: 'ping' }; + ws.send(JSON.stringify(msg)); + } + }, HEARTBEAT_INTERVAL); + }, [stopHeartbeat]); + + const handleMessage = useCallback((exchange: Exchange, msg: SSEMessage | SZSEMessage) => { + if (msg.type === 'pong') return; + + if (exchange === 'SSE') { + const result = handleSSEMessage( + msg as SSEMessage, + subscribedCodes.current.SSE, + {} // Will be merged with current state + ); + if (result) { + setQuotes(prev => ({ ...prev, ...result })); + } + } else { + if (msg.type === 'realtime') { + setQuotes(prev => { + const result = handleSZSERealtimeMessage( + msg as SZSERealtimeMessage, + subscribedCodes.current.SZSE, + prev + ); + return result || prev; + }); + } else if (msg.type === 'snapshot') { + setQuotes(prev => { + const result = handleSZSESnapshotMessage( + msg as SZSESnapshotMessage, + subscribedCodes.current.SZSE, + prev + ); + return result || prev; + }); + } + } + }, []); + + const createConnection = useCallback((exchange: Exchange) => { + if (wsRefs.current[exchange]) { + wsRefs.current[exchange]!.close(); + } + + const ws = new WebSocket(WS_CONFIG[exchange]); + wsRefs.current[exchange] = ws; + + ws.onopen = () => { + logger.info('FlexScreen', `${exchange} WebSocket 已连接`); + setConnected(prev => ({ ...prev, [exchange]: true })); + + if (exchange === 'SSE') { + const codes = Array.from(subscribedCodes.current.SSE); + if (codes.length > 0) { + ws.send(JSON.stringify({ + action: 'subscribe', + channels: ['stock', 'index'], + codes, + })); + } + } + + startHeartbeat(exchange); + }; + + ws.onmessage = (event: MessageEvent) => { + try { + const msg = JSON.parse(event.data); + handleMessage(exchange, msg); + } catch (e) { + logger.warn('FlexScreen', `${exchange} 消息解析失败`, e); + } + }; + + ws.onerror = (error: Event) => { + logger.error('FlexScreen', `${exchange} WebSocket 错误`, error); + }; + + ws.onclose = () => { + logger.info('FlexScreen', `${exchange} WebSocket 断开`); + setConnected(prev => ({ ...prev, [exchange]: false })); + stopHeartbeat(exchange); + + // 自动重连 + if (!reconnectRefs.current[exchange] && subscribedCodes.current[exchange].size > 0) { + reconnectRefs.current[exchange] = setTimeout(() => { + reconnectRefs.current[exchange] = null; + if (subscribedCodes.current[exchange].size > 0) { + createConnection(exchange); + } + }, RECONNECT_INTERVAL); + } + }; + }, [startHeartbeat, stopHeartbeat, handleMessage]); + + const subscribe = useCallback((code: string) => { + const baseCode = normalizeCode(code); + const exchange = getExchange(code); + + subscribedCodes.current[exchange].add(baseCode); + + const ws = wsRefs.current[exchange]; + if (exchange === 'SSE' && ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + action: 'subscribe', + channels: ['stock', 'index'], + codes: [baseCode], + })); + } + + if (!ws || ws.readyState !== WebSocket.OPEN) { + createConnection(exchange); + } + }, [createConnection]); + + const unsubscribe = useCallback((code: string) => { + const baseCode = normalizeCode(code); + const exchange = getExchange(code); + + subscribedCodes.current[exchange].delete(baseCode); + + setQuotes(prev => { + const updated = { ...prev }; + delete updated[baseCode]; + return updated; + }); + + if (subscribedCodes.current[exchange].size === 0) { + const ws = wsRefs.current[exchange]; + if (ws) { + ws.close(); + wsRefs.current[exchange] = null; + } + } + }, []); + + // 初始化和 codes 变化处理 + useEffect(() => { + if (!codes || codes.length === 0) return; + + const newSseCodes = new Set(); + const newSzseCodes = new Set(); + + codes.forEach(code => { + const baseCode = normalizeCode(code); + const exchange = getExchange(code); + if (exchange === 'SSE') { + newSseCodes.add(baseCode); + } else { + newSzseCodes.add(baseCode); + } + }); + + // 更新上交所订阅 + const oldSseCodes = subscribedCodes.current.SSE; + const sseToAdd = [...newSseCodes].filter(c => !oldSseCodes.has(c)); + + if (sseToAdd.length > 0 || newSseCodes.size !== oldSseCodes.size) { + subscribedCodes.current.SSE = newSseCodes; + const ws = wsRefs.current.SSE; + + if (ws && ws.readyState === WebSocket.OPEN && sseToAdd.length > 0) { + ws.send(JSON.stringify({ + action: 'subscribe', + channels: ['stock', 'index'], + codes: sseToAdd, + })); + } + + if (sseToAdd.length > 0 && (!ws || ws.readyState !== WebSocket.OPEN)) { + createConnection('SSE'); + } + + if (newSseCodes.size === 0 && ws) { + ws.close(); + wsRefs.current.SSE = null; + } + } + + // 更新深交所订阅 + const oldSzseCodes = subscribedCodes.current.SZSE; + const szseToAdd = [...newSzseCodes].filter(c => !oldSzseCodes.has(c)); + + if (szseToAdd.length > 0 || newSzseCodes.size !== oldSzseCodes.size) { + subscribedCodes.current.SZSE = newSzseCodes; + const ws = wsRefs.current.SZSE; + + if (szseToAdd.length > 0 && (!ws || ws.readyState !== WebSocket.OPEN)) { + createConnection('SZSE'); + } + + if (newSzseCodes.size === 0 && ws) { + ws.close(); + wsRefs.current.SZSE = null; + } + } + + // 清理已取消订阅的 quotes + const allNewCodes = new Set([...newSseCodes, ...newSzseCodes]); + setQuotes(prev => { + const updated: QuotesMap = {}; + Object.keys(prev).forEach(code => { + if (allNewCodes.has(code)) { + updated[code] = prev[code]; + } + }); + return updated; + }); + }, [codes, createConnection]); + + // 清理 + useEffect(() => { + return () => { + (['SSE', 'SZSE'] as Exchange[]).forEach(exchange => { + stopHeartbeat(exchange); + if (reconnectRefs.current[exchange]) { + clearTimeout(reconnectRefs.current[exchange]!); + } + const ws = wsRefs.current[exchange]; + if (ws) { + ws.close(); + } + }); + }; + }, [stopHeartbeat]); + + return { quotes, connected, subscribe, unsubscribe }; +}; + +export default useRealtimeQuote; diff --git a/src/views/StockOverview/components/FlexScreen/hooks/utils.ts b/src/views/StockOverview/components/FlexScreen/hooks/utils.ts new file mode 100644 index 00000000..8f71de36 --- /dev/null +++ b/src/views/StockOverview/components/FlexScreen/hooks/utils.ts @@ -0,0 +1,83 @@ +/** + * 实时行情相关工具函数 + */ + +import type { Exchange, OrderBookLevel } from '../types'; + +/** + * 判断证券代码属于哪个交易所 + * @param code - 证券代码(可带或不带后缀) + * @returns 交易所标识 + */ +export const getExchange = (code: string): Exchange => { + const baseCode = code.split('.')[0]; + + // 6开头为上海股票 + if (baseCode.startsWith('6')) { + return 'SSE'; + } + + // 000开头的6位数是深圳股票(如平安银行000001) + if (baseCode.startsWith('000') && baseCode.length === 6) { + return 'SZSE'; + } + + // 399开头是深证指数 + if (baseCode.startsWith('399')) { + return 'SZSE'; + } + + // 0、3开头是深圳股票 + if (baseCode.startsWith('0') || baseCode.startsWith('3')) { + return 'SZSE'; + } + + // 5开头是上海 ETF + if (baseCode.startsWith('5')) { + return 'SSE'; + } + + // 1开头是深圳 ETF/债券 + if (baseCode.startsWith('1')) { + return 'SZSE'; + } + + // 默认上海 + return 'SSE'; +}; + +/** + * 标准化证券代码为无后缀格式 + * @param code - 原始代码 + * @returns 无后缀代码 + */ +export const normalizeCode = (code: string): string => { + return code.split('.')[0]; +}; + +/** + * 从深交所 bids/asks 数组提取价格和量数组 + * @param orderBook - 盘口数组 [{price, volume}, ...] + * @returns { prices, volumes } + */ +export const extractOrderBook = ( + orderBook: OrderBookLevel[] | undefined +): { prices: number[]; volumes: number[] } => { + if (!orderBook || !Array.isArray(orderBook)) { + return { prices: [], volumes: [] }; + } + const prices = orderBook.map(item => item.price || 0); + const volumes = orderBook.map(item => item.volume || 0); + return { prices, volumes }; +}; + +/** + * 计算涨跌幅 + * @param price - 当前价 + * @param prevClose - 昨收价 + * @returns 涨跌幅百分比 + */ +export const calcChangePct = (price: number, prevClose: number): number => { + if (!prevClose || prevClose === 0) return 0; + return ((price - prevClose) / prevClose) * 100; +}; diff --git a/src/views/StockOverview/components/FlexScreen/index.js b/src/views/StockOverview/components/FlexScreen/index.tsx similarity index 83% rename from src/views/StockOverview/components/FlexScreen/index.js rename to src/views/StockOverview/components/FlexScreen/index.tsx index aeace485..59132aa9 100644 --- a/src/views/StockOverview/components/FlexScreen/index.js +++ b/src/views/StockOverview/components/FlexScreen/index.tsx @@ -6,7 +6,7 @@ * 1. 添加/删除自选证券 * 2. 显示实时行情(通过 WebSocket) * 3. 显示分时走势(结合 ClickHouse 历史数据) - * 4. 显示五档盘口(上交所完整五档,深交所买一卖一) + * 4. 显示五档盘口(上交所5档,深交所10档) * 5. 本地存储自选列表 */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; @@ -23,7 +23,6 @@ import { InputLeftElement, InputRightElement, IconButton, - Button, SimpleGrid, Flex, Spacer, @@ -41,7 +40,6 @@ import { MenuButton, MenuList, MenuItem, - Divider, Tag, TagLabel, } from '@chakra-ui/react'; @@ -53,24 +51,31 @@ import { ChevronUpIcon, SettingsIcon, } from '@chakra-ui/icons'; -import { FaDesktop, FaPlus, FaTrash, FaSync, FaWifi, FaExclamationCircle } from 'react-icons/fa'; +import { + FaDesktop, + FaTrash, + FaSync, + FaWifi, + FaExclamationCircle, +} from 'react-icons/fa'; import { useRealtimeQuote } from './hooks'; -import { QuoteTile } from './components'; +import QuoteTile from './components/QuoteTile'; import { logger } from '@utils/logger'; +import type { WatchlistItem, ConnectionStatus } from './types'; // 本地存储 key const STORAGE_KEY = 'flexscreen_watchlist'; // 默认自选列表 -const DEFAULT_WATCHLIST = [ +const DEFAULT_WATCHLIST: WatchlistItem[] = [ { code: '000001', name: '上证指数', isIndex: true }, { code: '399001', name: '深证成指', isIndex: true }, { code: '399006', name: '创业板指', isIndex: true }, ]; // 热门推荐 -const HOT_RECOMMENDATIONS = [ +const HOT_RECOMMENDATIONS: WatchlistItem[] = [ { code: '000001', name: '上证指数', isIndex: true }, { code: '399001', name: '深证成指', isIndex: true }, { code: '399006', name: '创业板指', isIndex: true }, @@ -81,17 +86,38 @@ const HOT_RECOMMENDATIONS = [ { code: '002594', name: '比亚迪', isIndex: false }, ]; +/** 搜索结果项 */ +interface SearchResultItem { + stock_code: string; + stock_name: string; + isIndex?: boolean; + code?: string; + name?: string; +} + +/** 搜索 API 响应 */ +interface SearchApiResponse { + success: boolean; + data?: SearchResultItem[]; +} + +/** 连接状态信息 */ +interface ConnectionStatusInfo { + color: string; + text: string; +} + /** * FlexScreen 组件 */ -const FlexScreen = () => { +const FlexScreen: React.FC = () => { const toast = useToast(); // 自选列表 - const [watchlist, setWatchlist] = useState([]); + const [watchlist, setWatchlist] = useState([]); // 搜索状态 const [searchQuery, setSearchQuery] = useState(''); - const [searchResults, setSearchResults] = useState([]); + const [searchResults, setSearchResults] = useState([]); const [isSearching, setIsSearching] = useState(false); const [showResults, setShowResults] = useState(false); // 面板状态 @@ -118,7 +144,7 @@ const FlexScreen = () => { try { const saved = localStorage.getItem(STORAGE_KEY); if (saved) { - const parsed = JSON.parse(saved); + const parsed = JSON.parse(saved) as WatchlistItem[]; if (Array.isArray(parsed) && parsed.length > 0) { setWatchlist(parsed); return; @@ -143,7 +169,7 @@ const FlexScreen = () => { }, [watchlist]); // 搜索证券 - const searchSecurities = useCallback(async (query) => { + const searchSecurities = useCallback(async (query: string): Promise => { if (!query.trim()) { setSearchResults([]); setShowResults(false); @@ -153,7 +179,7 @@ const FlexScreen = () => { setIsSearching(true); try { const response = await fetch(`/api/stocks/search?q=${encodeURIComponent(query)}&limit=10`); - const data = await response.json(); + const data: SearchApiResponse = await response.json(); if (data.success) { setSearchResults(data.data || []); @@ -178,44 +204,48 @@ const FlexScreen = () => { }, [searchQuery, searchSecurities]); // 添加证券 - const addSecurity = useCallback((security) => { - const code = security.stock_code || security.code; - const name = security.stock_name || security.name; - const isIndex = security.isIndex || code.startsWith('000') || code.startsWith('399'); + const addSecurity = useCallback( + (security: SearchResultItem | WatchlistItem): void => { + const code = 'stock_code' in security ? security.stock_code : security.code; + const name = 'stock_name' in security ? security.stock_name : security.name; + const isIndex = + security.isIndex || code.startsWith('000') || code.startsWith('399'); + + // 检查是否已存在 + if (watchlist.some(item => item.code === code)) { + toast({ + title: '已在自选列表中', + status: 'info', + duration: 2000, + isClosable: true, + }); + return; + } + + // 添加到列表 + setWatchlist(prev => [...prev, { code, name, isIndex }]); - // 检查是否已存在 - if (watchlist.some(item => item.code === code)) { toast({ - title: '已在自选列表中', - status: 'info', + title: `已添加 ${name}`, + status: 'success', duration: 2000, isClosable: true, }); - return; - } - // 添加到列表 - setWatchlist(prev => [...prev, { code, name, isIndex }]); - - toast({ - title: `已添加 ${name}`, - status: 'success', - duration: 2000, - isClosable: true, - }); - - // 清空搜索 - setSearchQuery(''); - setShowResults(false); - }, [watchlist, toast]); + // 清空搜索 + setSearchQuery(''); + setShowResults(false); + }, + [watchlist, toast] + ); // 移除证券 - const removeSecurity = useCallback((code) => { + const removeSecurity = useCallback((code: string): void => { setWatchlist(prev => prev.filter(item => item.code !== code)); }, []); // 清空自选列表 - const clearWatchlist = useCallback(() => { + const clearWatchlist = useCallback((): void => { setWatchlist([]); localStorage.removeItem(STORAGE_KEY); toast({ @@ -227,7 +257,7 @@ const FlexScreen = () => { }, [toast]); // 重置为默认列表 - const resetWatchlist = useCallback(() => { + const resetWatchlist = useCallback((): void => { setWatchlist(DEFAULT_WATCHLIST); toast({ title: '已重置为默认列表', @@ -239,7 +269,7 @@ const FlexScreen = () => { // 连接状态指示 const isAnyConnected = connected.SSE || connected.SZSE; - const connectionStatus = useMemo(() => { + const connectionStatus = useMemo((): ConnectionStatusInfo => { if (connected.SSE && connected.SZSE) { return { color: 'green', text: '上交所/深交所 已连接' }; } @@ -317,7 +347,7 @@ const FlexScreen = () => { setSearchQuery(e.target.value)} + onChange={e => setSearchQuery(e.target.value)} bg={searchBg} borderRadius="lg" _focus={{ @@ -413,7 +443,7 @@ const FlexScreen = () => { 热门推荐(点击添加) - {HOT_RECOMMENDATIONS.map((item) => ( + {HOT_RECOMMENDATIONS.map(item => ( { {/* 自选列表 */} {watchlist.length > 0 ? ( - {watchlist.map((item) => ( + {watchlist.map(item => ( {
- - 自选列表为空,请搜索添加证券 - + 自选列表为空,请搜索添加证券
)} diff --git a/src/views/StockOverview/components/FlexScreen/types.ts b/src/views/StockOverview/components/FlexScreen/types.ts new file mode 100644 index 00000000..796290f3 --- /dev/null +++ b/src/views/StockOverview/components/FlexScreen/types.ts @@ -0,0 +1,322 @@ +/** + * 灵活屏组件类型定义 + */ + +// ==================== WebSocket 相关类型 ==================== + +/** 交易所标识 */ +export type Exchange = 'SSE' | 'SZSE'; + +/** WebSocket 连接状态 */ +export interface ConnectionStatus { + SSE: boolean; + SZSE: boolean; +} + +/** 盘口档位数据 */ +export interface OrderBookLevel { + price: number; + volume: number; +} + +// ==================== 行情数据类型 ==================== + +/** 盘后交易数据 */ +export interface AfterhoursData { + bidPx: number; + bidSize: number; + offerPx: number; + offerSize: number; + volume: number; + amount: number; + numTrades: number; +} + +/** 基础行情数据 */ +export interface BaseQuoteData { + code: string; + name: string; + price: number; + prevClose: number; + open: number; + high: number; + low: number; + volume: number; + amount: number; + change: number; + changePct: number; + updateTime?: string; + exchange: Exchange; +} + +/** 股票行情数据 */ +export interface StockQuoteData extends BaseQuoteData { + numTrades?: number; + upperLimit?: number; // 涨停价 + lowerLimit?: number; // 跌停价 + bidPrices: number[]; + bidVolumes: number[]; + askPrices: number[]; + askVolumes: number[]; + tradingPhase?: string; + afterhours?: AfterhoursData; // 盘后交易数据 +} + +/** 指数行情数据 */ +export interface IndexQuoteData extends BaseQuoteData { + close?: number; + numTrades?: number; + bidPrices: number[]; + bidVolumes: number[]; + askPrices: number[]; + askVolumes: number[]; + tradingPhase?: string; +} + +/** 债券行情数据 */ +export interface BondQuoteData extends BaseQuoteData { + numTrades?: number; + weightedAvgPx?: number; + bidPrices: number[]; + bidVolumes: number[]; + askPrices: number[]; + askVolumes: number[]; + tradingPhase?: string; + isBond: true; +} + +/** 港股行情数据 */ +export interface HKStockQuoteData extends BaseQuoteData { + numTrades?: number; + nominalPx?: number; // 按盘价 + referencePx?: number; // 参考价 + bidPrices: number[]; + bidVolumes: number[]; + askPrices: number[]; + askVolumes: number[]; + tradingPhase?: string; + isHK: true; +} + +/** 统一行情数据类型 */ +export type QuoteData = StockQuoteData | IndexQuoteData | BondQuoteData | HKStockQuoteData; + +/** 行情数据字典 */ +export interface QuotesMap { + [code: string]: QuoteData; +} + +// ==================== 上交所 WebSocket 消息类型 ==================== + +/** 上交所行情数据 */ +export interface SSEQuoteItem { + security_id: string; + security_name: string; + prev_close: number; + open_price: number; + high_price: number; + low_price: number; + last_price: number; + close_price: number; + volume: number; + amount: number; + bid_prices?: number[]; + bid_volumes?: number[]; + ask_prices?: number[]; + ask_volumes?: number[]; + trading_status?: string; + trade_time?: string; + update_time?: string; +} + +/** 上交所消息 */ +export interface SSEMessage { + type: 'stock' | 'index' | 'etf' | 'bond' | 'option' | 'subscribed' | 'pong' | 'error'; + timestamp?: string; + data?: Record; + channels?: string[]; + message?: string; +} + +// ==================== 深交所 WebSocket 消息类型 ==================== + +/** 深交所数据类别 */ +export type SZSECategory = + | 'stock' // 300111 股票快照 + | 'bond' // 300211 债券快照 + | 'afterhours_block' // 300611 盘后定价大宗交易 + | 'afterhours_trading' // 303711 盘后定价交易 + | 'hk_stock' // 306311 港股快照 + | 'index' // 309011 指数快照 + | 'volume_stats' // 309111 成交量统计 + | 'fund_nav'; // 309211 基金净值 + +/** 深交所股票行情数据 */ +export interface SZSEStockData { + security_id: string; + orig_time?: number; + channel_no?: number; + trading_phase?: string; + last_px: number; + open_px: number; + high_px: number; + low_px: number; + prev_close: number; + volume: number; + amount: number; + num_trades?: number; + upper_limit?: number; + lower_limit?: number; + bids?: OrderBookLevel[]; + asks?: OrderBookLevel[]; +} + +/** 深交所指数行情数据 */ +export interface SZSEIndexData { + security_id: string; + orig_time?: number; + channel_no?: number; + trading_phase?: string; + 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; +} + +/** 深交所债券行情数据 */ +export interface SZSEBondData { + security_id: string; + orig_time?: number; + channel_no?: number; + trading_phase?: string; + last_px: number; + open_px: number; + high_px: number; + low_px: number; + prev_close: number; + weighted_avg_px?: number; + volume: number; + amount: number; + num_trades?: number; + auction_volume?: number; + auction_amount?: number; +} + +/** 深交所港股行情数据 */ +export interface SZSEHKStockData { + security_id: string; + orig_time?: number; + channel_no?: number; + trading_phase?: string; + last_px: number; + open_px: number; + high_px: number; + low_px: number; + prev_close: number; + nominal_px?: number; + reference_px?: number; + volume: number; + amount: number; + num_trades?: number; + vcm_start_time?: number; + vcm_end_time?: number; + bids?: OrderBookLevel[]; + asks?: OrderBookLevel[]; +} + +/** 深交所盘后交易数据 */ +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; +} + +/** 深交所实时消息 */ +export interface SZSERealtimeMessage { + type: 'realtime'; + category: SZSECategory; + msg_type?: number; + timestamp: string; + data: SZSEStockData | SZSEIndexData | SZSEBondData | SZSEHKStockData | SZSEAfterhoursData; +} + +/** 深交所快照消息 */ +export interface SZSESnapshotMessage { + type: 'snapshot'; + timestamp: string; + data: { + stocks?: SZSEStockData[]; + indexes?: SZSEIndexData[]; + bonds?: SZSEBondData[]; + }; +} + +/** 深交所消息类型 */ +export type SZSEMessage = SZSERealtimeMessage | SZSESnapshotMessage | { type: 'pong' }; + +// ==================== 组件 Props 类型 ==================== + +/** 自选证券项 */ +export interface WatchlistItem { + code: string; + name: string; + isIndex: boolean; +} + +/** QuoteTile 组件 Props */ +export interface QuoteTileProps { + code: string; + name: string; + quote: Partial; + isIndex?: boolean; + onRemove?: (code: string) => void; +} + +/** OrderBookPanel 组件 Props */ +export interface OrderBookPanelProps { + bidPrices?: number[]; + bidVolumes?: number[]; + askPrices?: number[]; + askVolumes?: number[]; + prevClose?: number; + upperLimit?: number; + lowerLimit?: number; + defaultLevels?: number; +} + +/** MiniTimelineChart 组件 Props */ +export interface MiniTimelineChartProps { + code: string; + isIndex?: boolean; + prevClose?: number; + currentPrice?: number; + height?: number; +} + +/** 分时数据点 */ +export interface TimelineDataPoint { + time: string; + price: number; +} + +/** useRealtimeQuote Hook 返回值 */ +export interface UseRealtimeQuoteReturn { + quotes: QuotesMap; + connected: ConnectionStatus; + subscribe: (code: string) => void; + unsubscribe: (code: string) => void; +}