update pay ui
This commit is contained in:
@@ -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<MiniTimelineChartProps> = ({
|
||||
code,
|
||||
isIndex = false,
|
||||
prevClose,
|
||||
currentPrice,
|
||||
height = 120,
|
||||
}) => {
|
||||
const chartRef = useRef(null);
|
||||
const chartInstance = useRef(null);
|
||||
const [timelineData, setTimelineData] = useState([]);
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
const chartInstance = useRef<ECharts | null>(null);
|
||||
const [timelineData, setTimelineData] = useState<TimelineDataPoint[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 获取分钟数据
|
||||
useEffect(() => {
|
||||
if (!code) return;
|
||||
|
||||
const fetchData = async () => {
|
||||
const fetchData = async (): Promise<void> => {
|
||||
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) ||
|
||||
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);
|
||||
@@ -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<OrderRowProps> = ({
|
||||
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<OrderBookPanelProps> = ({
|
||||
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(
|
||||
<OrderRow
|
||||
@@ -184,7 +200,7 @@ const OrderBookPanel = ({
|
||||
}
|
||||
|
||||
// 买盘(从买1到买N,即价格从高到低)
|
||||
const bidRows = [];
|
||||
const bidRows: React.ReactNode[] = [];
|
||||
for (let i = 0; i < showLevels; i++) {
|
||||
bidRows.push(
|
||||
<OrderRow
|
||||
@@ -250,7 +266,7 @@ const OrderBookPanel = ({
|
||||
transform="translateY(-50%)"
|
||||
fontSize="2xs"
|
||||
color="gray.400"
|
||||
bg={useColorModeValue('white', '#1a1a1a')}
|
||||
bg={bgColor}
|
||||
px={1}
|
||||
>
|
||||
昨收 {prevClose.toFixed(2)}
|
||||
@@ -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<QuoteTileProps> = ({
|
||||
code,
|
||||
name,
|
||||
quote = {},
|
||||
@@ -80,6 +74,9 @@ const QuoteTile = ({
|
||||
const navigate = useNavigate();
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
// 类型断言,确保类型安全
|
||||
const quoteData = quote as Partial<QuoteData>;
|
||||
|
||||
// 颜色主题
|
||||
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 (
|
||||
<Box
|
||||
bg={cardBg}
|
||||
@@ -225,7 +243,7 @@ const QuoteTile = ({
|
||||
</HStack>
|
||||
<HStack>
|
||||
<Text>今开:</Text>
|
||||
<Text color={textColor}>{formatPrice(quote.open)}</Text>
|
||||
<Text color={textColor}>{formatPrice(openPrice)}</Text>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<Text>成交额:</Text>
|
||||
@@ -248,16 +266,16 @@ const QuoteTile = ({
|
||||
{!isIndex && (
|
||||
<Box>
|
||||
<Text fontSize="xs" color={subTextColor} mb={1}>
|
||||
盘口 {quote.bidPrices?.length > 5 ? '(10档)' : '(5档)'}
|
||||
盘口 {bidPrices.length > 5 ? '(10档)' : '(5档)'}
|
||||
</Text>
|
||||
<OrderBookPanel
|
||||
bidPrices={quote.bidPrices || []}
|
||||
bidVolumes={quote.bidVolumes || []}
|
||||
askPrices={quote.askPrices || []}
|
||||
askVolumes={quote.askVolumes || []}
|
||||
bidPrices={bidPrices}
|
||||
bidVolumes={bidVolumes}
|
||||
askPrices={askPrices}
|
||||
askVolumes={askVolumes}
|
||||
prevClose={prevClose}
|
||||
upperLimit={quote.upperLimit}
|
||||
lowerLimit={quote.lowerLimit}
|
||||
upperLimit={upperLimit}
|
||||
lowerLimit={lowerLimit}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 组件导出文件
|
||||
*/
|
||||
|
||||
export { default as MiniTimelineChart } from './MiniTimelineChart';
|
||||
export { default as OrderBookPanel } from './OrderBookPanel';
|
||||
export { default as QuoteTile } from './QuoteTile';
|
||||
@@ -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;
|
||||
@@ -1 +0,0 @@
|
||||
export { useRealtimeQuote } from './useRealtimeQuote';
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Hooks 导出文件
|
||||
*/
|
||||
|
||||
export { useRealtimeQuote } from './useRealtimeQuote';
|
||||
export * from './constants';
|
||||
export * from './utils';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
83
src/views/StockOverview/components/FlexScreen/hooks/utils.ts
Normal file
83
src/views/StockOverview/components/FlexScreen/hooks/utils.ts
Normal 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;
|
||||
};
|
||||
@@ -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<WatchlistItem[]>([]);
|
||||
// 搜索状态
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
const [searchResults, setSearchResults] = useState<SearchResultItem[]>([]);
|
||||
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<void> => {
|
||||
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,10 +204,12 @@ 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)) {
|
||||
@@ -207,15 +235,17 @@ const FlexScreen = () => {
|
||||
// 清空搜索
|
||||
setSearchQuery('');
|
||||
setShowResults(false);
|
||||
}, [watchlist, toast]);
|
||||
},
|
||||
[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 = () => {
|
||||
<Input
|
||||
placeholder="搜索股票/指数代码或名称..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
bg={searchBg}
|
||||
borderRadius="lg"
|
||||
_focus={{
|
||||
@@ -413,7 +443,7 @@ const FlexScreen = () => {
|
||||
热门推荐(点击添加)
|
||||
</Text>
|
||||
<Flex flexWrap="wrap" gap={2}>
|
||||
{HOT_RECOMMENDATIONS.map((item) => (
|
||||
{HOT_RECOMMENDATIONS.map(item => (
|
||||
<Tag
|
||||
key={item.code}
|
||||
size="md"
|
||||
@@ -433,7 +463,7 @@ const FlexScreen = () => {
|
||||
{/* 自选列表 */}
|
||||
{watchlist.length > 0 ? (
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
|
||||
{watchlist.map((item) => (
|
||||
{watchlist.map(item => (
|
||||
<QuoteTile
|
||||
key={item.code}
|
||||
code={item.code}
|
||||
@@ -448,9 +478,7 @@ const FlexScreen = () => {
|
||||
<Center py={8}>
|
||||
<VStack spacing={3}>
|
||||
<Icon as={FaExclamationCircle} boxSize={10} color="gray.300" />
|
||||
<Text color={subTextColor}>
|
||||
自选列表为空,请搜索添加证券
|
||||
</Text>
|
||||
<Text color={subTextColor}>自选列表为空,请搜索添加证券</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
322
src/views/StockOverview/components/FlexScreen/types.ts
Normal file
322
src/views/StockOverview/components/FlexScreen/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user