update pay ui

This commit is contained in:
2025-12-10 14:40:35 +08:00
parent a01532ce65
commit 3adefc6225
5 changed files with 294 additions and 208 deletions

View File

@@ -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(() => {

View File

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

View File

@@ -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 类型 ====================