Merge branch 'feature_bugfix/251201_py_h5_ui' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/251201_py_h5_ui
* 'feature_bugfix/251201_py_h5_ui' of https://git.valuefrontier.cn/vf/vf_react: update pay ui update pay ui update pay ui update pay ui update pay ui
This commit is contained in:
@@ -307,7 +307,13 @@ export const conceptHandlers = [
|
|||||||
const count = Math.min(limit, stockPool.length);
|
const count = Math.min(limit, stockPool.length);
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const stock = stockPool[i];
|
const stock = stockPool[i];
|
||||||
const suffix = stock.code.startsWith('6') ? '.SH' : '.SZ';
|
// 根据股票代码判断交易所后缀
|
||||||
|
let suffix = '.SZ';
|
||||||
|
if (stock.code.startsWith('6')) {
|
||||||
|
suffix = '.SH';
|
||||||
|
} else if (stock.code.startsWith('8') || stock.code.startsWith('9') || stock.code.startsWith('4')) {
|
||||||
|
suffix = '.BJ';
|
||||||
|
}
|
||||||
stocks.push({
|
stocks.push({
|
||||||
stock_code: `${stock.code}${suffix}`,
|
stock_code: `${stock.code}${suffix}`,
|
||||||
code: `${stock.code}${suffix}`,
|
code: `${stock.code}${suffix}`,
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ const formatStockCode = (code) => {
|
|||||||
|
|
||||||
// 根据股票代码规则添加后缀
|
// 根据股票代码规则添加后缀
|
||||||
// 6开头 -> 上海 .SH
|
// 6开头 -> 上海 .SH
|
||||||
// 0、3开头 -> 深圳 .SZ
|
|
||||||
// 688开头 -> 科创板(上海).SH
|
// 688开头 -> 科创板(上海).SH
|
||||||
// 8开头(北交所)-> .BJ(暂不处理,大部分场景不需要)
|
// 0、3开头 -> 深圳 .SZ
|
||||||
|
// 8、9、4开头 -> 北交所 .BJ
|
||||||
const firstChar = code.charAt(0);
|
const firstChar = code.charAt(0);
|
||||||
const prefix = code.substring(0, 3);
|
const prefix = code.substring(0, 3);
|
||||||
|
|
||||||
@@ -27,6 +27,9 @@ const formatStockCode = (code) => {
|
|||||||
return `${code}.SH`;
|
return `${code}.SH`;
|
||||||
} else if (firstChar === '0' || firstChar === '3') {
|
} else if (firstChar === '0' || firstChar === '3') {
|
||||||
return `${code}.SZ`;
|
return `${code}.SZ`;
|
||||||
|
} else if (firstChar === '8' || firstChar === '9' || firstChar === '4') {
|
||||||
|
// 北交所股票
|
||||||
|
return `${code}.BJ`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认返回原代码(可能是指数或其他)
|
// 默认返回原代码(可能是指数或其他)
|
||||||
|
|||||||
@@ -285,8 +285,9 @@ export default function InvestmentCalendarChakra() {
|
|||||||
stockCode = `${stockCode}.SH`;
|
stockCode = `${stockCode}.SH`;
|
||||||
} else if (stockCode.startsWith('0') || stockCode.startsWith('3')) {
|
} else if (stockCode.startsWith('0') || stockCode.startsWith('3')) {
|
||||||
stockCode = `${stockCode}.SZ`;
|
stockCode = `${stockCode}.SZ`;
|
||||||
} else if (stockCode.startsWith('688')) {
|
} else if (stockCode.startsWith('8') || stockCode.startsWith('9') || stockCode.startsWith('4')) {
|
||||||
stockCode = `${stockCode}.SH`;
|
// 北交所股票
|
||||||
|
stockCode = `${stockCode}.BJ`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,13 +65,20 @@ const MiniTimelineChart: React.FC<MiniTimelineChartProps> = ({
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 是否首次加载
|
||||||
|
const isFirstLoad = useRef(true);
|
||||||
|
// 用 ref 追踪是否有数据(避免闭包问题)
|
||||||
|
const hasDataRef = useRef(false);
|
||||||
|
|
||||||
// 获取分钟数据
|
// 获取分钟数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!code) return;
|
if (!code) return;
|
||||||
|
|
||||||
const fetchData = async (): Promise<void> => {
|
const fetchData = async (): Promise<void> => {
|
||||||
|
// 只在首次加载时显示 loading 状态
|
||||||
|
if (isFirstLoad.current) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const apiPath = isIndex
|
const apiPath = isIndex
|
||||||
@@ -81,23 +88,40 @@ const MiniTimelineChart: React.FC<MiniTimelineChartProps> = ({
|
|||||||
const response = await fetch(apiPath);
|
const response = await fetch(apiPath);
|
||||||
const result: KLineApiResponse = await response.json();
|
const result: KLineApiResponse = await response.json();
|
||||||
|
|
||||||
if (result.success !== false && result.data) {
|
if (result.success !== false && result.data && result.data.length > 0) {
|
||||||
// 格式化数据
|
// 格式化数据
|
||||||
const formatted: TimelineDataPoint[] = result.data.map(item => ({
|
const formatted: TimelineDataPoint[] = result.data.map(item => ({
|
||||||
time: item.time || item.timestamp || '',
|
time: item.time || item.timestamp || '',
|
||||||
price: item.close || item.price || 0,
|
price: item.close || item.price || 0,
|
||||||
}));
|
}));
|
||||||
setTimelineData(formatted);
|
setTimelineData(formatted);
|
||||||
|
hasDataRef.current = true;
|
||||||
|
setError(null); // 清除之前的错误
|
||||||
} else {
|
} else {
|
||||||
|
// 只有在没有原有数据时才设置错误(保留原有数据)
|
||||||
|
if (!hasDataRef.current) {
|
||||||
setError(result.error || '暂无数据');
|
setError(result.error || '暂无数据');
|
||||||
}
|
}
|
||||||
|
// 有原有数据时,静默失败,保持显示原有数据
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// 只有在没有原有数据时才设置错误(保留原有数据)
|
||||||
|
if (!hasDataRef.current) {
|
||||||
setError('加载失败');
|
setError('加载失败');
|
||||||
|
}
|
||||||
|
// 有原有数据时,静默失败,保持显示原有数据
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
isFirstLoad.current = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 重置首次加载标记(code 变化时)
|
||||||
|
isFirstLoad.current = true;
|
||||||
|
hasDataRef.current = false;
|
||||||
|
setTimelineData([]); // 切换股票时清空数据
|
||||||
|
setError(null);
|
||||||
|
|
||||||
fetchData();
|
fetchData();
|
||||||
|
|
||||||
// 交易时间内每分钟刷新
|
// 交易时间内每分钟刷新
|
||||||
|
|||||||
@@ -7,15 +7,18 @@
|
|||||||
* - 开发环境 (HTTP): 直连 ws://
|
* - 开发环境 (HTTP): 直连 ws://
|
||||||
*
|
*
|
||||||
* 上交所 (SSE): 需主动订阅,提供五档行情
|
* 上交所 (SSE): 需主动订阅,提供五档行情
|
||||||
* 深交所 (SZSE): v4.0 API - 需主动订阅,提供十档行情
|
* 深交所 (SZSE): v4.0 API - 批量推送模式,提供五档行情
|
||||||
|
*
|
||||||
|
* API 文档: SZSE_WEBSOCKET_API.md
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { logger } from '@utils/logger';
|
import { logger } from '@utils/logger';
|
||||||
import { WS_CONFIG, HEARTBEAT_INTERVAL, RECONNECT_INTERVAL } from './constants';
|
import { WS_CONFIG, HEARTBEAT_INTERVAL, RECONNECT_INTERVAL } from './constants';
|
||||||
import { getExchange, normalizeCode, extractOrderBook, calcChangePct } from './utils';
|
import { getExchange, normalizeCode, calcChangePct } from './utils';
|
||||||
import type {
|
import type {
|
||||||
Exchange,
|
Exchange,
|
||||||
|
SZSEChannel,
|
||||||
ConnectionStatus,
|
ConnectionStatus,
|
||||||
QuotesMap,
|
QuotesMap,
|
||||||
QuoteData,
|
QuoteData,
|
||||||
@@ -28,10 +31,12 @@ import type {
|
|||||||
SZSEIndexData,
|
SZSEIndexData,
|
||||||
SZSEBondData,
|
SZSEBondData,
|
||||||
SZSEHKStockData,
|
SZSEHKStockData,
|
||||||
SZSEAfterhoursData,
|
|
||||||
UseRealtimeQuoteReturn,
|
UseRealtimeQuoteReturn,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
|
/** 最大重连次数 */
|
||||||
|
const MAX_RECONNECT_ATTEMPTS = 5;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理上交所消息
|
* 处理上交所消息
|
||||||
*/
|
*/
|
||||||
@@ -79,54 +84,16 @@ const handleSSEMessage = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从深交所数据中提取盘口价格和量
|
* 处理深交所批量行情消息 (新 API 格式)
|
||||||
* 新 API 格式:直接使用 bid_prices/bid_volumes/ask_prices/ask_volumes 数组
|
* 格式:{ type: 'stock'/'index'/'bond'/'hkstock', data: { code: quote, ... }, timestamp }
|
||||||
* 旧 API 格式:使用 bids/asks 对象数组
|
|
||||||
*/
|
*/
|
||||||
const extractSZSEOrderBook = (
|
const handleSZSERealtimeMessage = (
|
||||||
stockData: SZSEStockData
|
|
||||||
): { bidPrices: number[]; bidVolumes: number[]; askPrices: number[]; askVolumes: number[] } => {
|
|
||||||
// 新 API 格式:直接是数组
|
|
||||||
if (stockData.bid_prices && Array.isArray(stockData.bid_prices)) {
|
|
||||||
return {
|
|
||||||
bidPrices: stockData.bid_prices,
|
|
||||||
bidVolumes: stockData.bid_volumes || [],
|
|
||||||
askPrices: stockData.ask_prices || [],
|
|
||||||
askVolumes: stockData.ask_volumes || [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// 旧 API 格式:对象数组
|
|
||||||
const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(stockData.bids);
|
|
||||||
const { prices: askPrices, volumes: askVolumes } = extractOrderBook(stockData.asks);
|
|
||||||
return { bidPrices, bidVolumes, askPrices, askVolumes };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从深交所数据中提取价格字段(兼容新旧 API)
|
|
||||||
*/
|
|
||||||
const extractSZSEPrices = (stockData: SZSEStockData) => {
|
|
||||||
return {
|
|
||||||
prevClose: stockData.prev_close_px ?? stockData.prev_close ?? 0,
|
|
||||||
volume: stockData.total_volume_trade ?? stockData.volume ?? 0,
|
|
||||||
amount: stockData.total_value_trade ?? stockData.amount ?? 0,
|
|
||||||
upperLimit: stockData.upper_limit_px ?? stockData.upper_limit,
|
|
||||||
lowerLimit: stockData.lower_limit_px ?? stockData.lower_limit,
|
|
||||||
tradingPhase: stockData.trading_phase_code ?? stockData.trading_phase,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理深交所批量行情消息(新 API 格式,与 SSE 一致)
|
|
||||||
* 格式:{ type: 'stock', data: { '000001': {...}, '000002': {...} }, timestamp: '...' }
|
|
||||||
*/
|
|
||||||
const handleSZSEBatchMessage = (
|
|
||||||
msg: SZSERealtimeMessage,
|
msg: SZSERealtimeMessage,
|
||||||
subscribedCodes: Set<string>,
|
subscribedCodes: Set<string>,
|
||||||
prevQuotes: QuotesMap
|
prevQuotes: QuotesMap
|
||||||
): QuotesMap | null => {
|
): QuotesMap | null => {
|
||||||
const { data, timestamp } = msg;
|
const { type, data, timestamp } = msg;
|
||||||
|
|
||||||
// 新 API 格式:data 是 { code: quote, ... } 的字典
|
|
||||||
if (!data || typeof data !== 'object') {
|
if (!data || typeof data !== 'object') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -134,13 +101,10 @@ const handleSZSEBatchMessage = (
|
|||||||
const updated: QuotesMap = { ...prevQuotes };
|
const updated: QuotesMap = { ...prevQuotes };
|
||||||
let hasUpdate = false;
|
let hasUpdate = false;
|
||||||
|
|
||||||
// 遍历所有股票数据
|
|
||||||
Object.entries(data).forEach(([code, quote]) => {
|
Object.entries(data).forEach(([code, quote]) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
if (!quote || typeof quote !== 'object') return;
|
||||||
const stockData = quote as any;
|
|
||||||
if (!stockData || typeof stockData !== 'object') return;
|
|
||||||
|
|
||||||
const rawCode = stockData.security_id || code;
|
const rawCode = (quote as { security_id?: string }).security_id || code;
|
||||||
const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`;
|
const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`;
|
||||||
|
|
||||||
// 只处理已订阅的代码
|
// 只处理已订阅的代码
|
||||||
@@ -149,138 +113,71 @@ const handleSZSEBatchMessage = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
hasUpdate = true;
|
hasUpdate = true;
|
||||||
const { bidPrices, bidVolumes, askPrices, askVolumes } = extractSZSEOrderBook(stockData);
|
|
||||||
const { prevClose, volume, amount, upperLimit, lowerLimit, tradingPhase } = extractSZSEPrices(stockData);
|
|
||||||
|
|
||||||
updated[fullCode] = {
|
|
||||||
code: fullCode,
|
|
||||||
name: prevQuotes[fullCode]?.name || '',
|
|
||||||
price: stockData.last_px,
|
|
||||||
prevClose,
|
|
||||||
open: stockData.open_px,
|
|
||||||
high: stockData.high_px,
|
|
||||||
low: stockData.low_px,
|
|
||||||
volume,
|
|
||||||
amount,
|
|
||||||
numTrades: stockData.num_trades,
|
|
||||||
upperLimit,
|
|
||||||
lowerLimit,
|
|
||||||
change: stockData.last_px - prevClose,
|
|
||||||
changePct: calcChangePct(stockData.last_px, prevClose),
|
|
||||||
bidPrices,
|
|
||||||
bidVolumes,
|
|
||||||
askPrices,
|
|
||||||
askVolumes,
|
|
||||||
tradingPhase,
|
|
||||||
updateTime: stockData.update_time || timestamp,
|
|
||||||
exchange: 'SZSE',
|
|
||||||
} as QuoteData;
|
|
||||||
});
|
|
||||||
|
|
||||||
return hasUpdate ? updated : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理深交所实时消息 (兼容新旧 API)
|
|
||||||
* 新 API (批量模式): type='stock'/'bond'/'fund', data = { code: quote, ... }
|
|
||||||
* 旧 API (单条模式): type='realtime', category='stock', data = { security_id, ... }
|
|
||||||
*/
|
|
||||||
const handleSZSERealtimeMessage = (
|
|
||||||
msg: SZSERealtimeMessage,
|
|
||||||
subscribedCodes: Set<string>,
|
|
||||||
prevQuotes: QuotesMap
|
|
||||||
): QuotesMap | null => {
|
|
||||||
const { type, category, data, timestamp } = msg;
|
|
||||||
|
|
||||||
// 新 API 批量格式检测:data 是字典 { code: quote, ... },没有 security_id 在顶层
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const anyData = data as any;
|
|
||||||
const isBatchFormat = anyData && typeof anyData === 'object' && !anyData.security_id;
|
|
||||||
|
|
||||||
if (isBatchFormat && (type === 'stock' || type === 'bond' || type === 'fund')) {
|
|
||||||
return handleSZSEBatchMessage(msg, subscribedCodes, prevQuotes);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 旧 API 单条格式:data 直接是行情对象 { security_id, last_px, ... }
|
|
||||||
const rawCode = anyData?.security_id;
|
|
||||||
if (!rawCode) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`;
|
|
||||||
|
|
||||||
if (!subscribedCodes.has(rawCode) && !subscribedCodes.has(fullCode)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated: QuotesMap = { ...prevQuotes };
|
|
||||||
|
|
||||||
// 确定实际的类别:新 API 直接用 type,旧 API 用 category
|
|
||||||
const actualCategory = (type === 'realtime' ? category : type) as string;
|
|
||||||
|
|
||||||
switch (actualCategory) {
|
|
||||||
case 'stock': {
|
|
||||||
const stockData = data as SZSEStockData;
|
|
||||||
const { bidPrices, bidVolumes, askPrices, askVolumes } = extractSZSEOrderBook(stockData);
|
|
||||||
const { prevClose, volume, amount, upperLimit, lowerLimit, tradingPhase } = extractSZSEPrices(stockData);
|
|
||||||
|
|
||||||
updated[fullCode] = {
|
|
||||||
code: fullCode,
|
|
||||||
name: prevQuotes[fullCode]?.name || '',
|
|
||||||
price: stockData.last_px,
|
|
||||||
prevClose,
|
|
||||||
open: stockData.open_px,
|
|
||||||
high: stockData.high_px,
|
|
||||||
low: stockData.low_px,
|
|
||||||
volume,
|
|
||||||
amount,
|
|
||||||
numTrades: stockData.num_trades,
|
|
||||||
upperLimit,
|
|
||||||
lowerLimit,
|
|
||||||
change: stockData.last_px - prevClose,
|
|
||||||
changePct: calcChangePct(stockData.last_px, prevClose),
|
|
||||||
bidPrices,
|
|
||||||
bidVolumes,
|
|
||||||
askPrices,
|
|
||||||
askVolumes,
|
|
||||||
tradingPhase,
|
|
||||||
updateTime: stockData.update_time || timestamp,
|
|
||||||
exchange: 'SZSE',
|
|
||||||
} as QuoteData;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
case 'index': {
|
case 'index': {
|
||||||
const indexData = data as SZSEIndexData;
|
const indexData = quote as SZSEIndexData;
|
||||||
|
const prevClose = indexData.prev_close || 0;
|
||||||
|
const currentIndex = indexData.current_index || 0;
|
||||||
|
|
||||||
updated[fullCode] = {
|
updated[fullCode] = {
|
||||||
code: fullCode,
|
code: fullCode,
|
||||||
name: prevQuotes[fullCode]?.name || '',
|
name: prevQuotes[fullCode]?.name || '',
|
||||||
price: indexData.current_index,
|
price: currentIndex,
|
||||||
prevClose: indexData.prev_close,
|
prevClose,
|
||||||
open: indexData.open_index,
|
open: indexData.open_index || 0,
|
||||||
high: indexData.high_index,
|
high: indexData.high_index || 0,
|
||||||
low: indexData.low_index,
|
low: indexData.low_index || 0,
|
||||||
close: indexData.close_index,
|
close: indexData.close_index,
|
||||||
volume: indexData.volume,
|
volume: indexData.volume || 0,
|
||||||
amount: indexData.amount,
|
amount: indexData.amount || 0,
|
||||||
numTrades: indexData.num_trades,
|
numTrades: indexData.num_trades,
|
||||||
change: indexData.current_index - indexData.prev_close,
|
change: currentIndex - prevClose,
|
||||||
changePct: calcChangePct(indexData.current_index, indexData.prev_close),
|
changePct: calcChangePct(currentIndex, prevClose),
|
||||||
bidPrices: [],
|
bidPrices: [],
|
||||||
bidVolumes: [],
|
bidVolumes: [],
|
||||||
askPrices: [],
|
askPrices: [],
|
||||||
askVolumes: [],
|
askVolumes: [],
|
||||||
tradingPhase: indexData.trading_phase,
|
updateTime: indexData.update_time || timestamp,
|
||||||
updateTime: timestamp,
|
|
||||||
exchange: 'SZSE',
|
exchange: 'SZSE',
|
||||||
} as QuoteData;
|
} as QuoteData;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'bond':
|
case 'stock': {
|
||||||
case 'fund': {
|
const stockData = quote as SZSEStockData;
|
||||||
const bondData = data as SZSEBondData;
|
const prevClose = stockData.prev_close_px || 0;
|
||||||
const prevClose = (bondData as SZSEStockData).prev_close_px ?? bondData.prev_close ?? 0;
|
|
||||||
|
updated[fullCode] = {
|
||||||
|
code: fullCode,
|
||||||
|
name: prevQuotes[fullCode]?.name || '',
|
||||||
|
price: stockData.last_px,
|
||||||
|
prevClose,
|
||||||
|
open: stockData.open_px,
|
||||||
|
high: stockData.high_px,
|
||||||
|
low: stockData.low_px,
|
||||||
|
volume: stockData.total_volume_trade,
|
||||||
|
amount: stockData.total_value_trade,
|
||||||
|
numTrades: stockData.num_trades,
|
||||||
|
upperLimit: stockData.upper_limit_px,
|
||||||
|
lowerLimit: stockData.lower_limit_px,
|
||||||
|
change: stockData.last_px - prevClose,
|
||||||
|
changePct: calcChangePct(stockData.last_px, prevClose),
|
||||||
|
bidPrices: stockData.bid_prices || [],
|
||||||
|
bidVolumes: stockData.bid_volumes || [],
|
||||||
|
askPrices: stockData.ask_prices || [],
|
||||||
|
askVolumes: stockData.ask_volumes || [],
|
||||||
|
tradingPhase: stockData.trading_phase_code,
|
||||||
|
updateTime: stockData.update_time || timestamp,
|
||||||
|
exchange: 'SZSE',
|
||||||
|
} as QuoteData;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'bond': {
|
||||||
|
const bondData = quote as SZSEBondData;
|
||||||
|
const prevClose = bondData.prev_close || 0;
|
||||||
|
|
||||||
updated[fullCode] = {
|
updated[fullCode] = {
|
||||||
code: fullCode,
|
code: fullCode,
|
||||||
name: prevQuotes[fullCode]?.name || '',
|
name: prevQuotes[fullCode]?.name || '',
|
||||||
@@ -299,24 +196,23 @@ const handleSZSERealtimeMessage = (
|
|||||||
bidVolumes: [],
|
bidVolumes: [],
|
||||||
askPrices: [],
|
askPrices: [],
|
||||||
askVolumes: [],
|
askVolumes: [],
|
||||||
tradingPhase: bondData.trading_phase,
|
tradingPhase: bondData.trading_phase_code,
|
||||||
updateTime: timestamp,
|
updateTime: bondData.update_time || timestamp,
|
||||||
exchange: 'SZSE',
|
exchange: 'SZSE',
|
||||||
isBond: true,
|
isBond: true,
|
||||||
} as QuoteData;
|
} as QuoteData;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'hk_stock': {
|
case 'hkstock': {
|
||||||
const hkData = data as SZSEHKStockData;
|
const hkData = quote as SZSEHKStockData;
|
||||||
const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(hkData.bids);
|
const prevClose = hkData.prev_close || 0;
|
||||||
const { prices: askPrices, volumes: askVolumes } = extractOrderBook(hkData.asks);
|
|
||||||
|
|
||||||
updated[fullCode] = {
|
updated[fullCode] = {
|
||||||
code: fullCode,
|
code: fullCode,
|
||||||
name: prevQuotes[fullCode]?.name || '',
|
name: prevQuotes[fullCode]?.name || '',
|
||||||
price: hkData.last_px,
|
price: hkData.last_px,
|
||||||
prevClose: hkData.prev_close,
|
prevClose,
|
||||||
open: hkData.open_px,
|
open: hkData.open_px,
|
||||||
high: hkData.high_px,
|
high: hkData.high_px,
|
||||||
low: hkData.low_px,
|
low: hkData.low_px,
|
||||||
@@ -324,233 +220,77 @@ const handleSZSERealtimeMessage = (
|
|||||||
amount: hkData.amount,
|
amount: hkData.amount,
|
||||||
numTrades: hkData.num_trades,
|
numTrades: hkData.num_trades,
|
||||||
nominalPx: hkData.nominal_px,
|
nominalPx: hkData.nominal_px,
|
||||||
referencePx: hkData.reference_px,
|
change: hkData.last_px - prevClose,
|
||||||
change: hkData.last_px - hkData.prev_close,
|
changePct: calcChangePct(hkData.last_px, prevClose),
|
||||||
changePct: calcChangePct(hkData.last_px, hkData.prev_close),
|
bidPrices: hkData.bid_prices || [],
|
||||||
bidPrices,
|
bidVolumes: hkData.bid_volumes || [],
|
||||||
bidVolumes,
|
askPrices: hkData.ask_prices || [],
|
||||||
askPrices,
|
askVolumes: hkData.ask_volumes || [],
|
||||||
askVolumes,
|
tradingPhase: hkData.trading_phase_code,
|
||||||
tradingPhase: hkData.trading_phase,
|
updateTime: hkData.update_time || timestamp,
|
||||||
updateTime: timestamp,
|
|
||||||
exchange: 'SZSE',
|
exchange: 'SZSE',
|
||||||
isHK: true,
|
isHK: true,
|
||||||
} as QuoteData;
|
} as QuoteData;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'afterhours_block':
|
|
||||||
case 'afterhours_trading': {
|
|
||||||
const afterhoursData = data as SZSEAfterhoursData;
|
|
||||||
const existing = prevQuotes[fullCode];
|
|
||||||
const afterhoursInfo = {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 盘后数据的当前价格:优先使用 bid_px(买入价),否则使用 offer_px(卖出价)
|
|
||||||
const afterhoursPrice = afterhoursData.bid_px || afterhoursData.offer_px || 0;
|
|
||||||
const prevClose = afterhoursData.prev_close || existing?.prevClose || 0;
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
// 合并到现有数据,同时更新价格(如果盘后价格有效)
|
|
||||||
const newPrice = afterhoursPrice > 0 ? afterhoursPrice : existing.price;
|
|
||||||
updated[fullCode] = {
|
|
||||||
...existing,
|
|
||||||
price: newPrice,
|
|
||||||
change: newPrice - prevClose,
|
|
||||||
changePct: calcChangePct(newPrice, prevClose),
|
|
||||||
afterhours: afterhoursInfo,
|
|
||||||
updateTime: timestamp,
|
|
||||||
} as QuoteData;
|
|
||||||
} else {
|
|
||||||
// 盘后首次收到数据(刷新页面后),使用盘后价格创建行情结构
|
|
||||||
updated[fullCode] = {
|
|
||||||
code: fullCode,
|
|
||||||
name: '',
|
|
||||||
price: afterhoursPrice,
|
|
||||||
prevClose: prevClose,
|
|
||||||
open: 0,
|
|
||||||
high: 0,
|
|
||||||
low: 0,
|
|
||||||
volume: 0,
|
|
||||||
amount: 0,
|
|
||||||
change: afterhoursPrice - prevClose,
|
|
||||||
changePct: calcChangePct(afterhoursPrice, prevClose),
|
|
||||||
bidPrices: afterhoursPrice > 0 ? [afterhoursPrice] : [],
|
|
||||||
bidVolumes: [],
|
|
||||||
askPrices: afterhoursData.offer_px > 0 ? [afterhoursData.offer_px] : [],
|
|
||||||
askVolumes: [],
|
|
||||||
tradingPhase: afterhoursData.trading_phase,
|
|
||||||
afterhours: afterhoursInfo,
|
|
||||||
updateTime: timestamp,
|
|
||||||
exchange: 'SZSE',
|
|
||||||
} as QuoteData;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
default:
|
return hasUpdate ? updated : null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return updated;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理深交所快照消息 (snapshot)
|
* 处理深交所快照消息
|
||||||
* 新 API: data 是单个股票对象 { security_id, last_px, ... }
|
|
||||||
* 旧 API: data 是批量数据 { stocks: [...], indexes: [...], bonds: [...] }
|
|
||||||
*/
|
*/
|
||||||
const handleSZSESnapshotMessage = (
|
const handleSZSESnapshotMessage = (
|
||||||
msg: SZSESnapshotMessage,
|
msg: SZSESnapshotMessage,
|
||||||
subscribedCodes: Set<string>,
|
subscribedCodes: Set<string>,
|
||||||
prevQuotes: QuotesMap
|
prevQuotes: QuotesMap
|
||||||
): QuotesMap | null => {
|
): QuotesMap | null => {
|
||||||
const updated: QuotesMap = { ...prevQuotes };
|
const { data, timestamp } = msg;
|
||||||
let hasUpdate = false;
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
if (!data || typeof data !== 'object') {
|
||||||
const data = msg.data as any;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// 新 API 格式:data 直接是单个股票对象
|
|
||||||
if (data && data.security_id) {
|
|
||||||
const stockData = data as SZSEStockData;
|
const stockData = data as SZSEStockData;
|
||||||
const rawCode = stockData.security_id;
|
const rawCode = stockData.security_id;
|
||||||
|
if (!rawCode) return null;
|
||||||
|
|
||||||
const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`;
|
const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`;
|
||||||
|
|
||||||
if (subscribedCodes.has(rawCode) || subscribedCodes.has(fullCode)) {
|
if (!subscribedCodes.has(rawCode) && !subscribedCodes.has(fullCode)) {
|
||||||
const { bidPrices, bidVolumes, askPrices, askVolumes } = extractSZSEOrderBook(stockData);
|
return null;
|
||||||
const { prevClose, volume, amount, upperLimit, lowerLimit, tradingPhase } = extractSZSEPrices(stockData);
|
}
|
||||||
|
|
||||||
|
const prevClose = stockData.prev_close_px || 0;
|
||||||
|
|
||||||
|
const updated: QuotesMap = { ...prevQuotes };
|
||||||
updated[fullCode] = {
|
updated[fullCode] = {
|
||||||
code: fullCode,
|
code: fullCode,
|
||||||
name: '',
|
name: prevQuotes[fullCode]?.name || '',
|
||||||
price: stockData.last_px,
|
price: stockData.last_px,
|
||||||
prevClose,
|
prevClose,
|
||||||
open: stockData.open_px,
|
open: stockData.open_px,
|
||||||
high: stockData.high_px,
|
high: stockData.high_px,
|
||||||
low: stockData.low_px,
|
low: stockData.low_px,
|
||||||
volume,
|
volume: stockData.total_volume_trade,
|
||||||
amount,
|
amount: stockData.total_value_trade,
|
||||||
numTrades: stockData.num_trades,
|
numTrades: stockData.num_trades,
|
||||||
upperLimit,
|
upperLimit: stockData.upper_limit_px,
|
||||||
lowerLimit,
|
lowerLimit: stockData.lower_limit_px,
|
||||||
change: stockData.last_px - prevClose,
|
change: stockData.last_px - prevClose,
|
||||||
changePct: calcChangePct(stockData.last_px, prevClose),
|
changePct: calcChangePct(stockData.last_px, prevClose),
|
||||||
bidPrices,
|
bidPrices: stockData.bid_prices || [],
|
||||||
bidVolumes,
|
bidVolumes: stockData.bid_volumes || [],
|
||||||
askPrices,
|
askPrices: stockData.ask_prices || [],
|
||||||
askVolumes,
|
askVolumes: stockData.ask_volumes || [],
|
||||||
tradingPhase,
|
tradingPhase: stockData.trading_phase_code,
|
||||||
updateTime: stockData.update_time || msg.timestamp,
|
updateTime: stockData.update_time || timestamp,
|
||||||
exchange: 'SZSE',
|
exchange: 'SZSE',
|
||||||
} as QuoteData;
|
} as QuoteData;
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 旧 API 格式:批量数据
|
|
||||||
const { stocks = [], indexes = [], bonds = [] } = data || {};
|
|
||||||
|
|
||||||
stocks.forEach((s: SZSEStockData) => {
|
|
||||||
const rawCode = s.security_id;
|
|
||||||
const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`;
|
|
||||||
|
|
||||||
if (subscribedCodes.has(rawCode) || subscribedCodes.has(fullCode)) {
|
|
||||||
hasUpdate = true;
|
|
||||||
const { bidPrices, bidVolumes, askPrices, askVolumes } = extractSZSEOrderBook(s);
|
|
||||||
const { prevClose, volume, amount, upperLimit, lowerLimit, tradingPhase } = extractSZSEPrices(s);
|
|
||||||
|
|
||||||
updated[fullCode] = {
|
|
||||||
code: fullCode,
|
|
||||||
name: '',
|
|
||||||
price: s.last_px,
|
|
||||||
prevClose,
|
|
||||||
open: s.open_px,
|
|
||||||
high: s.high_px,
|
|
||||||
low: s.low_px,
|
|
||||||
volume,
|
|
||||||
amount,
|
|
||||||
numTrades: s.num_trades,
|
|
||||||
upperLimit,
|
|
||||||
lowerLimit,
|
|
||||||
change: s.last_px - prevClose,
|
|
||||||
changePct: calcChangePct(s.last_px, prevClose),
|
|
||||||
bidPrices,
|
|
||||||
bidVolumes,
|
|
||||||
askPrices,
|
|
||||||
askVolumes,
|
|
||||||
tradingPhase,
|
|
||||||
exchange: 'SZSE',
|
|
||||||
} as QuoteData;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
indexes.forEach((i: SZSEIndexData) => {
|
|
||||||
const rawCode = i.security_id;
|
|
||||||
const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`;
|
|
||||||
|
|
||||||
if (subscribedCodes.has(rawCode) || subscribedCodes.has(fullCode)) {
|
|
||||||
hasUpdate = true;
|
|
||||||
updated[fullCode] = {
|
|
||||||
code: fullCode,
|
|
||||||
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) => {
|
|
||||||
const rawCode = b.security_id;
|
|
||||||
const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`;
|
|
||||||
|
|
||||||
if (subscribedCodes.has(rawCode) || subscribedCodes.has(fullCode)) {
|
|
||||||
hasUpdate = true;
|
|
||||||
const prevClose = (b as SZSEStockData).prev_close_px ?? b.prev_close ?? 0;
|
|
||||||
updated[fullCode] = {
|
|
||||||
code: fullCode,
|
|
||||||
name: '',
|
|
||||||
price: b.last_px,
|
|
||||||
prevClose,
|
|
||||||
open: b.open_px,
|
|
||||||
high: b.high_px,
|
|
||||||
low: b.low_px,
|
|
||||||
volume: b.volume,
|
|
||||||
amount: b.amount,
|
|
||||||
change: b.last_px - prevClose,
|
|
||||||
changePct: calcChangePct(b.last_px, prevClose),
|
|
||||||
bidPrices: [],
|
|
||||||
bidVolumes: [],
|
|
||||||
askPrices: [],
|
|
||||||
askVolumes: [],
|
|
||||||
exchange: 'SZSE',
|
|
||||||
isBond: true,
|
|
||||||
} as QuoteData;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return hasUpdate ? updated : null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -564,13 +304,7 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
|||||||
const wsRefs = useRef<Record<Exchange, WebSocket | null>>({ SSE: null, SZSE: null });
|
const wsRefs = useRef<Record<Exchange, WebSocket | null>>({ SSE: null, SZSE: null });
|
||||||
const heartbeatRefs = useRef<Record<Exchange, NodeJS.Timeout | 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 reconnectRefs = useRef<Record<Exchange, NodeJS.Timeout | null>>({ SSE: null, SZSE: null });
|
||||||
// 重连计数器(避免无限重连刷屏)
|
|
||||||
const reconnectCountRef = useRef<Record<Exchange, number>>({ SSE: 0, SZSE: 0 });
|
const reconnectCountRef = useRef<Record<Exchange, number>>({ SSE: 0, SZSE: 0 });
|
||||||
const MAX_RECONNECT_ATTEMPTS = 5;
|
|
||||||
// 深交所 WebSocket 就绪状态(收到 welcome 消息后才能订阅)
|
|
||||||
const szseReadyRef = useRef<boolean>(false);
|
|
||||||
// 待发送的深交所订阅队列(在 welcome 之前收到的订阅请求)
|
|
||||||
const szsePendingSubscribeRef = useRef<string[]>([]);
|
|
||||||
|
|
||||||
const subscribedCodes = useRef<Record<Exchange, Set<string>>>({
|
const subscribedCodes = useRef<Record<Exchange, Set<string>>>({
|
||||||
SSE: new Set(),
|
SSE: new Set(),
|
||||||
@@ -589,25 +323,45 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
|||||||
heartbeatRefs.current[exchange] = setInterval(() => {
|
heartbeatRefs.current[exchange] = setInterval(() => {
|
||||||
const ws = wsRefs.current[exchange];
|
const ws = wsRefs.current[exchange];
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
// 上交所和深交所(新 API)都使用 action: 'ping'
|
|
||||||
ws.send(JSON.stringify({ action: 'ping' }));
|
ws.send(JSON.stringify({ action: 'ping' }));
|
||||||
}
|
}
|
||||||
}, HEARTBEAT_INTERVAL);
|
}, HEARTBEAT_INTERVAL);
|
||||||
}, [stopHeartbeat]);
|
}, [stopHeartbeat]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送深交所订阅请求(新 API 格式)
|
* 发送深交所订阅请求 (新 API 格式)
|
||||||
* 格式:{ action: 'subscribe', channels: ['stock'], codes: ['000001', '000002'] }
|
|
||||||
*/
|
*/
|
||||||
const sendSZSESubscribe = useCallback((baseCodes: string[]) => {
|
const sendSZSESubscribe = useCallback((baseCodes: string[]) => {
|
||||||
const ws = wsRefs.current.SZSE;
|
const ws = wsRefs.current.SZSE;
|
||||||
if (ws && ws.readyState === WebSocket.OPEN && baseCodes.length > 0) {
|
if (ws && ws.readyState === WebSocket.OPEN && baseCodes.length > 0) {
|
||||||
|
const channels: SZSEChannel[] = ['stock', 'index'];
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
action: 'subscribe',
|
action: 'subscribe',
|
||||||
channels: ['stock'], // 订阅股票频道
|
channels,
|
||||||
codes: baseCodes,
|
codes: baseCodes,
|
||||||
}));
|
}));
|
||||||
logger.info('FlexScreen', `SZSE 发送订阅请求`, { codes: baseCodes });
|
logger.info('FlexScreen', `SZSE 发送订阅请求`, { channels, codes: baseCodes });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取深交所订阅状态
|
||||||
|
*/
|
||||||
|
const getStatus = useCallback(() => {
|
||||||
|
const ws = wsRefs.current.SZSE;
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ action: 'status' }));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取深交所股票快照
|
||||||
|
*/
|
||||||
|
const getSnapshot = useCallback((code: string) => {
|
||||||
|
const ws = wsRefs.current.SZSE;
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
const baseCode = normalizeCode(code);
|
||||||
|
ws.send(JSON.stringify({ action: 'snapshot', code: baseCode }));
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -615,20 +369,19 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
|||||||
* 处理消息
|
* 处理消息
|
||||||
*/
|
*/
|
||||||
const handleMessage = useCallback((exchange: Exchange, msg: SSEMessage | SZSEMessage) => {
|
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 (msg.type === 'pong' || anyMsg.action === 'pong') return;
|
|
||||||
|
|
||||||
if (exchange === 'SSE') {
|
if (exchange === 'SSE') {
|
||||||
// 上交所消息处理
|
// 上交所消息处理
|
||||||
if (msg.type === 'subscribed') {
|
if (msg.type === 'subscribed') {
|
||||||
logger.info('FlexScreen', 'SSE 订阅成功', { channels: anyMsg.channels });
|
const sseMsg = msg as SSEMessage;
|
||||||
|
logger.info('FlexScreen', 'SSE 订阅成功', { channels: sseMsg.channels });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (msg.type === 'error') {
|
if (msg.type === 'error') {
|
||||||
logger.error('FlexScreen', 'SSE WebSocket 错误', { message: anyMsg.message });
|
const sseMsg = msg as SSEMessage;
|
||||||
|
logger.error('FlexScreen', 'SSE WebSocket 错误', { message: sseMsg.message });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 处理行情数据
|
// 处理行情数据
|
||||||
@@ -641,46 +394,36 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
|||||||
setQuotes(prev => ({ ...prev, ...result }));
|
setQuotes(prev => ({ ...prev, ...result }));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 深交所消息处理(支持新旧两种 API 格式)
|
// 深交所消息处理 (v4.0 API)
|
||||||
switch (msg.type) {
|
const szseMsg = msg as SZSEMessage;
|
||||||
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;
|
|
||||||
|
|
||||||
|
switch (szseMsg.type) {
|
||||||
case 'subscribed':
|
case 'subscribed':
|
||||||
// 订阅成功确认(兼容新旧格式)
|
|
||||||
logger.info('FlexScreen', 'SZSE 订阅成功', {
|
logger.info('FlexScreen', 'SZSE 订阅成功', {
|
||||||
channels: anyMsg.channels,
|
channels: szseMsg.channels,
|
||||||
codes: anyMsg.codes,
|
codes: szseMsg.codes,
|
||||||
securities: anyMsg.securities,
|
|
||||||
categories: anyMsg.categories,
|
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'unsubscribed':
|
case 'unsubscribed':
|
||||||
// 取消订阅确认
|
logger.info('FlexScreen', 'SZSE 取消订阅成功', {
|
||||||
logger.info('FlexScreen', 'SZSE 取消订阅成功');
|
channels: szseMsg.channels,
|
||||||
|
codes: szseMsg.codes,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'status':
|
||||||
|
logger.info('FlexScreen', 'SZSE 订阅状态', {
|
||||||
|
channels: szseMsg.channels,
|
||||||
|
codes: szseMsg.codes,
|
||||||
|
filter_active: szseMsg.filter_active,
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'snapshot':
|
case 'snapshot':
|
||||||
// 快照消息(订阅后首次返回的批量数据)
|
|
||||||
setQuotes(prev => {
|
setQuotes(prev => {
|
||||||
const result = handleSZSESnapshotMessage(
|
const result = handleSZSESnapshotMessage(
|
||||||
msg as SZSESnapshotMessage,
|
szseMsg as SZSESnapshotMessage,
|
||||||
subscribedCodes.current.SZSE,
|
subscribedCodes.current.SZSE,
|
||||||
prev
|
prev
|
||||||
);
|
);
|
||||||
@@ -688,25 +431,13 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'realtime':
|
|
||||||
// 旧 API:实时行情推送 (type='realtime' + category)
|
|
||||||
setQuotes(prev => {
|
|
||||||
const result = handleSZSERealtimeMessage(
|
|
||||||
msg as SZSERealtimeMessage,
|
|
||||||
subscribedCodes.current.SZSE,
|
|
||||||
prev
|
|
||||||
);
|
|
||||||
return result || prev;
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
|
|
||||||
// 新 API:直接使用 type='stock'/'bond'/'fund' 作为消息类型
|
|
||||||
case 'stock':
|
case 'stock':
|
||||||
|
case 'index':
|
||||||
case 'bond':
|
case 'bond':
|
||||||
case 'fund':
|
case 'hkstock':
|
||||||
setQuotes(prev => {
|
setQuotes(prev => {
|
||||||
const result = handleSZSERealtimeMessage(
|
const result = handleSZSERealtimeMessage(
|
||||||
msg as SZSERealtimeMessage,
|
szseMsg as SZSERealtimeMessage,
|
||||||
subscribedCodes.current.SZSE,
|
subscribedCodes.current.SZSE,
|
||||||
prev
|
prev
|
||||||
);
|
);
|
||||||
@@ -714,21 +445,20 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'query_result':
|
case 'codes_list':
|
||||||
case 'query_batch_result':
|
// 代码列表响应(调试用)
|
||||||
// 查询结果(目前不处理)
|
logger.info('FlexScreen', 'SZSE 可用代码列表', szseMsg);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'error':
|
case 'error':
|
||||||
logger.error('FlexScreen', 'SZSE WebSocket 错误', { message: anyMsg.message });
|
logger.error('FlexScreen', 'SZSE WebSocket 错误', { message: szseMsg.message });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// 未知消息类型
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [sendSZSESubscribe]);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建 WebSocket 连接
|
* 创建 WebSocket 连接
|
||||||
@@ -750,11 +480,6 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
|||||||
wsRefs.current[exchange]!.close();
|
wsRefs.current[exchange]!.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重置深交所就绪状态
|
|
||||||
if (exchange === 'SZSE') {
|
|
||||||
szseReadyRef.current = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
wsRefs.current[exchange] = ws;
|
wsRefs.current[exchange] = ws;
|
||||||
@@ -762,14 +487,21 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
|||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
logger.info('FlexScreen', `${exchange} WebSocket 已连接`, { url: wsUrl });
|
logger.info('FlexScreen', `${exchange} WebSocket 已连接`, { url: wsUrl });
|
||||||
setConnected(prev => ({ ...prev, [exchange]: true }));
|
setConnected(prev => ({ ...prev, [exchange]: true }));
|
||||||
// 连接成功,重置重连计数
|
|
||||||
reconnectCountRef.current[exchange] = 0;
|
reconnectCountRef.current[exchange] = 0;
|
||||||
|
|
||||||
if (exchange === 'SSE') {
|
// 连接后立即发送订阅
|
||||||
// 上交所:连接后立即发送订阅
|
const fullCodes = Array.from(subscribedCodes.current[exchange]);
|
||||||
const fullCodes = Array.from(subscribedCodes.current.SSE);
|
|
||||||
const baseCodes = fullCodes.map(c => normalizeCode(c));
|
const baseCodes = fullCodes.map(c => normalizeCode(c));
|
||||||
|
|
||||||
if (baseCodes.length > 0) {
|
if (baseCodes.length > 0) {
|
||||||
|
if (exchange === 'SSE') {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
action: 'subscribe',
|
||||||
|
channels: ['stock', 'index'],
|
||||||
|
codes: baseCodes,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// 深交所:新 API 格式,连接后直接订阅
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
action: 'subscribe',
|
action: 'subscribe',
|
||||||
channels: ['stock', 'index'],
|
channels: ['stock', 'index'],
|
||||||
@@ -777,7 +509,6 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 深交所:等待 welcome 消息后再订阅
|
|
||||||
|
|
||||||
startHeartbeat(exchange);
|
startHeartbeat(exchange);
|
||||||
};
|
};
|
||||||
@@ -791,10 +522,9 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = (error: Event) => {
|
ws.onerror = () => {
|
||||||
logger.error('FlexScreen', `${exchange} WebSocket 连接失败`, {
|
logger.error('FlexScreen', `${exchange} WebSocket 连接失败`, {
|
||||||
url: wsUrl,
|
url: wsUrl,
|
||||||
readyState: ws.readyState,
|
|
||||||
hint: '请检查:1) 后端服务是否启动 2) Nginx 代理是否配置正确',
|
hint: '请检查:1) 后端服务是否启动 2) Nginx 代理是否配置正确',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -804,12 +534,7 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
|||||||
setConnected(prev => ({ ...prev, [exchange]: false }));
|
setConnected(prev => ({ ...prev, [exchange]: false }));
|
||||||
stopHeartbeat(exchange);
|
stopHeartbeat(exchange);
|
||||||
|
|
||||||
// 重置深交所就绪状态
|
// 自动重连(指数退避)
|
||||||
if (exchange === 'SZSE') {
|
|
||||||
szseReadyRef.current = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 自动重连(有次数限制,避免刷屏)
|
|
||||||
const currentAttempts = reconnectCountRef.current[exchange];
|
const currentAttempts = reconnectCountRef.current[exchange];
|
||||||
if (
|
if (
|
||||||
!reconnectRefs.current[exchange] &&
|
!reconnectRefs.current[exchange] &&
|
||||||
@@ -817,7 +542,6 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
|||||||
currentAttempts < MAX_RECONNECT_ATTEMPTS
|
currentAttempts < MAX_RECONNECT_ATTEMPTS
|
||||||
) {
|
) {
|
||||||
reconnectCountRef.current[exchange] = currentAttempts + 1;
|
reconnectCountRef.current[exchange] = currentAttempts + 1;
|
||||||
// 指数退避:3秒、6秒、12秒、24秒、48秒
|
|
||||||
const delay = RECONNECT_INTERVAL * Math.pow(2, currentAttempts);
|
const delay = RECONNECT_INTERVAL * Math.pow(2, currentAttempts);
|
||||||
logger.info('FlexScreen', `${exchange} 将在 ${delay / 1000} 秒后重连 (${currentAttempts + 1}/${MAX_RECONNECT_ATTEMPTS})`);
|
logger.info('FlexScreen', `${exchange} 将在 ${delay / 1000} 秒后重连 (${currentAttempts + 1}/${MAX_RECONNECT_ATTEMPTS})`);
|
||||||
|
|
||||||
@@ -828,9 +552,7 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
|||||||
}
|
}
|
||||||
}, delay);
|
}, delay);
|
||||||
} else if (currentAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
} else if (currentAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||||
logger.warn('FlexScreen', `${exchange} 达到最大重连次数,停止重连。请检查 WebSocket 服务是否正常。`, {
|
logger.warn('FlexScreen', `${exchange} 达到最大重连次数,停止重连`, { url: wsUrl });
|
||||||
url: wsUrl,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -850,26 +572,17 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
|||||||
subscribedCodes.current[exchange].add(fullCode);
|
subscribedCodes.current[exchange].add(fullCode);
|
||||||
|
|
||||||
const ws = wsRefs.current[exchange];
|
const ws = wsRefs.current[exchange];
|
||||||
|
|
||||||
if (exchange === 'SSE') {
|
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
if (exchange === 'SSE') {
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
action: 'subscribe',
|
action: 'subscribe',
|
||||||
channels: ['stock', 'index'],
|
channels: ['stock', 'index'],
|
||||||
codes: [baseCode],
|
codes: [baseCode],
|
||||||
}));
|
}));
|
||||||
|
} else {
|
||||||
|
sendSZSESubscribe([baseCode]);
|
||||||
}
|
}
|
||||||
} else {
|
} 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(exchange);
|
||||||
}
|
}
|
||||||
}, [createConnection, sendSZSESubscribe]);
|
}, [createConnection, sendSZSESubscribe]);
|
||||||
@@ -884,9 +597,8 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
|||||||
|
|
||||||
subscribedCodes.current[exchange].delete(fullCode);
|
subscribedCodes.current[exchange].delete(fullCode);
|
||||||
|
|
||||||
// 发送取消订阅请求(深交所新 API 格式)
|
|
||||||
const ws = wsRefs.current[exchange];
|
const ws = wsRefs.current[exchange];
|
||||||
if (exchange === 'SZSE' && ws && ws.readyState === WebSocket.OPEN) {
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
action: 'unsubscribe',
|
action: 'unsubscribe',
|
||||||
codes: [baseCode],
|
codes: [baseCode],
|
||||||
@@ -899,12 +611,10 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
|||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (subscribedCodes.current[exchange].size === 0) {
|
if (subscribedCodes.current[exchange].size === 0 && ws) {
|
||||||
if (ws) {
|
|
||||||
ws.close();
|
ws.close();
|
||||||
wsRefs.current[exchange] = null;
|
wsRefs.current[exchange] = null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 初始化和 codes 变化处理
|
// 初始化和 codes 变化处理
|
||||||
@@ -961,14 +671,9 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
|||||||
const ws = wsRefs.current.SZSE;
|
const ws = wsRefs.current.SZSE;
|
||||||
|
|
||||||
if (szseToAdd.length > 0) {
|
if (szseToAdd.length > 0) {
|
||||||
if (ws && ws.readyState === WebSocket.OPEN && szseReadyRef.current) {
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
// WebSocket 已就绪,直接发送订阅
|
|
||||||
sendSZSESubscribe(szseToAddBase);
|
sendSZSESubscribe(szseToAddBase);
|
||||||
} else if (ws && ws.readyState === WebSocket.OPEN) {
|
|
||||||
// WebSocket 已连接但未就绪,加入待处理队列
|
|
||||||
szsePendingSubscribeRef.current.push(...szseToAddBase);
|
|
||||||
} else {
|
} else {
|
||||||
// WebSocket 未连接,创建连接
|
|
||||||
createConnection('SZSE');
|
createConnection('SZSE');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1008,7 +713,7 @@ export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn =
|
|||||||
};
|
};
|
||||||
}, [stopHeartbeat]);
|
}, [stopHeartbeat]);
|
||||||
|
|
||||||
return { quotes, connected, subscribe, unsubscribe };
|
return { quotes, connected, subscribe, unsubscribe, getStatus, getSnapshot };
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useRealtimeQuote;
|
export default useRealtimeQuote;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* 实时行情相关工具函数
|
* 实时行情相关工具函数
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Exchange, OrderBookLevel } from '../types';
|
import type { Exchange } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断证券代码属于哪个交易所
|
* 判断证券代码属于哪个交易所
|
||||||
@@ -75,23 +75,6 @@ export const normalizeCode = (code: string): string => {
|
|||||||
return code.split('.')[0];
|
return code.split('.')[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* 从深交所 bids/asks 数组提取价格和量数组
|
|
||||||
* 格式:[{price, volume}, ...]
|
|
||||||
* @param orderBook - 盘口数组
|
|
||||||
* @returns { prices, volumes }
|
|
||||||
*/
|
|
||||||
export const extractOrderBook = (
|
|
||||||
orderBook: OrderBookLevel[] | undefined
|
|
||||||
): { prices: number[]; volumes: number[] } => {
|
|
||||||
if (!orderBook || !Array.isArray(orderBook) || orderBook.length === 0) {
|
|
||||||
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 price - 当前价
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ const FlexScreen: React.FC = () => {
|
|||||||
const borderColor = 'rgba(255, 255, 255, 0.08)';
|
const borderColor = 'rgba(255, 255, 255, 0.08)';
|
||||||
const textColor = 'rgba(255, 255, 255, 0.95)';
|
const textColor = 'rgba(255, 255, 255, 0.95)';
|
||||||
const subTextColor = 'rgba(255, 255, 255, 0.6)';
|
const subTextColor = 'rgba(255, 255, 255, 0.6)';
|
||||||
const searchBg = 'rgba(255, 255, 255, 0.05)';
|
const searchBg = 'rgba(255, 255, 255, 0.12)'; // 调亮搜索框背景
|
||||||
const hoverBg = 'rgba(255, 255, 255, 0.08)';
|
const hoverBg = 'rgba(255, 255, 255, 0.08)';
|
||||||
const accentColor = '#8b5cf6';
|
const accentColor = '#8b5cf6';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* 灵活屏组件类型定义
|
* 灵活屏组件类型定义
|
||||||
|
* 基于深交所 WebSocket API v4.0 (SZSE_WEBSOCKET_API.md)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ==================== WebSocket 相关类型 ====================
|
// ==================== WebSocket 相关类型 ====================
|
||||||
@@ -7,18 +8,15 @@
|
|||||||
/** 交易所标识 */
|
/** 交易所标识 */
|
||||||
export type Exchange = 'SSE' | 'SZSE';
|
export type Exchange = 'SSE' | 'SZSE';
|
||||||
|
|
||||||
|
/** 深交所频道类型 */
|
||||||
|
export type SZSEChannel = 'stock' | 'index' | 'bond' | 'hkstock';
|
||||||
|
|
||||||
/** WebSocket 连接状态 */
|
/** WebSocket 连接状态 */
|
||||||
export interface ConnectionStatus {
|
export interface ConnectionStatus {
|
||||||
SSE: boolean;
|
SSE: boolean;
|
||||||
SZSE: boolean;
|
SZSE: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 盘口档位数据 */
|
|
||||||
export interface OrderBookLevel {
|
|
||||||
price: number;
|
|
||||||
volume: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 行情数据类型 ====================
|
// ==================== 行情数据类型 ====================
|
||||||
|
|
||||||
/** 盘后交易数据 */
|
/** 盘后交易数据 */
|
||||||
@@ -59,7 +57,7 @@ export interface StockQuoteData extends BaseQuoteData {
|
|||||||
askPrices: number[];
|
askPrices: number[];
|
||||||
askVolumes: number[];
|
askVolumes: number[];
|
||||||
tradingPhase?: string;
|
tradingPhase?: string;
|
||||||
afterhours?: AfterhoursData; // 盘后交易数据
|
afterhours?: AfterhoursData;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 指数行情数据 */
|
/** 指数行情数据 */
|
||||||
@@ -140,189 +138,180 @@ export interface SSEMessage {
|
|||||||
|
|
||||||
// ==================== 深交所 WebSocket 消息类型 ====================
|
// ==================== 深交所 WebSocket 消息类型 ====================
|
||||||
// API 文档: SZSE_WEBSOCKET_API.md
|
// API 文档: SZSE_WEBSOCKET_API.md
|
||||||
// 与上交所 API 保持一致的设计
|
|
||||||
|
|
||||||
/** 深交所数据类别(对应 channels) */
|
/**
|
||||||
export type SZSECategory = 'stock' | 'bond' | 'fund';
|
* 深交所股票行情数据 (消息类型 300111)
|
||||||
|
* 字段名与 API 文档保持一致
|
||||||
/** 深交所股票行情数据(新 API 格式) */
|
*/
|
||||||
export interface SZSEStockData {
|
export interface SZSEStockData {
|
||||||
security_id: string;
|
security_id: string;
|
||||||
md_stream_id?: string; // MDStreamID: 010
|
md_stream_id?: string;
|
||||||
orig_time?: number;
|
orig_time?: number;
|
||||||
channel_no?: number;
|
channel_no?: number;
|
||||||
trading_phase_code?: string; // 新字段名
|
trading_phase_code?: string;
|
||||||
trading_phase?: string; // 兼容旧字段名
|
prev_close_px: number;
|
||||||
prev_close_px: number; // 新字段名
|
|
||||||
prev_close?: number; // 兼容旧字段名
|
|
||||||
open_px: number;
|
open_px: number;
|
||||||
high_px: number;
|
high_px: number;
|
||||||
low_px: number;
|
low_px: number;
|
||||||
last_px: number;
|
last_px: number;
|
||||||
upper_limit_px?: number; // 新字段名
|
upper_limit_px?: number;
|
||||||
upper_limit?: number; // 兼容旧字段名
|
lower_limit_px?: number;
|
||||||
lower_limit_px?: number; // 新字段名
|
|
||||||
lower_limit?: number; // 兼容旧字段名
|
|
||||||
num_trades?: number;
|
num_trades?: number;
|
||||||
total_volume_trade?: number; // 新字段名 (成交量)
|
total_volume_trade: number;
|
||||||
total_value_trade?: number; // 新字段名 (成交额)
|
total_value_trade: number;
|
||||||
volume?: number; // 兼容旧字段名
|
bid_prices: number[];
|
||||||
amount?: number; // 兼容旧字段名
|
bid_volumes: number[];
|
||||||
// 新 API 格式:直接是数组
|
ask_prices: number[];
|
||||||
bid_prices?: number[];
|
ask_volumes: number[];
|
||||||
bid_volumes?: number[];
|
|
||||||
ask_prices?: number[];
|
|
||||||
ask_volumes?: number[];
|
|
||||||
// 兼容旧格式
|
|
||||||
bids?: OrderBookLevel[];
|
|
||||||
asks?: OrderBookLevel[];
|
|
||||||
update_time?: string;
|
update_time?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 深交所指数行情数据 */
|
/**
|
||||||
|
* 深交所指数行情数据 (消息类型 309011)
|
||||||
|
*/
|
||||||
export interface SZSEIndexData {
|
export interface SZSEIndexData {
|
||||||
security_id: string;
|
security_id: string;
|
||||||
orig_time?: number;
|
md_stream_id?: string;
|
||||||
channel_no?: number;
|
prev_close: number;
|
||||||
trading_phase?: string;
|
num_trades?: number;
|
||||||
|
volume: number;
|
||||||
|
amount: number;
|
||||||
current_index: number;
|
current_index: number;
|
||||||
open_index: number;
|
open_index: number;
|
||||||
high_index: number;
|
high_index: number;
|
||||||
low_index: number;
|
low_index: number;
|
||||||
close_index?: number;
|
close_index?: number;
|
||||||
prev_close: number;
|
update_time?: string;
|
||||||
volume: number;
|
|
||||||
amount: number;
|
|
||||||
num_trades?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 深交所债券行情数据 */
|
/**
|
||||||
|
* 深交所债券行情数据 (消息类型 300211)
|
||||||
|
*/
|
||||||
export interface SZSEBondData {
|
export interface SZSEBondData {
|
||||||
security_id: string;
|
security_id: string;
|
||||||
orig_time?: number;
|
md_stream_id?: string;
|
||||||
channel_no?: number;
|
trading_phase_code?: string;
|
||||||
trading_phase?: string;
|
prev_close: number;
|
||||||
last_px: number;
|
|
||||||
open_px: number;
|
open_px: number;
|
||||||
high_px: number;
|
high_px: number;
|
||||||
low_px: number;
|
low_px: number;
|
||||||
prev_close: number;
|
last_px: number;
|
||||||
weighted_avg_px?: number;
|
weighted_avg_px?: number;
|
||||||
|
num_trades?: number;
|
||||||
volume: number;
|
volume: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
num_trades?: number;
|
update_time?: string;
|
||||||
auction_volume?: number;
|
|
||||||
auction_amount?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 深交所港股行情数据 */
|
/**
|
||||||
|
* 深交所港股行情数据 (消息类型 306311)
|
||||||
|
*/
|
||||||
export interface SZSEHKStockData {
|
export interface SZSEHKStockData {
|
||||||
security_id: string;
|
security_id: string;
|
||||||
orig_time?: number;
|
md_stream_id?: string;
|
||||||
channel_no?: number;
|
trading_phase_code?: string;
|
||||||
trading_phase?: string;
|
prev_close: number;
|
||||||
last_px: number;
|
|
||||||
open_px: number;
|
open_px: number;
|
||||||
high_px: number;
|
high_px: number;
|
||||||
low_px: number;
|
low_px: number;
|
||||||
prev_close: number;
|
last_px: number;
|
||||||
nominal_px?: number;
|
nominal_px?: number; // 按盘价
|
||||||
reference_px?: number;
|
num_trades?: number;
|
||||||
volume: number;
|
volume: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
num_trades?: number;
|
bid_prices: number[];
|
||||||
vcm_start_time?: number;
|
bid_volumes: number[];
|
||||||
vcm_end_time?: number;
|
ask_prices: number[];
|
||||||
bids?: OrderBookLevel[];
|
ask_volumes: number[];
|
||||||
asks?: OrderBookLevel[];
|
update_time?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 深交所盘后交易数据 */
|
/**
|
||||||
export interface SZSEAfterhoursData {
|
* 深交所实时推送消息 (批量格式)
|
||||||
security_id: string;
|
* type 直接表示频道类型
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 深交所实时消息(新 API 格式:type 直接是 'stock' | 'bond' | 'fund') */
|
|
||||||
export interface SZSERealtimeMessage {
|
export interface SZSERealtimeMessage {
|
||||||
type: 'stock' | 'bond' | 'fund' | 'realtime'; // 新 API 直接用 type='stock' 等
|
type: 'stock' | 'index' | 'bond' | 'hkstock';
|
||||||
category?: SZSECategory; // 旧 API 使用 category
|
data: Record<string, SZSEStockData | SZSEIndexData | SZSEBondData | SZSEHKStockData>;
|
||||||
msg_type?: number;
|
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
data: SZSEStockData | SZSEIndexData | SZSEBondData | SZSEHKStockData | SZSEAfterhoursData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 深交所快照消息 */
|
/**
|
||||||
|
* 深交所快照响应消息
|
||||||
|
*/
|
||||||
export interface SZSESnapshotMessage {
|
export interface SZSESnapshotMessage {
|
||||||
type: 'snapshot';
|
type: 'snapshot';
|
||||||
|
data: SZSEStockData | SZSEIndexData | SZSEBondData | SZSEHKStockData;
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
data: SZSEStockData | {
|
|
||||||
// 兼容旧格式的批量快照
|
|
||||||
stocks?: SZSEStockData[];
|
|
||||||
indexes?: SZSEIndexData[];
|
|
||||||
bonds?: SZSEBondData[];
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 深交所欢迎消息 */
|
/**
|
||||||
export interface SZSEWelcomeMessage {
|
* 深交所订阅确认消息
|
||||||
type: 'welcome';
|
*/
|
||||||
message: string;
|
|
||||||
timestamp: string;
|
|
||||||
usage?: Record<string, unknown>;
|
|
||||||
categories?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 深交所订阅确认消息 */
|
|
||||||
export interface SZSESubscribedMessage {
|
export interface SZSESubscribedMessage {
|
||||||
type: 'subscribed';
|
type: 'subscribed';
|
||||||
channels?: string[]; // 新 API 格式
|
channels: SZSEChannel[];
|
||||||
codes?: string[]; // 新 API 格式
|
codes: string[];
|
||||||
securities?: string[]; // 兼容旧格式
|
|
||||||
categories?: string[]; // 兼容旧格式
|
|
||||||
all?: boolean;
|
|
||||||
incremental_only?: boolean;
|
|
||||||
message?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 深交所取消订阅确认消息 */
|
/**
|
||||||
|
* 深交所取消订阅确认消息
|
||||||
|
*/
|
||||||
export interface SZSEUnsubscribedMessage {
|
export interface SZSEUnsubscribedMessage {
|
||||||
type: 'unsubscribed';
|
type: 'unsubscribed';
|
||||||
channels?: string[]; // 新 API 格式
|
channels: SZSEChannel[];
|
||||||
codes?: string[]; // 新 API 格式
|
codes: string[];
|
||||||
securities?: string[]; // 兼容旧格式
|
|
||||||
categories?: string[]; // 兼容旧格式
|
|
||||||
remaining_securities?: string[];
|
|
||||||
remaining_categories?: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 深交所错误消息 */
|
/**
|
||||||
|
* 深交所订阅状态响应
|
||||||
|
*/
|
||||||
|
export interface SZSEStatusMessage {
|
||||||
|
type: 'status';
|
||||||
|
channels: SZSEChannel[];
|
||||||
|
codes: string[];
|
||||||
|
filter_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 深交所代码列表响应 (单频道)
|
||||||
|
*/
|
||||||
|
export interface SZSECodesListSingleMessage {
|
||||||
|
type: 'codes_list';
|
||||||
|
category: SZSEChannel;
|
||||||
|
codes: string[];
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 深交所代码列表响应 (全部频道)
|
||||||
|
*/
|
||||||
|
export interface SZSECodesListAllMessage {
|
||||||
|
type: 'codes_list';
|
||||||
|
data: Record<SZSEChannel, { codes: string[]; count: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 深交所错误消息
|
||||||
|
*/
|
||||||
export interface SZSEErrorMessage {
|
export interface SZSEErrorMessage {
|
||||||
type: 'error';
|
type: 'error';
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 深交所消息类型 */
|
/**
|
||||||
|
* 深交所消息联合类型
|
||||||
|
*/
|
||||||
export type SZSEMessage =
|
export type SZSEMessage =
|
||||||
| SZSERealtimeMessage
|
| SZSERealtimeMessage
|
||||||
| SZSESnapshotMessage
|
| SZSESnapshotMessage
|
||||||
| SZSEWelcomeMessage
|
|
||||||
| SZSESubscribedMessage
|
| SZSESubscribedMessage
|
||||||
| SZSEUnsubscribedMessage
|
| SZSEUnsubscribedMessage
|
||||||
|
| SZSEStatusMessage
|
||||||
|
| SZSECodesListSingleMessage
|
||||||
|
| SZSECodesListAllMessage
|
||||||
| SZSEErrorMessage
|
| SZSEErrorMessage
|
||||||
| { type: 'pong'; timestamp?: string }
|
| { type: 'pong' };
|
||||||
| { type: 'query_result'; security_id: string; found: boolean; data: unknown }
|
|
||||||
| { type: 'query_batch_result'; count: number; found: number; data: Record<string, unknown> };
|
|
||||||
|
|
||||||
// ==================== 组件 Props 类型 ====================
|
// ==================== 组件 Props 类型 ====================
|
||||||
|
|
||||||
@@ -375,4 +364,8 @@ export interface UseRealtimeQuoteReturn {
|
|||||||
connected: ConnectionStatus;
|
connected: ConnectionStatus;
|
||||||
subscribe: (code: string) => void;
|
subscribe: (code: string) => void;
|
||||||
unsubscribe: (code: string) => void;
|
unsubscribe: (code: string) => void;
|
||||||
|
/** 获取订阅状态 (仅深交所) */
|
||||||
|
getStatus: () => void;
|
||||||
|
/** 获取单只股票快照 (仅深交所) */
|
||||||
|
getSnapshot: (code: string) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,551 @@
|
|||||||
|
/**
|
||||||
|
* 异动详情右边栏抽屉组件
|
||||||
|
* 点击分时图上的异动标记后显示,展示该时间段的所有异动详情
|
||||||
|
*/
|
||||||
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerBody,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerOverlay,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerCloseButton,
|
||||||
|
Box,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
Badge,
|
||||||
|
Icon,
|
||||||
|
Collapse,
|
||||||
|
Spinner,
|
||||||
|
Tooltip,
|
||||||
|
Flex,
|
||||||
|
Popover,
|
||||||
|
PopoverTrigger,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverBody,
|
||||||
|
Portal,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { keyframes, css } from '@emotion/react';
|
||||||
|
import {
|
||||||
|
Clock,
|
||||||
|
Zap,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
BarChart3,
|
||||||
|
Flame,
|
||||||
|
Target,
|
||||||
|
Activity,
|
||||||
|
Rocket,
|
||||||
|
Waves,
|
||||||
|
Gauge,
|
||||||
|
Sparkles,
|
||||||
|
ExternalLink,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { colors, glassEffect } from '../../../theme/glassTheme';
|
||||||
|
import {
|
||||||
|
ALERT_TYPE_CONFIG,
|
||||||
|
getAlertTypeLabel,
|
||||||
|
getAlertTypeDescription,
|
||||||
|
getScoreColor,
|
||||||
|
formatScore,
|
||||||
|
} from '../utils/chartHelpers';
|
||||||
|
import MiniTimelineChart from '@components/Charts/Stock/MiniTimelineChart';
|
||||||
|
|
||||||
|
// 动画
|
||||||
|
const pulseGlow = keyframes`
|
||||||
|
0%, 100% { opacity: 0.6; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取异动类型图标
|
||||||
|
*/
|
||||||
|
const getAlertIcon = (alertType) => {
|
||||||
|
const iconMap = {
|
||||||
|
surge_up: TrendingUp,
|
||||||
|
surge: Zap,
|
||||||
|
surge_down: TrendingDown,
|
||||||
|
volume_surge_up: Activity,
|
||||||
|
shrink_surge_up: Rocket,
|
||||||
|
volume_oscillation: Waves,
|
||||||
|
limit_up: Flame,
|
||||||
|
rank_jump: Target,
|
||||||
|
volume_spike: BarChart3,
|
||||||
|
};
|
||||||
|
return iconMap[alertType] || Zap;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个异动详情卡片
|
||||||
|
*/
|
||||||
|
const AlertDetailCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const alertConfig = ALERT_TYPE_CONFIG[alert.alert_type] || ALERT_TYPE_CONFIG.surge;
|
||||||
|
const isUp = alert.alert_type !== 'surge_down';
|
||||||
|
const AlertIcon = getAlertIcon(alert.alert_type);
|
||||||
|
|
||||||
|
const handleStockClick = (e, stockCode) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate(`/company?scode=${stockCode}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConceptClick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (alert.concept_id) {
|
||||||
|
navigate(`/concept/${alert.concept_id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
bg="rgba(255, 255, 255, 0.03)"
|
||||||
|
borderRadius="16px"
|
||||||
|
border={isExpanded ? `1px solid ${alertConfig.color}50` : '1px solid rgba(255, 255, 255, 0.08)'}
|
||||||
|
overflow="hidden"
|
||||||
|
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
|
||||||
|
_hover={{
|
||||||
|
border: `1px solid ${alertConfig.color}40`,
|
||||||
|
bg: 'rgba(255, 255, 255, 0.05)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 顶部渐变条 */}
|
||||||
|
<Box
|
||||||
|
h="2px"
|
||||||
|
bgGradient={`linear(to-r, ${alertConfig.gradient[0]}, ${alertConfig.gradient[1]})`}
|
||||||
|
opacity={isExpanded ? 1 : 0.6}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 主内容区 - 可点击展开 */}
|
||||||
|
<Box
|
||||||
|
p={4}
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
{/* 第一行:展开箭头 + 概念名称 + 评分 */}
|
||||||
|
<Flex justify="space-between" align="center" mb={3}>
|
||||||
|
<HStack spacing={3} flex={1}>
|
||||||
|
<Icon
|
||||||
|
as={isExpanded ? ChevronDown : ChevronRight}
|
||||||
|
color={colors.text.secondary}
|
||||||
|
boxSize={4}
|
||||||
|
transition="transform 0.2s"
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
p={2}
|
||||||
|
borderRadius="10px"
|
||||||
|
bg={`${alertConfig.color}20`}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
as={AlertIcon}
|
||||||
|
boxSize={4}
|
||||||
|
color={alertConfig.color}
|
||||||
|
css={css`filter: drop-shadow(0 0 4px ${alertConfig.color}80);`}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<VStack align="flex-start" spacing={0}>
|
||||||
|
<HStack>
|
||||||
|
<Text
|
||||||
|
fontWeight="bold"
|
||||||
|
fontSize="md"
|
||||||
|
color={colors.text.primary}
|
||||||
|
css={css`text-shadow: 0 0 20px ${alertConfig.color}30;`}
|
||||||
|
>
|
||||||
|
{alert.concept_name}
|
||||||
|
</Text>
|
||||||
|
<Tooltip label="查看概念详情" hasArrow>
|
||||||
|
<Box
|
||||||
|
as="span"
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={handleConceptClick}
|
||||||
|
_hover={{ color: alertConfig.color }}
|
||||||
|
>
|
||||||
|
<Icon as={ExternalLink} boxSize={3} color={colors.text.muted} />
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
</HStack>
|
||||||
|
<Text fontSize="xs" color={colors.text.muted}>
|
||||||
|
{alert.time}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 评分 */}
|
||||||
|
<HStack
|
||||||
|
spacing={1}
|
||||||
|
px={3}
|
||||||
|
py={1.5}
|
||||||
|
borderRadius="full"
|
||||||
|
bg={`${getScoreColor(alert.final_score)}15`}
|
||||||
|
border={`1px solid ${getScoreColor(alert.final_score)}30`}
|
||||||
|
>
|
||||||
|
<Icon as={Gauge} boxSize={3.5} color={getScoreColor(alert.final_score)} />
|
||||||
|
<Text
|
||||||
|
fontSize="sm"
|
||||||
|
fontWeight="bold"
|
||||||
|
color={getScoreColor(alert.final_score)}
|
||||||
|
css={css`text-shadow: 0 0 10px ${getScoreColor(alert.final_score)}50;`}
|
||||||
|
>
|
||||||
|
{formatScore(alert.final_score)}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" color={colors.text.muted}>分</Text>
|
||||||
|
</HStack>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* 第二行:类型标签 + Alpha + 其他指标 */}
|
||||||
|
<Flex gap={2} flexWrap="wrap" align="center">
|
||||||
|
<Tooltip label={getAlertTypeDescription(alert.alert_type)} hasArrow>
|
||||||
|
<Badge
|
||||||
|
bg={`${alertConfig.color}20`}
|
||||||
|
color={alertConfig.color}
|
||||||
|
fontSize="xs"
|
||||||
|
px={2}
|
||||||
|
py={1}
|
||||||
|
borderRadius="md"
|
||||||
|
cursor="help"
|
||||||
|
>
|
||||||
|
{getAlertTypeLabel(alert.alert_type)}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{alert.alpha != null && (
|
||||||
|
<HStack spacing={1}>
|
||||||
|
<Text fontSize="xs" color={colors.text.muted}>Alpha</Text>
|
||||||
|
<Text
|
||||||
|
fontSize="xs"
|
||||||
|
fontWeight="bold"
|
||||||
|
color={alert.alpha >= 0 ? colors.market.up : colors.market.down}
|
||||||
|
>
|
||||||
|
{alert.alpha >= 0 ? '+' : ''}{alert.alpha.toFixed(2)}%
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(alert.limit_up_ratio || 0) > 0.03 && (
|
||||||
|
<HStack spacing={1}>
|
||||||
|
<Icon as={Flame} boxSize={3} color="#fa541c" />
|
||||||
|
<Text fontSize="xs" fontWeight="bold" color="#fa541c">
|
||||||
|
{Math.round(alert.limit_up_ratio * 100)}%
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{alert.is_v2 && alert.confirm_ratio != null && (
|
||||||
|
<HStack spacing={1}>
|
||||||
|
<Text fontSize="xs" color={colors.text.muted}>确认</Text>
|
||||||
|
<Text
|
||||||
|
fontSize="xs"
|
||||||
|
fontWeight="medium"
|
||||||
|
color={alert.confirm_ratio >= 0.8 ? '#52c41a' : alert.confirm_ratio >= 0.6 ? '#faad14' : '#ff4d4f'}
|
||||||
|
>
|
||||||
|
{Math.round(alert.confirm_ratio * 100)}%
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 展开内容 - 相关股票 */}
|
||||||
|
<Collapse in={isExpanded} animateOpacity>
|
||||||
|
<Box
|
||||||
|
borderTop="1px solid rgba(255, 255, 255, 0.08)"
|
||||||
|
p={4}
|
||||||
|
bg="rgba(0, 0, 0, 0.2)"
|
||||||
|
>
|
||||||
|
{loadingStocks ? (
|
||||||
|
<HStack justify="center" py={4}>
|
||||||
|
<Spinner size="sm" color={alertConfig.color} />
|
||||||
|
<Text fontSize="sm" color={colors.text.secondary}>加载相关股票...</Text>
|
||||||
|
</HStack>
|
||||||
|
) : stocks && stocks.length > 0 ? (
|
||||||
|
<VStack align="stretch" spacing={2}>
|
||||||
|
{/* 统计信息 */}
|
||||||
|
{(() => {
|
||||||
|
const validStocks = stocks.filter(s => s.change_pct != null && !isNaN(s.change_pct));
|
||||||
|
if (validStocks.length === 0) return null;
|
||||||
|
const avgChange = validStocks.reduce((sum, s) => sum + s.change_pct, 0) / validStocks.length;
|
||||||
|
const upCount = validStocks.filter(s => s.change_pct > 0).length;
|
||||||
|
const downCount = validStocks.filter(s => s.change_pct < 0).length;
|
||||||
|
return (
|
||||||
|
<HStack
|
||||||
|
spacing={4}
|
||||||
|
p={3}
|
||||||
|
bg="rgba(255, 255, 255, 0.02)"
|
||||||
|
borderRadius="10px"
|
||||||
|
fontSize="xs"
|
||||||
|
>
|
||||||
|
<HStack>
|
||||||
|
<Text color={colors.text.muted}>均涨:</Text>
|
||||||
|
<Text fontWeight="bold" color={avgChange >= 0 ? colors.market.up : colors.market.down}>
|
||||||
|
{avgChange >= 0 ? '+' : ''}{avgChange.toFixed(2)}%
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<HStack spacing={1}>
|
||||||
|
<Text color={colors.market.up} fontWeight="medium">{upCount}涨</Text>
|
||||||
|
<Text color={colors.text.muted}>/</Text>
|
||||||
|
<Text color={colors.market.down} fontWeight="medium">{downCount}跌</Text>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* 股票列表 */}
|
||||||
|
<Box maxH="250px" overflowY="auto" pr={1}>
|
||||||
|
<VStack align="stretch" spacing={1}>
|
||||||
|
{stocks.slice(0, 15).map((stock, idx) => {
|
||||||
|
const changePct = stock.change_pct;
|
||||||
|
const hasChange = changePct != null && !isNaN(changePct);
|
||||||
|
const stockCode = stock.code || stock.stock_code;
|
||||||
|
const stockName = stock.name || stock.stock_name || '-';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HStack
|
||||||
|
key={idx}
|
||||||
|
p={2}
|
||||||
|
borderRadius="8px"
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={(e) => handleStockClick(e, stockCode)}
|
||||||
|
_hover={{ bg: 'rgba(255, 255, 255, 0.05)' }}
|
||||||
|
transition="background 0.15s"
|
||||||
|
justify="space-between"
|
||||||
|
>
|
||||||
|
<HStack spacing={3} flex={1}>
|
||||||
|
{/* 股票名称 - 带迷你分时图悬停 */}
|
||||||
|
<Popover trigger="hover" placement="left" isLazy>
|
||||||
|
<PopoverTrigger>
|
||||||
|
<Text
|
||||||
|
fontSize="sm"
|
||||||
|
color="#60a5fa"
|
||||||
|
fontWeight="medium"
|
||||||
|
_hover={{ color: '#93c5fd', textDecoration: 'underline' }}
|
||||||
|
>
|
||||||
|
{stockName}
|
||||||
|
</Text>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<Portal>
|
||||||
|
<PopoverContent
|
||||||
|
w="200px"
|
||||||
|
h="100px"
|
||||||
|
bg="rgba(15, 15, 25, 0.95)"
|
||||||
|
borderColor="rgba(255, 255, 255, 0.1)"
|
||||||
|
boxShadow="0 8px 32px rgba(0, 0, 0, 0.5)"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<PopoverBody p={2}>
|
||||||
|
<Text fontSize="xs" color={colors.text.secondary} mb={1}>
|
||||||
|
{stockName} 分时走势
|
||||||
|
</Text>
|
||||||
|
<Box h="70px">
|
||||||
|
<MiniTimelineChart stockCode={stockCode} />
|
||||||
|
</Box>
|
||||||
|
</PopoverBody>
|
||||||
|
</PopoverContent>
|
||||||
|
</Portal>
|
||||||
|
</Popover>
|
||||||
|
<Text fontSize="xs" color={colors.text.muted}>
|
||||||
|
{stockCode}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<Text
|
||||||
|
fontSize="sm"
|
||||||
|
fontWeight="bold"
|
||||||
|
color={
|
||||||
|
hasChange && changePct > 0 ? colors.market.up :
|
||||||
|
hasChange && changePct < 0 ? colors.market.down :
|
||||||
|
colors.text.muted
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{hasChange ? `${changePct > 0 ? '+' : ''}${changePct.toFixed(2)}%` : '-'}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{stocks.length > 15 && (
|
||||||
|
<Text fontSize="xs" color={colors.text.muted} textAlign="center">
|
||||||
|
共 {stocks.length} 只相关股票,显示前 15 只
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
) : (
|
||||||
|
<Text fontSize="sm" color={colors.text.muted} textAlign="center" py={4}>
|
||||||
|
暂无相关股票数据
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异动详情抽屉主组件
|
||||||
|
*/
|
||||||
|
const AlertDetailDrawer = ({ isOpen, onClose, alertData }) => {
|
||||||
|
const [expandedAlertId, setExpandedAlertId] = useState(null);
|
||||||
|
const [conceptStocks, setConceptStocks] = useState({});
|
||||||
|
const [loadingConcepts, setLoadingConcepts] = useState({});
|
||||||
|
|
||||||
|
const { alerts = [], timeRange, alertCount } = alertData || {};
|
||||||
|
|
||||||
|
// 重置状态当抽屉关闭或数据变化
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setExpandedAlertId(null);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// 获取概念相关股票
|
||||||
|
const fetchConceptStocks = useCallback(async (conceptId) => {
|
||||||
|
if (loadingConcepts[conceptId] || conceptStocks[conceptId]) return;
|
||||||
|
|
||||||
|
setLoadingConcepts(prev => ({ ...prev, [conceptId]: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/concept/${encodeURIComponent(conceptId)}/stocks`);
|
||||||
|
if (response.data?.success && response.data?.data?.stocks) {
|
||||||
|
setConceptStocks(prev => ({
|
||||||
|
...prev,
|
||||||
|
[conceptId]: response.data.data.stocks
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setConceptStocks(prev => ({ ...prev, [conceptId]: [] }));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取概念股票失败:', error);
|
||||||
|
setConceptStocks(prev => ({ ...prev, [conceptId]: [] }));
|
||||||
|
} finally {
|
||||||
|
setLoadingConcepts(prev => ({ ...prev, [conceptId]: false }));
|
||||||
|
}
|
||||||
|
}, [loadingConcepts, conceptStocks]);
|
||||||
|
|
||||||
|
// 处理展开/收起
|
||||||
|
const handleToggle = useCallback((alert) => {
|
||||||
|
const alertId = `${alert.concept_id}-${alert.time}`;
|
||||||
|
if (expandedAlertId === alertId) {
|
||||||
|
setExpandedAlertId(null);
|
||||||
|
} else {
|
||||||
|
setExpandedAlertId(alertId);
|
||||||
|
if (alert.concept_id) {
|
||||||
|
fetchConceptStocks(alert.concept_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [expandedAlertId, fetchConceptStocks]);
|
||||||
|
|
||||||
|
// 按分数排序
|
||||||
|
const sortedAlerts = [...alerts].sort((a, b) =>
|
||||||
|
(b.final_score || 0) - (a.final_score || 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
isOpen={isOpen}
|
||||||
|
placement="right"
|
||||||
|
onClose={onClose}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<DrawerOverlay bg="rgba(0, 0, 0, 0.6)" backdropFilter="blur(4px)" />
|
||||||
|
<DrawerContent
|
||||||
|
bg="rgba(10, 10, 15, 0.95)"
|
||||||
|
backdropFilter="blur(20px)"
|
||||||
|
borderLeft="1px solid rgba(255, 255, 255, 0.1)"
|
||||||
|
>
|
||||||
|
<DrawerCloseButton
|
||||||
|
color={colors.text.secondary}
|
||||||
|
_hover={{ color: colors.text.primary, bg: 'rgba(255, 255, 255, 0.1)' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 头部 */}
|
||||||
|
<DrawerHeader
|
||||||
|
borderBottomWidth="1px"
|
||||||
|
borderColor="rgba(255, 255, 255, 0.08)"
|
||||||
|
pb={4}
|
||||||
|
>
|
||||||
|
<VStack align="flex-start" spacing={2}>
|
||||||
|
<HStack spacing={3}>
|
||||||
|
<Box
|
||||||
|
p={2}
|
||||||
|
borderRadius="12px"
|
||||||
|
bgGradient="linear(to-br, #8b5cf6, #ec4899)"
|
||||||
|
boxShadow="0 4px 15px rgba(139, 92, 246, 0.4)"
|
||||||
|
>
|
||||||
|
<Icon as={Zap} boxSize={5} color="white" />
|
||||||
|
</Box>
|
||||||
|
<Text
|
||||||
|
fontSize="lg"
|
||||||
|
fontWeight="bold"
|
||||||
|
color={colors.text.primary}
|
||||||
|
css={css`text-shadow: 0 0 20px rgba(139, 92, 246, 0.4);`}
|
||||||
|
>
|
||||||
|
异动详情
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 时间段和数量信息 */}
|
||||||
|
<HStack spacing={4}>
|
||||||
|
<HStack spacing={2}>
|
||||||
|
<Icon as={Clock} boxSize={4} color={colors.accent.purple} />
|
||||||
|
<Text fontSize="sm" color={colors.text.secondary}>
|
||||||
|
{timeRange || '未知时段'}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<HStack
|
||||||
|
spacing={1}
|
||||||
|
px={3}
|
||||||
|
py={1}
|
||||||
|
borderRadius="full"
|
||||||
|
bg="rgba(139, 92, 246, 0.15)"
|
||||||
|
border="1px solid rgba(139, 92, 246, 0.3)"
|
||||||
|
>
|
||||||
|
<Icon as={Sparkles} boxSize={3} color={colors.accent.purple} />
|
||||||
|
<Text fontSize="sm" fontWeight="bold" color={colors.accent.purple}>
|
||||||
|
{alertCount || alerts.length} 个异动
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</DrawerHeader>
|
||||||
|
|
||||||
|
{/* 内容区 */}
|
||||||
|
<DrawerBody py={4}>
|
||||||
|
{alerts.length === 0 ? (
|
||||||
|
<Box
|
||||||
|
p={8}
|
||||||
|
textAlign="center"
|
||||||
|
bg="rgba(255, 255, 255, 0.02)"
|
||||||
|
borderRadius="16px"
|
||||||
|
border="1px solid rgba(255, 255, 255, 0.05)"
|
||||||
|
>
|
||||||
|
<Icon as={Zap} boxSize={10} color={colors.text.muted} mb={3} opacity={0.5} />
|
||||||
|
<Text color={colors.text.muted}>暂无异动数据</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<VStack spacing={3} align="stretch">
|
||||||
|
{sortedAlerts.map((alert, idx) => {
|
||||||
|
const alertId = `${alert.concept_id}-${alert.time}`;
|
||||||
|
return (
|
||||||
|
<AlertDetailCard
|
||||||
|
key={alertId || idx}
|
||||||
|
alert={alert}
|
||||||
|
isExpanded={expandedAlertId === alertId}
|
||||||
|
onToggle={() => handleToggle(alert)}
|
||||||
|
stocks={conceptStocks[alert.concept_id]}
|
||||||
|
loadingStocks={loadingConcepts[alert.concept_id]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</DrawerBody>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AlertDetailDrawer;
|
||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Icon,
|
Icon,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
useColorModeValue,
|
|
||||||
Flex,
|
Flex,
|
||||||
Collapse,
|
Collapse,
|
||||||
Spinner,
|
Spinner,
|
||||||
@@ -84,10 +83,10 @@ const getAlertIcon = (alertType) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 指标提示组件 - 带详细说明
|
* 指标提示组件 - 带详细说明(深色主题)
|
||||||
*/
|
*/
|
||||||
const MetricTooltip = ({ metricKey, children }) => {
|
const MetricTooltip = ({ metricKey, children }) => {
|
||||||
const tooltipBg = useColorModeValue('gray.800', 'gray.700');
|
const tooltipBg = 'rgba(15, 15, 25, 0.95)';
|
||||||
const config = METRIC_CONFIG[metricKey];
|
const config = METRIC_CONFIG[metricKey];
|
||||||
if (!config) return children;
|
if (!config) return children;
|
||||||
|
|
||||||
@@ -117,10 +116,10 @@ const MetricTooltip = ({ metricKey, children }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 迷你进度条组件
|
* 迷你进度条组件(深色主题)
|
||||||
*/
|
*/
|
||||||
const MiniProgressBar = ({ value, maxValue = 100, color, width = '40px', showGlow = false }) => {
|
const MiniProgressBar = ({ value, maxValue = 100, color, width = '40px', showGlow = false }) => {
|
||||||
const bgColor = useColorModeValue('gray.200', 'gray.700');
|
const bgColor = 'rgba(255, 255, 255, 0.1)';
|
||||||
const percent = Math.min((value / maxValue) * 100, 100);
|
const percent = Math.min((value / maxValue) * 100, 100);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -147,10 +146,10 @@ const MiniProgressBar = ({ value, maxValue = 100, color, width = '40px', showGlo
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Z-Score 双向进度条组件
|
* Z-Score 双向进度条组件(深色主题)
|
||||||
*/
|
*/
|
||||||
const ZScoreBar = ({ value, color }) => {
|
const ZScoreBar = ({ value, color }) => {
|
||||||
const bgColor = useColorModeValue('gray.200', 'gray.700');
|
const bgColor = 'rgba(255, 255, 255, 0.1)';
|
||||||
const absValue = Math.abs(value || 0);
|
const absValue = Math.abs(value || 0);
|
||||||
const percent = Math.min(absValue / 4 * 50, 50);
|
const percent = Math.min(absValue / 4 * 50, 50);
|
||||||
const isPositive = (value || 0) >= 0;
|
const isPositive = (value || 0) >= 0;
|
||||||
@@ -176,7 +175,7 @@ const ZScoreBar = ({ value, color }) => {
|
|||||||
transform="translateX(-50%)"
|
transform="translateX(-50%)"
|
||||||
w="2px"
|
w="2px"
|
||||||
h="6px"
|
h="6px"
|
||||||
bg={useColorModeValue('gray.400', 'gray.500')}
|
bg="rgba(255, 255, 255, 0.3)"
|
||||||
borderRadius="full"
|
borderRadius="full"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -209,20 +208,20 @@ const TriggeredRuleBadge = ({ rule }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 科技感异动卡片
|
* 科技感异动卡片 - 统一使用深色主题
|
||||||
*/
|
*/
|
||||||
const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => {
|
const AlertCard = ({ alert, isExpanded, onToggle, stocks, loadingStocks }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// 颜色主题
|
// 统一深色主题配色(与 glassTheme 保持一致)
|
||||||
const cardBg = useColorModeValue('white', '#0d0d0d');
|
const cardBg = 'rgba(255, 255, 255, 0.03)';
|
||||||
const hoverBg = useColorModeValue('gray.50', '#1a1a1a');
|
const hoverBg = 'rgba(255, 255, 255, 0.06)';
|
||||||
const borderColor = useColorModeValue('gray.200', '#2d2d2d');
|
const borderColor = 'rgba(255, 255, 255, 0.08)';
|
||||||
const expandedBg = useColorModeValue('gray.50', '#111111');
|
const expandedBg = 'rgba(0, 0, 0, 0.2)';
|
||||||
const tableBg = useColorModeValue('gray.50', '#0a0a0a');
|
const tableBg = 'rgba(255, 255, 255, 0.02)';
|
||||||
const popoverBg = useColorModeValue('white', '#1a1a1a');
|
const popoverBg = 'rgba(15, 15, 25, 0.95)';
|
||||||
const textColor = useColorModeValue('gray.800', 'white');
|
const textColor = 'rgba(255, 255, 255, 0.95)';
|
||||||
const subTextColor = useColorModeValue('gray.500', 'gray.400');
|
const subTextColor = 'rgba(255, 255, 255, 0.6)';
|
||||||
|
|
||||||
const alertConfig = ALERT_TYPE_CONFIG[alert.alert_type] || ALERT_TYPE_CONFIG.surge;
|
const alertConfig = ALERT_TYPE_CONFIG[alert.alert_type] || ALERT_TYPE_CONFIG.surge;
|
||||||
const isUp = alert.alert_type !== 'surge_down';
|
const isUp = alert.alert_type !== 'surge_down';
|
||||||
@@ -681,8 +680,9 @@ const ConceptAlertList = ({
|
|||||||
const [conceptStocks, setConceptStocks] = useState({});
|
const [conceptStocks, setConceptStocks] = useState({});
|
||||||
const [loadingConcepts, setLoadingConcepts] = useState({});
|
const [loadingConcepts, setLoadingConcepts] = useState({});
|
||||||
|
|
||||||
const subTextColor = useColorModeValue('gray.500', 'gray.400');
|
// 统一深色主题配色
|
||||||
const emptyBg = useColorModeValue('gray.50', '#111111');
|
const subTextColor = 'rgba(255, 255, 255, 0.6)';
|
||||||
|
const emptyBg = 'rgba(255, 255, 255, 0.02)';
|
||||||
|
|
||||||
// 获取概念相关股票 - 使用 ref 避免依赖循环
|
// 获取概念相关股票 - 使用 ref 避免依赖循环
|
||||||
const fetchConceptStocks = useCallback(async (conceptId) => {
|
const fetchConceptStocks = useCallback(async (conceptId) => {
|
||||||
|
|||||||
@@ -1,26 +1,28 @@
|
|||||||
/**
|
/**
|
||||||
* 指数分时图组件
|
* 指数分时图组件
|
||||||
* 展示大盘分时走势,支持概念异动标注
|
* 展示大盘分时走势,支持概念异动标注(按10分钟分组)
|
||||||
*/
|
*/
|
||||||
import React, { useRef, useEffect, useCallback, useMemo } from 'react';
|
import React, { useRef, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { Box, useColorModeValue } from '@chakra-ui/react';
|
import { Box } from '@chakra-ui/react';
|
||||||
import * as echarts from 'echarts';
|
import * as echarts from 'echarts';
|
||||||
import { getAlertMarkPoints } from '../utils/chartHelpers';
|
import { getAlertMarkPointsGrouped } from '../utils/chartHelpers';
|
||||||
|
import { colors, glassEffect } from '../../../theme/glassTheme';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Object} props
|
* @param {Object} props
|
||||||
* @param {Object} props.indexData - 指数数据 { timeline, prev_close, name, ... }
|
* @param {Object} props.indexData - 指数数据 { timeline, prev_close, name, ... }
|
||||||
* @param {Array} props.alerts - 异动数据数组
|
* @param {Array} props.alerts - 异动数据数组
|
||||||
* @param {Function} props.onAlertClick - 点击异动标注的回调
|
* @param {Function} props.onAlertClick - 点击异动标注的回调(传递该时间段所有异动)
|
||||||
* @param {string} props.height - 图表高度
|
* @param {string} props.height - 图表高度
|
||||||
*/
|
*/
|
||||||
const IndexMinuteChart = ({ indexData, alerts = [], onAlertClick, height = '350px' }) => {
|
const IndexMinuteChart = ({ indexData, alerts = [], onAlertClick, height = '350px' }) => {
|
||||||
const chartRef = useRef(null);
|
const chartRef = useRef(null);
|
||||||
const chartInstance = useRef(null);
|
const chartInstance = useRef(null);
|
||||||
|
|
||||||
const textColor = useColorModeValue('gray.800', 'white');
|
// 使用 glassTheme 的深色主题颜色
|
||||||
const subTextColor = useColorModeValue('gray.600', 'gray.400');
|
const textColor = colors.text.primary;
|
||||||
const gridLineColor = useColorModeValue('#eee', '#333');
|
const subTextColor = colors.text.secondary;
|
||||||
|
const gridLineColor = 'rgba(255, 255, 255, 0.08)';
|
||||||
|
|
||||||
// 计算图表配置
|
// 计算图表配置
|
||||||
const chartOption = useMemo(() => {
|
const chartOption = useMemo(() => {
|
||||||
@@ -44,8 +46,8 @@ const IndexMinuteChart = ({ indexData, alerts = [], onAlertClick, height = '350p
|
|||||||
const yAxisMin = priceMin - priceRange * 0.1;
|
const yAxisMin = priceMin - priceRange * 0.1;
|
||||||
const yAxisMax = priceMax + priceRange * 0.25; // 上方留更多空间给标注
|
const yAxisMax = priceMax + priceRange * 0.25; // 上方留更多空间给标注
|
||||||
|
|
||||||
// 准备异动标注
|
// 准备异动标注 - 按10分钟分组
|
||||||
const markPoints = getAlertMarkPoints(alerts, times, prices, priceMax);
|
const markPoints = getAlertMarkPointsGrouped(alerts, times, prices, priceMax, 10);
|
||||||
|
|
||||||
// 渐变色 - 根据涨跌
|
// 渐变色 - 根据涨跌
|
||||||
const latestChangePct = changePcts[changePcts.length - 1] || 0;
|
const latestChangePct = changePcts[changePcts.length - 1] || 0;
|
||||||
@@ -67,8 +69,17 @@ const IndexMinuteChart = ({ indexData, alerts = [], onAlertClick, height = '350p
|
|||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
axisPointer: {
|
axisPointer: {
|
||||||
type: 'cross',
|
type: 'cross',
|
||||||
crossStyle: { color: '#999' },
|
crossStyle: { color: 'rgba(255, 255, 255, 0.3)' },
|
||||||
|
lineStyle: { color: 'rgba(139, 92, 246, 0.5)' },
|
||||||
},
|
},
|
||||||
|
backgroundColor: 'rgba(15, 15, 25, 0.95)',
|
||||||
|
borderColor: 'rgba(139, 92, 246, 0.3)',
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: 0,
|
||||||
|
textStyle: {
|
||||||
|
color: colors.text.primary,
|
||||||
|
},
|
||||||
|
extraCssText: 'backdrop-filter: blur(12px); border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.4);',
|
||||||
formatter: (params) => {
|
formatter: (params) => {
|
||||||
if (!params || params.length === 0) return '';
|
if (!params || params.length === 0) return '';
|
||||||
|
|
||||||
@@ -79,19 +90,19 @@ const IndexMinuteChart = ({ indexData, alerts = [], onAlertClick, height = '350p
|
|||||||
const volume = volumes[dataIndex];
|
const volume = volumes[dataIndex];
|
||||||
|
|
||||||
let html = `
|
let html = `
|
||||||
<div style="padding: 8px;">
|
<div style="padding: 12px; color: rgba(255,255,255,0.95);">
|
||||||
<div style="font-weight: bold; margin-bottom: 4px;">${time}</div>
|
<div style="font-weight: bold; margin-bottom: 6px; color: rgba(255,255,255,0.7); font-size: 12px;">${time}</div>
|
||||||
<div>指数: <span style="color: ${changePct >= 0 ? '#ff4d4d' : '#22c55e'}; font-weight: bold;">${price?.toFixed(2)}</span></div>
|
<div style="margin-bottom: 4px;">指数: <span style="color: ${changePct >= 0 ? '#ef4444' : '#22c55e'}; font-weight: bold;">${price?.toFixed(2)}</span></div>
|
||||||
<div>涨跌: <span style="color: ${changePct >= 0 ? '#ff4d4d' : '#22c55e'};">${changePct >= 0 ? '+' : ''}${changePct?.toFixed(2)}%</span></div>
|
<div style="margin-bottom: 4px;">涨跌: <span style="color: ${changePct >= 0 ? '#ef4444' : '#22c55e'};">${changePct >= 0 ? '+' : ''}${changePct?.toFixed(2)}%</span></div>
|
||||||
<div>成交量: ${(volume / 10000).toFixed(0)}万手</div>
|
<div style="color: rgba(255,255,255,0.7);">成交量: ${(volume / 10000).toFixed(0)}万手</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 检查是否有异动
|
// 检查是否有异动
|
||||||
const alertsAtTime = alerts.filter((a) => a.time === time);
|
const alertsAtTime = alerts.filter((a) => a.time === time);
|
||||||
if (alertsAtTime.length > 0) {
|
if (alertsAtTime.length > 0) {
|
||||||
html += '<div style="border-top: 1px solid rgba(139,92,246,0.3); margin-top: 6px; padding-top: 6px;">';
|
html += '<div style="border-top: 1px solid rgba(139,92,246,0.3); margin: 0 12px; padding: 10px 0;">';
|
||||||
html += `<div style="font-weight: bold; color: #8b5cf6; margin-bottom: 4px;">📍 概念异动 (${alertsAtTime.length})</div>`;
|
html += `<div style="font-weight: bold; color: #a78bfa; margin-bottom: 6px; font-size: 12px;">📍 概念异动 (${alertsAtTime.length})</div>`;
|
||||||
alertsAtTime.slice(0, 5).forEach((alert) => {
|
alertsAtTime.slice(0, 5).forEach((alert) => {
|
||||||
const typeLabel = {
|
const typeLabel = {
|
||||||
surge: '异动',
|
surge: '异动',
|
||||||
@@ -104,13 +115,13 @@ const IndexMinuteChart = ({ indexData, alerts = [], onAlertClick, height = '350p
|
|||||||
rank_jump: '排名跃升',
|
rank_jump: '排名跃升',
|
||||||
volume_spike: '放量',
|
volume_spike: '放量',
|
||||||
}[alert.alert_type] || alert.alert_type;
|
}[alert.alert_type] || alert.alert_type;
|
||||||
const typeColor = alert.alert_type === 'surge_down' ? '#52c41a' : '#ff4d4f';
|
const typeColor = alert.alert_type === 'surge_down' ? '#4ade80' : '#f87171';
|
||||||
const alpha = alert.alpha ? ` α${alert.alpha > 0 ? '+' : ''}${alert.alpha.toFixed(1)}%` : '';
|
const alpha = alert.alpha ? ` α${alert.alpha > 0 ? '+' : ''}${alert.alpha.toFixed(1)}%` : '';
|
||||||
const score = alert.final_score ? ` [${Math.round(alert.final_score)}分]` : '';
|
const score = alert.final_score ? ` [${Math.round(alert.final_score)}分]` : '';
|
||||||
html += `<div style="color: ${typeColor}; font-size: 11px; margin: 2px 0;">• ${alert.concept_name} <span style="opacity:0.8">(${typeLabel}${alpha}${score})</span></div>`;
|
html += `<div style="color: ${typeColor}; font-size: 11px; margin: 3px 0;">• ${alert.concept_name} <span style="opacity:0.7">(${typeLabel}${alpha}${score})</span></div>`;
|
||||||
});
|
});
|
||||||
if (alertsAtTime.length > 5) {
|
if (alertsAtTime.length > 5) {
|
||||||
html += `<div style="color: #8c8c8c; font-size: 10px; margin-top: 4px;">还有 ${alertsAtTime.length - 5} 个异动...</div>`;
|
html += `<div style="color: rgba(255,255,255,0.4); font-size: 10px; margin-top: 6px;">还有 ${alertsAtTime.length - 5} 个异动...</div>`;
|
||||||
}
|
}
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
}
|
}
|
||||||
@@ -223,19 +234,18 @@ const IndexMinuteChart = ({ indexData, alerts = [], onAlertClick, height = '350p
|
|||||||
|
|
||||||
chartInstance.current.setOption(chartOption, true);
|
chartInstance.current.setOption(chartOption, true);
|
||||||
|
|
||||||
// 点击事件 - 支持多个异动
|
// 点击事件 - 传递该时间段所有异动数据
|
||||||
if (onAlertClick) {
|
if (onAlertClick) {
|
||||||
chartInstance.current.off('click');
|
chartInstance.current.off('click');
|
||||||
chartInstance.current.on('click', 'series.line.markPoint', (params) => {
|
chartInstance.current.on('click', 'series.line.markPoint', (params) => {
|
||||||
if (params.data && params.data.alertData) {
|
if (params.data) {
|
||||||
const alertData = params.data.alertData;
|
// 传递完整的标记点数据,包含 alertData(所有异动)、timeRange、alertCount 等
|
||||||
// 如果是数组(多个异动),传递第一个(最高分)
|
onAlertClick({
|
||||||
// 调用方可以从 alertData 中获取所有异动
|
alerts: params.data.alertData || [],
|
||||||
if (Array.isArray(alertData)) {
|
timeRange: params.data.timeRange,
|
||||||
onAlertClick(alertData[0]);
|
alertCount: params.data.alertCount || 1,
|
||||||
} else {
|
time: params.data.time,
|
||||||
onAlertClick(alertData);
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export { default as IndexMinuteChart } from './IndexMinuteChart';
|
export { default as IndexMinuteChart } from './IndexMinuteChart';
|
||||||
export { default as ConceptAlertList } from './ConceptAlertList';
|
export { default as ConceptAlertList } from './ConceptAlertList';
|
||||||
export { default as AlertSummary } from './AlertSummary';
|
export { default as AlertSummary } from './AlertSummary';
|
||||||
|
export { default as AlertDetailDrawer } from './AlertDetailDrawer';
|
||||||
|
|||||||
@@ -20,17 +20,14 @@ import {
|
|||||||
Flex,
|
Flex,
|
||||||
Spacer,
|
Spacer,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
IconButton,
|
|
||||||
Collapse,
|
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
|
useDisclosure,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { keyframes, css } from '@emotion/react';
|
import { keyframes, css } from '@emotion/react';
|
||||||
import {
|
import {
|
||||||
Flame,
|
Flame,
|
||||||
List,
|
List,
|
||||||
LineChart,
|
LineChart,
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
Info,
|
Info,
|
||||||
Zap,
|
Zap,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -40,7 +37,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { useHotspotData } from './hooks';
|
import { useHotspotData } from './hooks';
|
||||||
import { IndexMinuteChart, ConceptAlertList, AlertSummary } from './components';
|
import { IndexMinuteChart, AlertDetailDrawer } from './components';
|
||||||
import { ALERT_TYPE_CONFIG, getAlertTypeLabel } from './utils/chartHelpers';
|
import { ALERT_TYPE_CONFIG, getAlertTypeLabel } from './utils/chartHelpers';
|
||||||
import {
|
import {
|
||||||
glassEffect,
|
glassEffect,
|
||||||
@@ -198,8 +195,10 @@ const CompactAlertCard = ({ alert, onClick, isSelected }) => {
|
|||||||
*/
|
*/
|
||||||
const HotspotOverview = ({ selectedDate }) => {
|
const HotspotOverview = ({ selectedDate }) => {
|
||||||
const [selectedAlert, setSelectedAlert] = useState(null);
|
const [selectedAlert, setSelectedAlert] = useState(null);
|
||||||
const [showDetailList, setShowDetailList] = useState(false);
|
const [drawerAlertData, setDrawerAlertData] = useState(null);
|
||||||
const [autoExpandAlertKey, setAutoExpandAlertKey] = useState(null);
|
|
||||||
|
// 右边栏抽屉控制
|
||||||
|
const { isOpen: isDrawerOpen, onOpen: onDrawerOpen, onClose: onDrawerClose } = useDisclosure();
|
||||||
|
|
||||||
// 获取数据
|
// 获取数据
|
||||||
const { loading, error, data } = useHotspotData(selectedDate);
|
const { loading, error, data } = useHotspotData(selectedDate);
|
||||||
@@ -212,14 +211,25 @@ const HotspotOverview = ({ selectedDate }) => {
|
|||||||
const sectionBg = glassEffect.light.bg;
|
const sectionBg = glassEffect.light.bg;
|
||||||
const scrollbarColor = 'rgba(139, 92, 246, 0.3)';
|
const scrollbarColor = 'rgba(139, 92, 246, 0.3)';
|
||||||
|
|
||||||
// 点击异动标注 - 自动展开详细列表并选中
|
// 点击分时图上的异动标注 - 打开右边栏抽屉显示详情
|
||||||
const handleAlertClick = useCallback((alert) => {
|
const handleChartAlertClick = useCallback((alertGroupData) => {
|
||||||
|
// alertGroupData 包含 { alerts, timeRange, alertCount, time }
|
||||||
|
setDrawerAlertData(alertGroupData);
|
||||||
|
onDrawerOpen();
|
||||||
|
}, [onDrawerOpen]);
|
||||||
|
|
||||||
|
// 点击底部异动卡片 - 打开右边栏抽屉显示单个异动详情
|
||||||
|
const handleCardAlertClick = useCallback((alert) => {
|
||||||
setSelectedAlert(alert);
|
setSelectedAlert(alert);
|
||||||
// 自动展开详细列表并设置需要展开的项
|
// 构造单个异动的数据格式
|
||||||
setShowDetailList(true);
|
setDrawerAlertData({
|
||||||
const alertKey = `${alert.concept_id}-${alert.time}`;
|
alerts: [alert],
|
||||||
setAutoExpandAlertKey(alertKey);
|
timeRange: alert.time,
|
||||||
}, []);
|
alertCount: 1,
|
||||||
|
time: alert.time,
|
||||||
|
});
|
||||||
|
onDrawerOpen();
|
||||||
|
}, [onDrawerOpen]);
|
||||||
|
|
||||||
// 渲染加载状态 - Glassmorphism 风格
|
// 渲染加载状态 - Glassmorphism 风格
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -637,7 +647,7 @@ const HotspotOverview = ({ selectedDate }) => {
|
|||||||
<IndexMinuteChart
|
<IndexMinuteChart
|
||||||
indexData={index}
|
indexData={index}
|
||||||
alerts={alerts}
|
alerts={alerts}
|
||||||
onAlertClick={handleAlertClick}
|
onAlertClick={handleChartAlertClick}
|
||||||
height="420px"
|
height="420px"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -645,8 +655,7 @@ const HotspotOverview = ({ selectedDate }) => {
|
|||||||
{/* 异动列表 - Glassmorphism 横向滚动 */}
|
{/* 异动列表 - Glassmorphism 横向滚动 */}
|
||||||
{alerts.length > 0 && (
|
{alerts.length > 0 && (
|
||||||
<Box>
|
<Box>
|
||||||
<Flex justify="space-between" align="center" mb={4}>
|
<HStack spacing={3} mb={4}>
|
||||||
<HStack spacing={3}>
|
|
||||||
<Box
|
<Box
|
||||||
p={2}
|
p={2}
|
||||||
borderRadius="12px"
|
borderRadius="12px"
|
||||||
@@ -661,24 +670,8 @@ const HotspotOverview = ({ selectedDate }) => {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Text fontSize="sm" fontWeight="bold" color={textColor}>异动记录</Text>
|
<Text fontSize="sm" fontWeight="bold" color={textColor}>异动记录</Text>
|
||||||
<Text fontSize="xs" color={colors.text.muted}>(点击卡片查看个股详情)</Text>
|
<Text fontSize="xs" color={colors.text.muted}>(点击卡片查看详情)</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
<Tooltip label={showDetailList ? '收起详细列表' : '展开详细列表'} hasArrow>
|
|
||||||
<IconButton
|
|
||||||
icon={<Icon as={showDetailList ? ChevronUp : ChevronDown} boxSize={4} />}
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
borderRadius="12px"
|
|
||||||
color={colors.text.secondary}
|
|
||||||
_hover={{
|
|
||||||
bg: 'rgba(255,255,255,0.05)',
|
|
||||||
color: textColor,
|
|
||||||
}}
|
|
||||||
onClick={() => setShowDetailList(!showDetailList)}
|
|
||||||
aria-label="切换详细列表"
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
{/* 横向滚动卡片 */}
|
{/* 横向滚动卡片 */}
|
||||||
<Box
|
<Box
|
||||||
@@ -701,48 +694,12 @@ const HotspotOverview = ({ selectedDate }) => {
|
|||||||
<CompactAlertCard
|
<CompactAlertCard
|
||||||
key={`${alert.concept_id}-${alert.time}-${idx}`}
|
key={`${alert.concept_id}-${alert.time}-${idx}`}
|
||||||
alert={alert}
|
alert={alert}
|
||||||
onClick={handleAlertClick}
|
onClick={handleCardAlertClick}
|
||||||
isSelected={selectedAlert?.concept_id === alert.concept_id && selectedAlert?.time === alert.time}
|
isSelected={selectedAlert?.concept_id === alert.concept_id && selectedAlert?.time === alert.time}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</HStack>
|
</HStack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 详细列表(可展开) - Glassmorphism */}
|
|
||||||
<Collapse in={showDetailList} animateOpacity>
|
|
||||||
<Box
|
|
||||||
mt={4}
|
|
||||||
bg={sectionBg}
|
|
||||||
backdropFilter={glassEffect.light.backdropFilter}
|
|
||||||
borderRadius="20px"
|
|
||||||
border={glassEffect.light.border}
|
|
||||||
p={5}
|
|
||||||
position="relative"
|
|
||||||
overflow="hidden"
|
|
||||||
>
|
|
||||||
{/* 背景光晕 */}
|
|
||||||
<Box
|
|
||||||
position="absolute"
|
|
||||||
top="50%"
|
|
||||||
left="50%"
|
|
||||||
transform="translate(-50%, -50%)"
|
|
||||||
w="80%"
|
|
||||||
h="200px"
|
|
||||||
borderRadius="full"
|
|
||||||
bg="rgba(139, 92, 246, 0.05)"
|
|
||||||
filter="blur(60px)"
|
|
||||||
pointerEvents="none"
|
|
||||||
/>
|
|
||||||
<ConceptAlertList
|
|
||||||
alerts={alerts}
|
|
||||||
onAlertClick={handleAlertClick}
|
|
||||||
selectedAlert={selectedAlert}
|
|
||||||
maxHeight="400px"
|
|
||||||
autoExpandAlertKey={autoExpandAlertKey}
|
|
||||||
onAutoExpandComplete={() => setAutoExpandAlertKey(null)}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Collapse>
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -790,6 +747,13 @@ const HotspotOverview = ({ selectedDate }) => {
|
|||||||
</Center>
|
</Center>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* 异动详情右边栏抽屉 */}
|
||||||
|
<AlertDetailDrawer
|
||||||
|
isOpen={isDrawerOpen}
|
||||||
|
onClose={onDrawerClose}
|
||||||
|
alertData={drawerAlertData}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -281,7 +281,136 @@ export const getAlertTypeColor = (alertType) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成图表标注点数据 - 支持同一时间多个异动折叠显示
|
* 将时间字符串转换为分钟数
|
||||||
|
* @param {string} timeStr - 时间字符串,如 "09:30"
|
||||||
|
* @returns {number} 分钟数
|
||||||
|
*/
|
||||||
|
const timeToMinutes = (timeStr) => {
|
||||||
|
if (!timeStr) return 0;
|
||||||
|
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取时间所属的分组区间
|
||||||
|
* @param {string} timeStr - 时间字符串
|
||||||
|
* @param {number} intervalMinutes - 分组间隔(分钟)
|
||||||
|
* @returns {string} 时间区间,如 "09:30-09:40"
|
||||||
|
*/
|
||||||
|
const getTimeGroup = (timeStr, intervalMinutes = 10) => {
|
||||||
|
const minutes = timeToMinutes(timeStr);
|
||||||
|
const groupStart = Math.floor(minutes / intervalMinutes) * intervalMinutes;
|
||||||
|
const groupEnd = groupStart + intervalMinutes;
|
||||||
|
|
||||||
|
const startHour = Math.floor(groupStart / 60);
|
||||||
|
const startMin = groupStart % 60;
|
||||||
|
const endHour = Math.floor(groupEnd / 60);
|
||||||
|
const endMin = groupEnd % 60;
|
||||||
|
|
||||||
|
const formatTime = (h, m) => `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
|
||||||
|
return `${formatTime(startHour, startMin)}-${formatTime(endHour, endMin)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成图表标注点数据 - 按时间间隔分组
|
||||||
|
* @param {Array} alerts - 异动数据数组
|
||||||
|
* @param {Array} times - 时间数组
|
||||||
|
* @param {Array} prices - 价格数组
|
||||||
|
* @param {number} priceMax - 最高价格
|
||||||
|
* @param {number} intervalMinutes - 分组间隔(分钟),默认10分钟
|
||||||
|
* @returns {Array} ECharts markPoint data
|
||||||
|
*/
|
||||||
|
export const getAlertMarkPointsGrouped = (alerts, times, prices, priceMax, intervalMinutes = 10) => {
|
||||||
|
if (!alerts || alerts.length === 0) return [];
|
||||||
|
|
||||||
|
// 1. 按时间间隔分组
|
||||||
|
const alertsByGroup = {};
|
||||||
|
alerts.forEach(alert => {
|
||||||
|
const group = getTimeGroup(alert.time, intervalMinutes);
|
||||||
|
if (!alertsByGroup[group]) {
|
||||||
|
alertsByGroup[group] = [];
|
||||||
|
}
|
||||||
|
alertsByGroup[group].push(alert);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 对每个分组内的异动按分数排序
|
||||||
|
Object.keys(alertsByGroup).forEach(group => {
|
||||||
|
alertsByGroup[group].sort((a, b) =>
|
||||||
|
(b.final_score || b.importance_score || 0) - (a.final_score || a.importance_score || 0)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 生成标记点
|
||||||
|
return Object.entries(alertsByGroup).map(([timeRange, groupAlerts]) => {
|
||||||
|
// 找到该分组中间时间点对应的坐标
|
||||||
|
const midTime = groupAlerts[Math.floor(groupAlerts.length / 2)]?.time;
|
||||||
|
const timeIndex = times.indexOf(midTime);
|
||||||
|
const price = timeIndex >= 0 ? prices[timeIndex] : priceMax;
|
||||||
|
|
||||||
|
const alertCount = groupAlerts.length;
|
||||||
|
const topAlert = groupAlerts[0];
|
||||||
|
const hasMultiple = alertCount > 1;
|
||||||
|
|
||||||
|
// 使用最高分异动的样式
|
||||||
|
const { color, gradient } = getAlertStyle(
|
||||||
|
topAlert.alert_type,
|
||||||
|
topAlert.final_score / 100 || topAlert.importance_score || 0.5
|
||||||
|
);
|
||||||
|
|
||||||
|
// 生成显示标签
|
||||||
|
const [startTime] = timeRange.split('-');
|
||||||
|
const label = hasMultiple ? `${startTime} (${alertCount})` : topAlert.concept_name?.substring(0, 4) || startTime;
|
||||||
|
|
||||||
|
const isDown = topAlert.alert_type === 'surge_down';
|
||||||
|
const symbolSize = hasMultiple ? 45 + Math.min(alertCount * 2, 15) : 35;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: timeRange,
|
||||||
|
coord: [midTime || times[0], price],
|
||||||
|
value: label,
|
||||||
|
symbol: 'pin',
|
||||||
|
symbolSize,
|
||||||
|
itemStyle: {
|
||||||
|
color: {
|
||||||
|
type: 'radial',
|
||||||
|
x: 0.5, y: 0.5, r: 0.8,
|
||||||
|
colorStops: [
|
||||||
|
{ offset: 0, color: gradient[0] },
|
||||||
|
{ offset: 0.7, color: gradient[1] },
|
||||||
|
{ offset: 1, color: `${color}88` },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
borderColor: hasMultiple ? '#ffffff' : 'rgba(255,255,255,0.8)',
|
||||||
|
borderWidth: hasMultiple ? 3 : 2,
|
||||||
|
shadowBlur: hasMultiple ? 20 : 10,
|
||||||
|
shadowColor: `${color}${hasMultiple ? 'aa' : '66'}`,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: isDown ? 'bottom' : 'top',
|
||||||
|
formatter: label,
|
||||||
|
fontSize: hasMultiple ? 11 : 10,
|
||||||
|
fontWeight: hasMultiple ? 700 : 500,
|
||||||
|
color: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
backgroundColor: 'rgba(15, 15, 25, 0.9)',
|
||||||
|
padding: hasMultiple ? [5, 10] : [3, 6],
|
||||||
|
borderRadius: 6,
|
||||||
|
borderColor: `${color}80`,
|
||||||
|
borderWidth: 1,
|
||||||
|
shadowBlur: 8,
|
||||||
|
shadowColor: `${color}40`,
|
||||||
|
},
|
||||||
|
// 存储该时间段所有异动数据
|
||||||
|
alertData: groupAlerts,
|
||||||
|
alertCount,
|
||||||
|
timeRange,
|
||||||
|
time: midTime,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成图表标注点数据 - 支持同一时间多个异动折叠显示(原有函数保留)
|
||||||
* @param {Array} alerts - 异动数据数组
|
* @param {Array} alerts - 异动数据数组
|
||||||
* @param {Array} times - 时间数组
|
* @param {Array} times - 时间数组
|
||||||
* @param {Array} prices - 价格数组
|
* @param {Array} prices - 价格数组
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ const StockOverview = () => {
|
|||||||
const cardBg = 'rgba(255, 255, 255, 0.03)'; // 玻璃态卡片背景
|
const cardBg = 'rgba(255, 255, 255, 0.03)'; // 玻璃态卡片背景
|
||||||
const borderColor = 'rgba(255, 255, 255, 0.08)'; // 边框
|
const borderColor = 'rgba(255, 255, 255, 0.08)'; // 边框
|
||||||
const hoverBg = 'rgba(255, 255, 255, 0.06)'; // 悬停背景
|
const hoverBg = 'rgba(255, 255, 255, 0.06)'; // 悬停背景
|
||||||
const searchBg = 'rgba(255, 255, 255, 0.05)'; // 搜索框背景
|
const searchBg = 'rgba(255, 255, 255, 0.15)'; // 搜索框背景(调亮)
|
||||||
const textColor = 'rgba(255, 255, 255, 0.95)'; // 主文字
|
const textColor = 'rgba(255, 255, 255, 0.95)'; // 主文字
|
||||||
const subTextColor = 'rgba(255, 255, 255, 0.6)'; // 次要文字
|
const subTextColor = 'rgba(255, 255, 255, 0.6)'; // 次要文字
|
||||||
const goldColor = '#8b5cf6'; // 使用紫色作为强调色
|
const goldColor = '#8b5cf6'; // 使用紫色作为强调色
|
||||||
|
|||||||
Reference in New Issue
Block a user