update pay ui

This commit is contained in:
2025-12-10 11:19:02 +08:00
parent e501ac3819
commit da44dcd522
12 changed files with 1229 additions and 820 deletions

View File

@@ -5,16 +5,19 @@
import React, { useEffect, useRef, useState, useMemo } from 'react'; import React, { useEffect, useRef, useState, useMemo } from 'react';
import { Box, Spinner, Center, Text } from '@chakra-ui/react'; import { Box, Spinner, Center, Text } from '@chakra-ui/react';
import * as echarts from 'echarts'; import * as echarts from 'echarts';
import type { ECharts, EChartsOption } from 'echarts';
import type { MiniTimelineChartProps, TimelineDataPoint } from '../types';
/** /**
* X * X
* A股交易时间9:30-11:30, 13:00-15:00 * A股交易时间9:30-11:30, 13:00-15:00
*/ */
const generateTimeTicks = () => { const generateTimeTicks = (): string[] => {
const ticks = []; const ticks: string[] = [];
// 上午 // 上午
for (let h = 9; h <= 11; h++) { 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; if (h === 11 && m > 30) break;
ticks.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`); ticks.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`);
} }
@@ -31,33 +34,42 @@ const generateTimeTicks = () => {
const TIME_TICKS = 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 * 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<MiniTimelineChartProps> = ({
code, code,
isIndex = false, isIndex = false,
prevClose, prevClose,
currentPrice, currentPrice,
height = 120, height = 120,
}) => { }) => {
const chartRef = useRef(null); const chartRef = useRef<HTMLDivElement>(null);
const chartInstance = useRef(null); const chartInstance = useRef<ECharts | null>(null);
const [timelineData, setTimelineData] = useState([]); const [timelineData, setTimelineData] = useState<TimelineDataPoint[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState<string | null>(null);
// 获取分钟数据 // 获取分钟数据
useEffect(() => { useEffect(() => {
if (!code) return; if (!code) return;
const fetchData = async () => { const fetchData = async (): Promise<void> => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -67,13 +79,13 @@ const MiniTimelineChart = ({
: `/api/stock/${code}/kline?type=minute`; : `/api/stock/${code}/kline?type=minute`;
const response = await fetch(apiPath); const response = await fetch(apiPath);
const result = await response.json(); const result: KLineApiResponse = await response.json();
if (result.success !== false && result.data) { if (result.success !== false && result.data) {
// 格式化数据 // 格式化数据
const formatted = result.data.map(item => ({ const formatted: TimelineDataPoint[] = result.data.map(item => ({
time: item.time || item.timestamp, time: item.time || item.timestamp || '',
price: item.close || item.price, price: item.close || item.price || 0,
})); }));
setTimelineData(formatted); setTimelineData(formatted);
} else { } else {
@@ -93,10 +105,11 @@ const MiniTimelineChart = ({
const hours = now.getHours(); const hours = now.getHours();
const minutes = now.getMinutes(); const minutes = now.getMinutes();
const currentMinutes = hours * 60 + minutes; const currentMinutes = hours * 60 + minutes;
const isTrading = (currentMinutes >= 570 && currentMinutes <= 690) || const isTrading =
(currentMinutes >= 570 && currentMinutes <= 690) ||
(currentMinutes >= 780 && currentMinutes <= 900); (currentMinutes >= 780 && currentMinutes <= 900);
let intervalId; let intervalId: NodeJS.Timeout | undefined;
if (isTrading) { if (isTrading) {
intervalId = setInterval(fetchData, 60000); // 1分钟刷新 intervalId = setInterval(fetchData, 60000); // 1分钟刷新
} }
@@ -107,7 +120,7 @@ const MiniTimelineChart = ({
}, [code, isIndex]); }, [code, isIndex]);
// 合并实时价格到数据中 // 合并实时价格到数据中
const chartData = useMemo(() => { const chartData = useMemo((): TimelineDataPoint[] => {
if (!timelineData.length) return []; if (!timelineData.length) return [];
const data = [...timelineData]; const data = [...timelineData];
@@ -154,7 +167,7 @@ const MiniTimelineChart = ({
const lastPrice = values[values.length - 1] || baseLine; const lastPrice = values[values.length - 1] || baseLine;
const isUp = lastPrice >= baseLine; const isUp = lastPrice >= baseLine;
const option = { const option: EChartsOption = {
grid: { grid: {
top: 5, top: 5,
right: 5, right: 5,
@@ -236,7 +249,7 @@ const MiniTimelineChart = ({
// 窗口 resize 处理 // 窗口 resize 处理
useEffect(() => { useEffect(() => {
const handleResize = () => { const handleResize = (): void => {
chartInstance.current?.resize(); chartInstance.current?.resize();
}; };
window.addEventListener('resize', handleResize); window.addEventListener('resize', handleResize);

View File

@@ -18,12 +18,18 @@ import {
Badge, Badge,
} from '@chakra-ui/react'; } 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 || volume === 0) return '-';
if (volume >= 10000) { if (volume >= 10000) {
return `${(volume / 10000).toFixed(0)}`; 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) { if (!price || price === 0) {
return { text: '-', color: 'gray.400' }; return { text: '-', color: 'gray.400' };
} }
@@ -60,10 +63,29 @@ const formatPrice = (price, prevClose) => {
return { text, color: 'gray.600' }; 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<OrderRowProps> = ({
label,
price,
volume,
prevClose,
isBid,
maxVolume,
isLimitPrice,
}) => {
const bgColor = useColorModeValue( const bgColor = useColorModeValue(
isBid ? 'red.50' : 'green.50', isBid ? 'red.50' : 'green.50',
isBid ? 'rgba(239, 68, 68, 0.1)' : 'rgba(34, 197, 94, 0.1)' 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 * 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<OrderBookPanelProps> = ({
bidPrices = [], bidPrices = [],
bidVolumes = [], bidVolumes = [],
askPrices = [], askPrices = [],
@@ -151,6 +164,7 @@ const OrderBookPanel = ({
}) => { }) => {
const borderColor = useColorModeValue('gray.200', 'gray.700'); const borderColor = useColorModeValue('gray.200', 'gray.700');
const buttonBg = useColorModeValue('gray.100', 'gray.700'); const buttonBg = useColorModeValue('gray.100', 'gray.700');
const bgColor = useColorModeValue('white', '#1a1a1a');
// 可切换显示的档位数 // 可切换显示的档位数
const maxAvailableLevels = Math.max(bidPrices.length, askPrices.length, 1); 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 maxVolume = allVolumes.length > 0 ? Math.max(...allVolumes) : 0;
// 判断是否为涨跌停价 // 判断是否为涨跌停价
const isUpperLimit = (price) => upperLimit && Math.abs(price - upperLimit) < 0.001; const isUpperLimit = (price: number): boolean =>
const isLowerLimit = (price) => lowerLimit && Math.abs(price - lowerLimit) < 0.001; !!upperLimit && Math.abs(price - upperLimit) < 0.001;
const isLowerLimit = (price: number): boolean =>
!!lowerLimit && Math.abs(price - lowerLimit) < 0.001;
// 卖盘从卖N到卖1即价格从高到低 // 卖盘从卖N到卖1即价格从高到低
const askRows = []; const askRows: React.ReactNode[] = [];
for (let i = showLevels - 1; i >= 0; i--) { for (let i = showLevels - 1; i >= 0; i--) {
askRows.push( askRows.push(
<OrderRow <OrderRow
@@ -184,7 +200,7 @@ const OrderBookPanel = ({
} }
// 买盘从买1到买N即价格从高到低 // 买盘从买1到买N即价格从高到低
const bidRows = []; const bidRows: React.ReactNode[] = [];
for (let i = 0; i < showLevels; i++) { for (let i = 0; i < showLevels; i++) {
bidRows.push( bidRows.push(
<OrderRow <OrderRow
@@ -250,7 +266,7 @@ const OrderBookPanel = ({
transform="translateY(-50%)" transform="translateY(-50%)"
fontSize="2xs" fontSize="2xs"
color="gray.400" color="gray.400"
bg={useColorModeValue('white', '#1a1a1a')} bg={bgColor}
px={1} px={1}
> >
{prevClose.toFixed(2)} {prevClose.toFixed(2)}

View File

@@ -13,18 +13,18 @@ import {
useColorModeValue, useColorModeValue,
Collapse, Collapse,
Badge, Badge,
Flex,
Spacer,
} from '@chakra-ui/react'; } 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 { useNavigate } from 'react-router-dom';
import MiniTimelineChart from './MiniTimelineChart'; import MiniTimelineChart from './MiniTimelineChart';
import OrderBookPanel from './OrderBookPanel'; import OrderBookPanel from './OrderBookPanel';
import type { QuoteTileProps, QuoteData } from '../types';
/** /**
* *
*/ */
const formatPrice = (price) => { const formatPrice = (price?: number): string => {
if (!price || isNaN(price)) return '-'; if (!price || isNaN(price)) return '-';
return price.toFixed(2); 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%'; if (!pct || isNaN(pct)) return '0.00%';
const sign = pct > 0 ? '+' : ''; const sign = pct > 0 ? '+' : '';
return `${sign}${pct.toFixed(2)}%`; 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 '-'; if (!change || isNaN(change)) return '-';
const sign = change > 0 ? '+' : ''; const sign = change > 0 ? '+' : '';
return `${sign}${change.toFixed(2)}`; 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 || isNaN(amount)) return '-';
if (amount >= 100000000) { if (amount >= 100000000) {
return `${(amount / 100000000).toFixed(2)}亿`; return `${(amount / 100000000).toFixed(2)}亿`;
@@ -63,14 +63,8 @@ const formatAmount = (amount) => {
/** /**
* QuoteTile * 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<QuoteTileProps> = ({
code, code,
name, name,
quote = {}, quote = {},
@@ -80,6 +74,9 @@ const QuoteTile = ({
const navigate = useNavigate(); const navigate = useNavigate();
const [expanded, setExpanded] = useState(true); const [expanded, setExpanded] = useState(true);
// 类型断言,确保类型安全
const quoteData = quote as Partial<QuoteData>;
// 颜色主题 // 颜色主题
const cardBg = useColorModeValue('white', '#1a1a1a'); const cardBg = useColorModeValue('white', '#1a1a1a');
const borderColor = useColorModeValue('gray.200', '#333'); const borderColor = useColorModeValue('gray.200', '#333');
@@ -88,24 +85,36 @@ const QuoteTile = ({
const subTextColor = useColorModeValue('gray.500', 'gray.400'); const subTextColor = useColorModeValue('gray.500', 'gray.400');
// 涨跌色 // 涨跌色
const { price, prevClose, change, changePct, amount } = quote; const { price, prevClose, change, changePct, amount } = quoteData;
const priceColor = useColorModeValue( const priceColor = useColorModeValue(
!prevClose || price === prevClose ? 'gray.800' : !prevClose || price === prevClose
price > prevClose ? 'red.500' : 'green.500', ? 'gray.800'
!prevClose || price === prevClose ? 'gray.200' : : price && price > prevClose
price > prevClose ? 'red.400' : 'green.400' ? 'red.500'
: 'green.500',
!prevClose || price === prevClose
? 'gray.200'
: price && price > prevClose
? 'red.400'
: 'green.400'
); );
// 涨跌幅背景色 // 涨跌幅背景色
const changeBgColor = useColorModeValue( const changeBgColor = useColorModeValue(
!changePct || changePct === 0 ? 'gray.100' : !changePct || changePct === 0
changePct > 0 ? 'red.100' : 'green.100', ? 'gray.100'
!changePct || changePct === 0 ? 'gray.700' : : changePct > 0
changePct > 0 ? 'rgba(239, 68, 68, 0.2)' : 'rgba(34, 197, 94, 0.2)' ? '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) { if (isIndex) {
// 指数暂无详情页 // 指数暂无详情页
return; return;
@@ -113,6 +122,15 @@ const QuoteTile = ({
navigate(`/company?scode=${code}`); 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 ( return (
<Box <Box
bg={cardBg} bg={cardBg}
@@ -225,7 +243,7 @@ const QuoteTile = ({
</HStack> </HStack>
<HStack> <HStack>
<Text>:</Text> <Text>:</Text>
<Text color={textColor}>{formatPrice(quote.open)}</Text> <Text color={textColor}>{formatPrice(openPrice)}</Text>
</HStack> </HStack>
<HStack> <HStack>
<Text>:</Text> <Text>:</Text>
@@ -248,16 +266,16 @@ const QuoteTile = ({
{!isIndex && ( {!isIndex && (
<Box> <Box>
<Text fontSize="xs" color={subTextColor} mb={1}> <Text fontSize="xs" color={subTextColor} mb={1}>
{quote.bidPrices?.length > 5 ? '(10档)' : '(5档)'} {bidPrices.length > 5 ? '(10档)' : '(5档)'}
</Text> </Text>
<OrderBookPanel <OrderBookPanel
bidPrices={quote.bidPrices || []} bidPrices={bidPrices}
bidVolumes={quote.bidVolumes || []} bidVolumes={bidVolumes}
askPrices={quote.askPrices || []} askPrices={askPrices}
askVolumes={quote.askVolumes || []} askVolumes={askVolumes}
prevClose={prevClose} prevClose={prevClose}
upperLimit={quote.upperLimit} upperLimit={upperLimit}
lowerLimit={quote.lowerLimit} lowerLimit={lowerLimit}
/> />
</Box> </Box>
)} )}

View File

@@ -1,3 +1,7 @@
/**
*
*/
export { default as MiniTimelineChart } from './MiniTimelineChart'; export { default as MiniTimelineChart } from './MiniTimelineChart';
export { default as OrderBookPanel } from './OrderBookPanel'; export { default as OrderBookPanel } from './OrderBookPanel';
export { default as QuoteTile } from './QuoteTile'; export { default as QuoteTile } from './QuoteTile';

View File

@@ -0,0 +1,17 @@
/**
* WebSocket 配置常量
*/
import type { Exchange } from '../types';
/** WebSocket 服务地址 */
export const WS_CONFIG: Record<Exchange, string> = {
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;

View File

@@ -1 +0,0 @@
export { useRealtimeQuote } from './useRealtimeQuote';

View File

@@ -0,0 +1,7 @@
/**
* Hooks 导出文件
*/
export { useRealtimeQuote } from './useRealtimeQuote';
export * from './constants';
export * from './utils';

View File

@@ -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;

View File

@@ -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<string>,
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<string>,
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<string>,
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<QuotesMap>({});
const [connected, setConnected] = useState<ConnectionStatus>({ SSE: false, SZSE: false });
const wsRefs = useRef<Record<Exchange, WebSocket | null>>({ SSE: null, SZSE: null });
const heartbeatRefs = useRef<Record<Exchange, NodeJS.Timeout | null>>({ SSE: null, SZSE: null });
const reconnectRefs = useRef<Record<Exchange, NodeJS.Timeout | null>>({ SSE: null, SZSE: null });
const subscribedCodes = useRef<Record<Exchange, Set<string>>>({
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<string>();
const newSzseCodes = new Set<string>();
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;

View File

@@ -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;
};

View File

@@ -6,7 +6,7 @@
* 1. / * 1. /
* 2. WebSocket * 2. WebSocket
* 3. ClickHouse * 3. ClickHouse
* 4. * 4. 510
* 5. * 5.
*/ */
import React, { useState, useEffect, useCallback, useMemo } from 'react'; import React, { useState, useEffect, useCallback, useMemo } from 'react';
@@ -23,7 +23,6 @@ import {
InputLeftElement, InputLeftElement,
InputRightElement, InputRightElement,
IconButton, IconButton,
Button,
SimpleGrid, SimpleGrid,
Flex, Flex,
Spacer, Spacer,
@@ -41,7 +40,6 @@ import {
MenuButton, MenuButton,
MenuList, MenuList,
MenuItem, MenuItem,
Divider,
Tag, Tag,
TagLabel, TagLabel,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
@@ -53,24 +51,31 @@ import {
ChevronUpIcon, ChevronUpIcon,
SettingsIcon, SettingsIcon,
} from '@chakra-ui/icons'; } 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 { useRealtimeQuote } from './hooks';
import { QuoteTile } from './components'; import QuoteTile from './components/QuoteTile';
import { logger } from '@utils/logger'; import { logger } from '@utils/logger';
import type { WatchlistItem, ConnectionStatus } from './types';
// 本地存储 key // 本地存储 key
const STORAGE_KEY = 'flexscreen_watchlist'; const STORAGE_KEY = 'flexscreen_watchlist';
// 默认自选列表 // 默认自选列表
const DEFAULT_WATCHLIST = [ const DEFAULT_WATCHLIST: WatchlistItem[] = [
{ code: '000001', name: '上证指数', isIndex: true }, { code: '000001', name: '上证指数', isIndex: true },
{ code: '399001', name: '深证成指', isIndex: true }, { code: '399001', name: '深证成指', isIndex: true },
{ code: '399006', name: '创业板指', isIndex: true }, { code: '399006', name: '创业板指', isIndex: true },
]; ];
// 热门推荐 // 热门推荐
const HOT_RECOMMENDATIONS = [ const HOT_RECOMMENDATIONS: WatchlistItem[] = [
{ code: '000001', name: '上证指数', isIndex: true }, { code: '000001', name: '上证指数', isIndex: true },
{ code: '399001', name: '深证成指', isIndex: true }, { code: '399001', name: '深证成指', isIndex: true },
{ code: '399006', name: '创业板指', isIndex: true }, { code: '399006', name: '创业板指', isIndex: true },
@@ -81,17 +86,38 @@ const HOT_RECOMMENDATIONS = [
{ code: '002594', name: '比亚迪', isIndex: false }, { 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 * FlexScreen
*/ */
const FlexScreen = () => { const FlexScreen: React.FC = () => {
const toast = useToast(); const toast = useToast();
// 自选列表 // 自选列表
const [watchlist, setWatchlist] = useState([]); const [watchlist, setWatchlist] = useState<WatchlistItem[]>([]);
// 搜索状态 // 搜索状态
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState([]); const [searchResults, setSearchResults] = useState<SearchResultItem[]>([]);
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
const [showResults, setShowResults] = useState(false); const [showResults, setShowResults] = useState(false);
// 面板状态 // 面板状态
@@ -118,7 +144,7 @@ const FlexScreen = () => {
try { try {
const saved = localStorage.getItem(STORAGE_KEY); const saved = localStorage.getItem(STORAGE_KEY);
if (saved) { if (saved) {
const parsed = JSON.parse(saved); const parsed = JSON.parse(saved) as WatchlistItem[];
if (Array.isArray(parsed) && parsed.length > 0) { if (Array.isArray(parsed) && parsed.length > 0) {
setWatchlist(parsed); setWatchlist(parsed);
return; return;
@@ -143,7 +169,7 @@ const FlexScreen = () => {
}, [watchlist]); }, [watchlist]);
// 搜索证券 // 搜索证券
const searchSecurities = useCallback(async (query) => { const searchSecurities = useCallback(async (query: string): Promise<void> => {
if (!query.trim()) { if (!query.trim()) {
setSearchResults([]); setSearchResults([]);
setShowResults(false); setShowResults(false);
@@ -153,7 +179,7 @@ const FlexScreen = () => {
setIsSearching(true); setIsSearching(true);
try { try {
const response = await fetch(`/api/stocks/search?q=${encodeURIComponent(query)}&limit=10`); 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) { if (data.success) {
setSearchResults(data.data || []); setSearchResults(data.data || []);
@@ -178,10 +204,12 @@ const FlexScreen = () => {
}, [searchQuery, searchSecurities]); }, [searchQuery, searchSecurities]);
// 添加证券 // 添加证券
const addSecurity = useCallback((security) => { const addSecurity = useCallback(
const code = security.stock_code || security.code; (security: SearchResultItem | WatchlistItem): void => {
const name = security.stock_name || security.name; const code = 'stock_code' in security ? security.stock_code : security.code;
const isIndex = security.isIndex || code.startsWith('000') || code.startsWith('399'); 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)) { if (watchlist.some(item => item.code === code)) {
@@ -207,15 +235,17 @@ const FlexScreen = () => {
// 清空搜索 // 清空搜索
setSearchQuery(''); setSearchQuery('');
setShowResults(false); setShowResults(false);
}, [watchlist, toast]); },
[watchlist, toast]
);
// 移除证券 // 移除证券
const removeSecurity = useCallback((code) => { const removeSecurity = useCallback((code: string): void => {
setWatchlist(prev => prev.filter(item => item.code !== code)); setWatchlist(prev => prev.filter(item => item.code !== code));
}, []); }, []);
// 清空自选列表 // 清空自选列表
const clearWatchlist = useCallback(() => { const clearWatchlist = useCallback((): void => {
setWatchlist([]); setWatchlist([]);
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
toast({ toast({
@@ -227,7 +257,7 @@ const FlexScreen = () => {
}, [toast]); }, [toast]);
// 重置为默认列表 // 重置为默认列表
const resetWatchlist = useCallback(() => { const resetWatchlist = useCallback((): void => {
setWatchlist(DEFAULT_WATCHLIST); setWatchlist(DEFAULT_WATCHLIST);
toast({ toast({
title: '已重置为默认列表', title: '已重置为默认列表',
@@ -239,7 +269,7 @@ const FlexScreen = () => {
// 连接状态指示 // 连接状态指示
const isAnyConnected = connected.SSE || connected.SZSE; const isAnyConnected = connected.SSE || connected.SZSE;
const connectionStatus = useMemo(() => { const connectionStatus = useMemo((): ConnectionStatusInfo => {
if (connected.SSE && connected.SZSE) { if (connected.SSE && connected.SZSE) {
return { color: 'green', text: '上交所/深交所 已连接' }; return { color: 'green', text: '上交所/深交所 已连接' };
} }
@@ -317,7 +347,7 @@ const FlexScreen = () => {
<Input <Input
placeholder="搜索股票/指数代码或名称..." placeholder="搜索股票/指数代码或名称..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={e => setSearchQuery(e.target.value)}
bg={searchBg} bg={searchBg}
borderRadius="lg" borderRadius="lg"
_focus={{ _focus={{
@@ -413,7 +443,7 @@ const FlexScreen = () => {
</Text> </Text>
<Flex flexWrap="wrap" gap={2}> <Flex flexWrap="wrap" gap={2}>
{HOT_RECOMMENDATIONS.map((item) => ( {HOT_RECOMMENDATIONS.map(item => (
<Tag <Tag
key={item.code} key={item.code}
size="md" size="md"
@@ -433,7 +463,7 @@ const FlexScreen = () => {
{/* 自选列表 */} {/* 自选列表 */}
{watchlist.length > 0 ? ( {watchlist.length > 0 ? (
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}> <SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
{watchlist.map((item) => ( {watchlist.map(item => (
<QuoteTile <QuoteTile
key={item.code} key={item.code}
code={item.code} code={item.code}
@@ -448,9 +478,7 @@ const FlexScreen = () => {
<Center py={8}> <Center py={8}>
<VStack spacing={3}> <VStack spacing={3}>
<Icon as={FaExclamationCircle} boxSize={10} color="gray.300" /> <Icon as={FaExclamationCircle} boxSize={10} color="gray.300" />
<Text color={subTextColor}> <Text color={subTextColor}></Text>
</Text>
</VStack> </VStack>
</Center> </Center>
)} )}

View File

@@ -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<string, SSEQuoteItem>;
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<QuoteData>;
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;
}