update pay ui
This commit is contained in:
@@ -3,11 +3,11 @@
|
||||
* 管理上交所和深交所 WebSocket 连接,获取实时行情数据
|
||||
*
|
||||
* 连接方式:
|
||||
* - 生产环境 (HTTPS): 通过 Nginx 代理使用 wss:// (如 wss://valuefrontier.cn/ws/sse)
|
||||
* - 生产环境 (HTTPS): 通过 Nginx 代理使用 wss://
|
||||
* - 开发环境 (HTTP): 直连 ws://
|
||||
*
|
||||
* 上交所 (SSE): 需主动订阅,提供五档行情
|
||||
* 深交所 (SZSE): 自动推送,提供十档行情
|
||||
* 深交所 (SZSE): v4.0 API - 需主动订阅,提供十档行情
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
@@ -34,8 +34,6 @@ import type {
|
||||
|
||||
/**
|
||||
* 处理上交所消息
|
||||
* 注意:上交所返回的 code 不带后缀,但通过 msg.type 区分 'stock' 和 'index'
|
||||
* 存储时使用带后缀的完整代码作为 key(如 000001.SH)
|
||||
*/
|
||||
const handleSSEMessage = (
|
||||
msg: SSEMessage,
|
||||
@@ -49,10 +47,8 @@ const handleSSEMessage = (
|
||||
const data = msg.data || {};
|
||||
const updated: QuotesMap = { ...prevQuotes };
|
||||
let hasUpdate = false;
|
||||
const isIndex = msg.type === 'index';
|
||||
|
||||
Object.entries(data).forEach(([code, quote]: [string, SSEQuoteItem]) => {
|
||||
// 生成带后缀的完整代码(上交所统一用 .SH)
|
||||
const fullCode = code.includes('.') ? code : `${code}.SH`;
|
||||
|
||||
if (subscribedCodes.has(code) || subscribedCodes.has(fullCode)) {
|
||||
@@ -83,9 +79,7 @@ const handleSSEMessage = (
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理深交所实时消息
|
||||
* 注意:深交所返回的 security_id 可能带后缀也可能不带
|
||||
* 存储时统一使用带后缀的完整代码作为 key(如 000001.SZ)
|
||||
* 处理深交所实时消息 (realtime)
|
||||
*/
|
||||
const handleSZSERealtimeMessage = (
|
||||
msg: SZSERealtimeMessage,
|
||||
@@ -94,7 +88,6 @@ const handleSZSERealtimeMessage = (
|
||||
): QuotesMap | null => {
|
||||
const { category, data, timestamp } = msg;
|
||||
const rawCode = data.security_id;
|
||||
// 生成带后缀的完整代码(深交所统一用 .SZ)
|
||||
const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`;
|
||||
|
||||
if (!subscribedCodes.has(rawCode) && !subscribedCodes.has(fullCode)) {
|
||||
@@ -106,48 +99,8 @@ const handleSZSERealtimeMessage = (
|
||||
switch (category) {
|
||||
case 'stock': {
|
||||
const stockData = data as SZSEStockData;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rawData = data as any; // 用于检查替代字段名
|
||||
|
||||
// 调试日志:检查深交所返回的盘口原始数据(临时使用 warn 级别方便调试)
|
||||
if (!stockData.bids || stockData.bids.length === 0) {
|
||||
logger.warn('FlexScreen', `SZSE股票数据无盘口 ${fullCode}`, {
|
||||
hasBids: !!stockData.bids,
|
||||
hasAsks: !!stockData.asks,
|
||||
bidsLength: stockData.bids?.length || 0,
|
||||
asksLength: stockData.asks?.length || 0,
|
||||
// 检查替代字段名
|
||||
hasBidPrices: !!rawData.bid_prices,
|
||||
hasAskPrices: !!rawData.ask_prices,
|
||||
dataKeys: Object.keys(stockData), // 查看服务端实际返回了哪些字段
|
||||
});
|
||||
}
|
||||
|
||||
// 优先使用 bids/asks 对象数组格式,如果不存在则尝试 bid_prices/ask_prices 分离数组格式
|
||||
let bidPrices: number[] = [];
|
||||
let bidVolumes: number[] = [];
|
||||
let askPrices: number[] = [];
|
||||
let askVolumes: number[] = [];
|
||||
|
||||
if (stockData.bids && stockData.bids.length > 0) {
|
||||
const extracted = extractOrderBook(stockData.bids);
|
||||
bidPrices = extracted.prices;
|
||||
bidVolumes = extracted.volumes;
|
||||
} else if (rawData.bid_prices && Array.isArray(rawData.bid_prices)) {
|
||||
// 替代格式:bid_prices 和 bid_volumes 分离
|
||||
bidPrices = rawData.bid_prices;
|
||||
bidVolumes = rawData.bid_volumes || [];
|
||||
}
|
||||
|
||||
if (stockData.asks && stockData.asks.length > 0) {
|
||||
const extracted = extractOrderBook(stockData.asks);
|
||||
askPrices = extracted.prices;
|
||||
askVolumes = extracted.volumes;
|
||||
} else if (rawData.ask_prices && Array.isArray(rawData.ask_prices)) {
|
||||
// 替代格式:ask_prices 和 ask_volumes 分离
|
||||
askPrices = rawData.ask_prices;
|
||||
askVolumes = rawData.ask_volumes || [];
|
||||
}
|
||||
const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(stockData.bids);
|
||||
const { prices: askPrices, volumes: askVolumes } = extractOrderBook(stockData.asks);
|
||||
|
||||
updated[fullCode] = {
|
||||
code: fullCode,
|
||||
@@ -292,8 +245,8 @@ const handleSZSERealtimeMessage = (
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理深交所快照消息
|
||||
* 存储时统一使用带后缀的完整代码作为 key
|
||||
* 处理深交所快照消息 (snapshot)
|
||||
* 订阅后首次返回的批量数据
|
||||
*/
|
||||
const handleSZSESnapshotMessage = (
|
||||
msg: SZSESnapshotMessage,
|
||||
@@ -310,43 +263,8 @@ const handleSZSESnapshotMessage = (
|
||||
|
||||
if (subscribedCodes.has(rawCode) || subscribedCodes.has(fullCode)) {
|
||||
hasUpdate = true;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rawData = s as any; // 用于检查替代字段名
|
||||
|
||||
// 调试日志:检查快照消息中的盘口数据(无盘口时警告)
|
||||
if (!s.bids || s.bids.length === 0) {
|
||||
logger.warn('FlexScreen', `SZSE快照股票数据无盘口 ${fullCode}`, {
|
||||
hasBids: !!s.bids,
|
||||
hasAsks: !!s.asks,
|
||||
hasBidPrices: !!rawData.bid_prices,
|
||||
hasAskPrices: !!rawData.ask_prices,
|
||||
dataKeys: Object.keys(s),
|
||||
});
|
||||
}
|
||||
|
||||
// 优先使用 bids/asks 对象数组格式,如果不存在则尝试 bid_prices/ask_prices 分离数组格式
|
||||
let bidPrices: number[] = [];
|
||||
let bidVolumes: number[] = [];
|
||||
let askPrices: number[] = [];
|
||||
let askVolumes: number[] = [];
|
||||
|
||||
if (s.bids && s.bids.length > 0) {
|
||||
const extracted = extractOrderBook(s.bids);
|
||||
bidPrices = extracted.prices;
|
||||
bidVolumes = extracted.volumes;
|
||||
} else if (rawData.bid_prices && Array.isArray(rawData.bid_prices)) {
|
||||
bidPrices = rawData.bid_prices;
|
||||
bidVolumes = rawData.bid_volumes || [];
|
||||
}
|
||||
|
||||
if (s.asks && s.asks.length > 0) {
|
||||
const extracted = extractOrderBook(s.asks);
|
||||
askPrices = extracted.prices;
|
||||
askVolumes = extracted.volumes;
|
||||
} else if (rawData.ask_prices && Array.isArray(rawData.ask_prices)) {
|
||||
askPrices = rawData.ask_prices;
|
||||
askVolumes = rawData.ask_volumes || [];
|
||||
}
|
||||
const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(s.bids);
|
||||
const { prices: askPrices, volumes: askVolumes } = extractOrderBook(s.asks);
|
||||
|
||||
updated[fullCode] = {
|
||||
code: fullCode,
|
||||
@@ -433,7 +351,7 @@ const handleSZSESnapshotMessage = (
|
||||
|
||||
/**
|
||||
* 实时行情 Hook
|
||||
* @param codes - 订阅的证券代码列表
|
||||
* @param codes - 订阅的证券代码列表(带后缀,如 000001.SZ)
|
||||
*/
|
||||
export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn => {
|
||||
const [quotes, setQuotes] = useState<QuotesMap>({});
|
||||
@@ -442,6 +360,11 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
||||
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 });
|
||||
// 深交所 WebSocket 就绪状态(收到 welcome 消息后才能订阅)
|
||||
const szseReadyRef = useRef<boolean>(false);
|
||||
// 待发送的深交所订阅队列(在 welcome 之前收到的订阅请求)
|
||||
const szsePendingSubscribeRef = useRef<string[]>([]);
|
||||
|
||||
const subscribedCodes = useRef<Record<Exchange, Set<string>>>({
|
||||
SSE: new Set(),
|
||||
SZSE: new Set(),
|
||||
@@ -459,50 +382,123 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
||||
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));
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL);
|
||||
}, [stopHeartbeat]);
|
||||
|
||||
/**
|
||||
* 发送深交所订阅请求
|
||||
* 格式:{ type: 'subscribe', securities: ['000001', '000002'] }
|
||||
*/
|
||||
const sendSZSESubscribe = useCallback((baseCodes: string[]) => {
|
||||
const ws = wsRefs.current.SZSE;
|
||||
if (ws && ws.readyState === WebSocket.OPEN && baseCodes.length > 0) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
securities: baseCodes,
|
||||
}));
|
||||
logger.info('FlexScreen', `SZSE 发送订阅请求`, { securities: baseCodes });
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 处理消息
|
||||
*/
|
||||
const handleMessage = useCallback((exchange: Exchange, msg: SSEMessage | SZSEMessage) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const anyMsg = msg as any;
|
||||
|
||||
// 心跳响应
|
||||
if (msg.type === 'pong') 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;
|
||||
});
|
||||
// 深交所消息处理
|
||||
switch (msg.type) {
|
||||
case 'welcome':
|
||||
// 收到欢迎消息,深交所 WebSocket 就绪
|
||||
logger.info('FlexScreen', 'SZSE WebSocket 就绪,可以订阅');
|
||||
szseReadyRef.current = true;
|
||||
|
||||
// 发送之前待处理的订阅请求
|
||||
if (szsePendingSubscribeRef.current.length > 0) {
|
||||
sendSZSESubscribe(szsePendingSubscribeRef.current);
|
||||
szsePendingSubscribeRef.current = [];
|
||||
} else {
|
||||
// 如果已有订阅的代码,立即发送订阅
|
||||
const currentCodes = Array.from(subscribedCodes.current.SZSE).map(c => normalizeCode(c));
|
||||
if (currentCodes.length > 0) {
|
||||
sendSZSESubscribe(currentCodes);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'subscribed':
|
||||
// 订阅成功确认
|
||||
logger.info('FlexScreen', 'SZSE 订阅成功', {
|
||||
securities: anyMsg.securities,
|
||||
categories: anyMsg.categories,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'unsubscribed':
|
||||
// 取消订阅确认
|
||||
logger.info('FlexScreen', 'SZSE 取消订阅成功');
|
||||
break;
|
||||
|
||||
case 'snapshot':
|
||||
// 快照消息(订阅后首次返回的批量数据)
|
||||
setQuotes(prev => {
|
||||
const result = handleSZSESnapshotMessage(
|
||||
msg as SZSESnapshotMessage,
|
||||
subscribedCodes.current.SZSE,
|
||||
prev
|
||||
);
|
||||
return result || prev;
|
||||
});
|
||||
break;
|
||||
|
||||
case 'realtime':
|
||||
// 实时行情推送
|
||||
setQuotes(prev => {
|
||||
const result = handleSZSERealtimeMessage(
|
||||
msg as SZSERealtimeMessage,
|
||||
subscribedCodes.current.SZSE,
|
||||
prev
|
||||
);
|
||||
return result || prev;
|
||||
});
|
||||
break;
|
||||
|
||||
case 'query_result':
|
||||
case 'query_batch_result':
|
||||
// 查询结果(目前不处理)
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
logger.error('FlexScreen', 'SZSE WebSocket 错误', { message: anyMsg.message });
|
||||
break;
|
||||
|
||||
default:
|
||||
// 未知消息类型
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}, [sendSZSESubscribe]);
|
||||
|
||||
/**
|
||||
* 创建 WebSocket 连接
|
||||
*/
|
||||
const createConnection = useCallback((exchange: Exchange) => {
|
||||
// 防御性检查:确保 HTTPS 页面不会意外连接 ws://(Mixed Content 安全错误)
|
||||
// 正常情况下 WS_CONFIG 会自动根据协议返回正确的 URL,这里是备用保护
|
||||
const isHttps = typeof window !== 'undefined' && window.location.protocol === 'https:';
|
||||
const wsUrl = WS_CONFIG[exchange];
|
||||
const isInsecureWs = wsUrl.startsWith('ws://');
|
||||
@@ -519,6 +515,11 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
||||
wsRefs.current[exchange]!.close();
|
||||
}
|
||||
|
||||
// 重置深交所就绪状态
|
||||
if (exchange === 'SZSE') {
|
||||
szseReadyRef.current = false;
|
||||
}
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRefs.current[exchange] = ws;
|
||||
@@ -528,7 +529,7 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
||||
setConnected(prev => ({ ...prev, [exchange]: true }));
|
||||
|
||||
if (exchange === 'SSE') {
|
||||
// subscribedCodes 存的是带后缀的完整代码,发送给 WS 需要去掉后缀
|
||||
// 上交所:连接后立即发送订阅
|
||||
const fullCodes = Array.from(subscribedCodes.current.SSE);
|
||||
const baseCodes = fullCodes.map(c => normalizeCode(c));
|
||||
if (baseCodes.length > 0) {
|
||||
@@ -539,6 +540,7 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
||||
}));
|
||||
}
|
||||
}
|
||||
// 深交所:等待 welcome 消息后再订阅
|
||||
|
||||
startHeartbeat(exchange);
|
||||
};
|
||||
@@ -561,7 +563,12 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
||||
setConnected(prev => ({ ...prev, [exchange]: false }));
|
||||
stopHeartbeat(exchange);
|
||||
|
||||
// 自动重连(仅在非 HTTPS + ws:// 场景下)
|
||||
// 重置深交所就绪状态
|
||||
if (exchange === 'SZSE') {
|
||||
szseReadyRef.current = false;
|
||||
}
|
||||
|
||||
// 自动重连
|
||||
if (!reconnectRefs.current[exchange] && subscribedCodes.current[exchange].size > 0) {
|
||||
reconnectRefs.current[exchange] = setTimeout(() => {
|
||||
reconnectRefs.current[exchange] = null;
|
||||
@@ -577,43 +584,67 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
||||
}
|
||||
}, [startHeartbeat, stopHeartbeat, handleMessage]);
|
||||
|
||||
/**
|
||||
* 订阅单个证券
|
||||
*/
|
||||
const subscribe = useCallback((code: string) => {
|
||||
const exchange = getExchange(code);
|
||||
// 确保使用带后缀的完整代码
|
||||
const fullCode = code.includes('.') ? code : `${code}.${exchange === 'SSE' ? 'SH' : 'SZ'}`;
|
||||
const baseCode = normalizeCode(code);
|
||||
|
||||
subscribedCodes.current[exchange].add(fullCode);
|
||||
|
||||
const ws = wsRefs.current[exchange];
|
||||
if (exchange === 'SSE' && ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
action: 'subscribe',
|
||||
channels: ['stock', 'index'],
|
||||
codes: [baseCode], // 发送给 WS 用不带后缀的代码
|
||||
}));
|
||||
|
||||
if (exchange === 'SSE') {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
action: 'subscribe',
|
||||
channels: ['stock', 'index'],
|
||||
codes: [baseCode],
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
// 深交所
|
||||
if (ws && ws.readyState === WebSocket.OPEN && szseReadyRef.current) {
|
||||
sendSZSESubscribe([baseCode]);
|
||||
} else if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
// WebSocket 已连接但未收到 welcome,加入待处理队列
|
||||
szsePendingSubscribeRef.current.push(baseCode);
|
||||
}
|
||||
}
|
||||
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
createConnection(exchange);
|
||||
}
|
||||
}, [createConnection]);
|
||||
}, [createConnection, sendSZSESubscribe]);
|
||||
|
||||
/**
|
||||
* 取消订阅
|
||||
*/
|
||||
const unsubscribe = useCallback((code: string) => {
|
||||
const exchange = getExchange(code);
|
||||
// 确保使用带后缀的完整代码
|
||||
const fullCode = code.includes('.') ? code : `${code}.${exchange === 'SSE' ? 'SH' : 'SZ'}`;
|
||||
const baseCode = normalizeCode(code);
|
||||
|
||||
subscribedCodes.current[exchange].delete(fullCode);
|
||||
|
||||
// 发送取消订阅请求(深交所)
|
||||
const ws = wsRefs.current[exchange];
|
||||
if (exchange === 'SZSE' && ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'unsubscribe',
|
||||
securities: [baseCode],
|
||||
}));
|
||||
}
|
||||
|
||||
setQuotes(prev => {
|
||||
const updated = { ...prev };
|
||||
delete updated[fullCode]; // 删除时也用带后缀的 key
|
||||
delete updated[fullCode];
|
||||
return updated;
|
||||
});
|
||||
|
||||
if (subscribedCodes.current[exchange].size === 0) {
|
||||
const ws = wsRefs.current[exchange];
|
||||
if (ws) {
|
||||
ws.close();
|
||||
wsRefs.current[exchange] = null;
|
||||
@@ -622,17 +653,14 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
||||
}, []);
|
||||
|
||||
// 初始化和 codes 变化处理
|
||||
// 注意:codes 现在是带后缀的完整代码(如 000001.SH)
|
||||
useEffect(() => {
|
||||
if (!codes || codes.length === 0) return;
|
||||
|
||||
// 使用带后缀的完整代码作为内部 key
|
||||
const newSseCodes = new Set<string>();
|
||||
const newSzseCodes = new Set<string>();
|
||||
|
||||
codes.forEach(code => {
|
||||
const exchange = getExchange(code);
|
||||
// 确保代码带后缀
|
||||
const fullCode = code.includes('.') ? code : `${code}.${exchange === 'SSE' ? 'SH' : 'SZ'}`;
|
||||
if (exchange === 'SSE') {
|
||||
newSseCodes.add(fullCode);
|
||||
@@ -644,7 +672,6 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
||||
// 更新上交所订阅
|
||||
const oldSseCodes = subscribedCodes.current.SSE;
|
||||
const sseToAdd = [...newSseCodes].filter(c => !oldSseCodes.has(c));
|
||||
// 发送给 WebSocket 的代码需要去掉后缀
|
||||
const sseToAddBase = sseToAdd.map(c => normalizeCode(c));
|
||||
|
||||
if (sseToAdd.length > 0 || newSseCodes.size !== oldSseCodes.size) {
|
||||
@@ -672,13 +699,23 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
||||
// 更新深交所订阅
|
||||
const oldSzseCodes = subscribedCodes.current.SZSE;
|
||||
const szseToAdd = [...newSzseCodes].filter(c => !oldSzseCodes.has(c));
|
||||
const szseToAddBase = szseToAdd.map(c => normalizeCode(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 (szseToAdd.length > 0) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN && szseReadyRef.current) {
|
||||
// WebSocket 已就绪,直接发送订阅
|
||||
sendSZSESubscribe(szseToAddBase);
|
||||
} else if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
// WebSocket 已连接但未就绪,加入待处理队列
|
||||
szsePendingSubscribeRef.current.push(...szseToAddBase);
|
||||
} else {
|
||||
// WebSocket 未连接,创建连接
|
||||
createConnection('SZSE');
|
||||
}
|
||||
}
|
||||
|
||||
if (newSzseCodes.size === 0 && ws) {
|
||||
@@ -687,7 +724,7 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
||||
}
|
||||
}
|
||||
|
||||
// 清理已取消订阅的 quotes(使用带后缀的完整代码)
|
||||
// 清理已取消订阅的 quotes
|
||||
const allNewCodes = new Set([...newSseCodes, ...newSzseCodes]);
|
||||
setQuotes(prev => {
|
||||
const updated: QuotesMap = {};
|
||||
@@ -698,7 +735,7 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}, [codes, createConnection]);
|
||||
}, [codes, createConnection, sendSZSESubscribe]);
|
||||
|
||||
// 清理
|
||||
useEffect(() => {
|
||||
|
||||
@@ -75,65 +75,21 @@ export const normalizeCode = (code: string): string => {
|
||||
return code.split('.')[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* 盘口数据可能的格式(根据不同的 WebSocket 服务端实现)
|
||||
*/
|
||||
type OrderBookInput =
|
||||
| OrderBookLevel[] // 格式1: [{price, volume}, ...]
|
||||
| Array<[number, number]> // 格式2: [[price, volume], ...]
|
||||
| { prices: number[]; volumes: number[] } // 格式3: {prices: [...], volumes: [...]}
|
||||
| undefined;
|
||||
|
||||
/**
|
||||
* 从深交所 bids/asks 数组提取价格和量数组
|
||||
* 支持多种可能的数据格式
|
||||
* @param orderBook - 盘口数据,支持多种格式
|
||||
* 格式:[{price, volume}, ...]
|
||||
* @param orderBook - 盘口数组
|
||||
* @returns { prices, volumes }
|
||||
*/
|
||||
export const extractOrderBook = (
|
||||
orderBook: OrderBookInput
|
||||
orderBook: OrderBookLevel[] | undefined
|
||||
): { prices: number[]; volumes: number[] } => {
|
||||
if (!orderBook) {
|
||||
if (!orderBook || !Array.isArray(orderBook) || orderBook.length === 0) {
|
||||
return { prices: [], volumes: [] };
|
||||
}
|
||||
|
||||
// 格式3: 已经是 {prices, volumes} 结构
|
||||
if (!Array.isArray(orderBook) && 'prices' in orderBook && 'volumes' in orderBook) {
|
||||
return {
|
||||
prices: orderBook.prices || [],
|
||||
volumes: orderBook.volumes || [],
|
||||
};
|
||||
}
|
||||
|
||||
// 必须是数组才能继续
|
||||
if (!Array.isArray(orderBook) || orderBook.length === 0) {
|
||||
return { prices: [], volumes: [] };
|
||||
}
|
||||
|
||||
const firstItem = orderBook[0];
|
||||
|
||||
// 格式2: [[price, volume], ...]
|
||||
if (Array.isArray(firstItem)) {
|
||||
const prices = orderBook.map((item: unknown) => {
|
||||
const arr = item as [number, number];
|
||||
return arr[0] || 0;
|
||||
});
|
||||
const volumes = orderBook.map((item: unknown) => {
|
||||
const arr = item as [number, number];
|
||||
return arr[1] || 0;
|
||||
});
|
||||
return { prices, volumes };
|
||||
}
|
||||
|
||||
// 格式1: [{price, volume}, ...] (标准格式)
|
||||
if (typeof firstItem === 'object' && firstItem !== null) {
|
||||
const typedBook = orderBook as OrderBookLevel[];
|
||||
const prices = typedBook.map(item => item.price || 0);
|
||||
const volumes = typedBook.map(item => item.volume || 0);
|
||||
return { prices, volumes };
|
||||
}
|
||||
|
||||
return { prices: [], volumes: [] };
|
||||
const prices = orderBook.map(item => item.price || 0);
|
||||
const volumes = orderBook.map(item => item.volume || 0);
|
||||
return { prices, volumes };
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -265,8 +265,51 @@ export interface SZSESnapshotMessage {
|
||||
};
|
||||
}
|
||||
|
||||
/** 深交所欢迎消息 */
|
||||
export interface SZSEWelcomeMessage {
|
||||
type: 'welcome';
|
||||
message: string;
|
||||
timestamp: string;
|
||||
usage?: Record<string, unknown>;
|
||||
categories?: string[];
|
||||
}
|
||||
|
||||
/** 深交所订阅确认消息 */
|
||||
export interface SZSESubscribedMessage {
|
||||
type: 'subscribed';
|
||||
securities?: string[];
|
||||
categories?: string[];
|
||||
all?: boolean;
|
||||
incremental_only?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/** 深交所取消订阅确认消息 */
|
||||
export interface SZSEUnsubscribedMessage {
|
||||
type: 'unsubscribed';
|
||||
securities?: string[];
|
||||
categories?: string[];
|
||||
remaining_securities?: string[];
|
||||
remaining_categories?: string[];
|
||||
}
|
||||
|
||||
/** 深交所错误消息 */
|
||||
export interface SZSEErrorMessage {
|
||||
type: 'error';
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** 深交所消息类型 */
|
||||
export type SZSEMessage = SZSERealtimeMessage | SZSESnapshotMessage | { type: 'pong' };
|
||||
export type SZSEMessage =
|
||||
| SZSERealtimeMessage
|
||||
| SZSESnapshotMessage
|
||||
| SZSEWelcomeMessage
|
||||
| SZSESubscribedMessage
|
||||
| SZSEUnsubscribedMessage
|
||||
| SZSEErrorMessage
|
||||
| { type: 'pong'; timestamp?: string }
|
||||
| { type: 'query_result'; security_id: string; found: boolean; data: unknown }
|
||||
| { type: 'query_batch_result'; count: number; found: number; data: Record<string, unknown> };
|
||||
|
||||
// ==================== 组件 Props 类型 ====================
|
||||
|
||||
|
||||
Reference in New Issue
Block a user