update pay ui
This commit is contained in:
158
app.py
158
app.py
@@ -6285,6 +6285,164 @@ def get_stock_kline(stock_code):
|
||||
return jsonify({'error': f'Unsupported chart type: {chart_type}'}), 400
|
||||
|
||||
|
||||
@app.route('/api/stock/batch-kline', methods=['POST'])
|
||||
def get_batch_kline_data():
|
||||
"""批量获取多只股票的K线/分时数据
|
||||
请求体:{ codes: string[], type: 'timeline'|'daily', event_time?: string }
|
||||
返回:{ success: true, data: { [code]: { data: [], trade_date: '', ... } } }
|
||||
"""
|
||||
try:
|
||||
data = request.json
|
||||
codes = data.get('codes', [])
|
||||
chart_type = data.get('type', 'timeline')
|
||||
event_time = data.get('event_time')
|
||||
|
||||
if not codes:
|
||||
return jsonify({'success': False, 'error': '请提供股票代码列表'}), 400
|
||||
|
||||
if len(codes) > 50:
|
||||
return jsonify({'success': False, 'error': '单次最多查询50只股票'}), 400
|
||||
|
||||
try:
|
||||
event_datetime = datetime.fromisoformat(event_time) if event_time else datetime.now()
|
||||
except ValueError:
|
||||
return jsonify({'success': False, 'error': 'Invalid event_time format'}), 400
|
||||
|
||||
client = get_clickhouse_client()
|
||||
|
||||
# 批量获取股票名称
|
||||
stock_names = {}
|
||||
with engine.connect() as conn:
|
||||
base_codes = list(set([code.split('.')[0] for code in codes]))
|
||||
if base_codes:
|
||||
placeholders = ','.join([f':code{i}' for i in range(len(base_codes))])
|
||||
params = {f'code{i}': code for i, code in enumerate(base_codes)}
|
||||
result = conn.execute(text(
|
||||
f"SELECT SECCODE, SECNAME FROM ea_stocklist WHERE SECCODE IN ({placeholders})"
|
||||
), params).fetchall()
|
||||
for row in result:
|
||||
stock_names[row[0]] = row[1]
|
||||
|
||||
# 确定目标交易日
|
||||
target_date = get_trading_day_near_date(event_datetime.date())
|
||||
is_after_market = event_datetime.time() > dt_time(15, 0)
|
||||
|
||||
if target_date and is_after_market:
|
||||
next_trade_date = get_trading_day_near_date(target_date + timedelta(days=1))
|
||||
if next_trade_date:
|
||||
target_date = next_trade_date
|
||||
|
||||
if not target_date:
|
||||
# 返回空数据
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {code: {'data': [], 'trade_date': event_datetime.date().strftime('%Y-%m-%d'), 'type': chart_type} for code in codes}
|
||||
})
|
||||
|
||||
start_time = datetime.combine(target_date, dt_time(9, 30))
|
||||
end_time = datetime.combine(target_date, dt_time(15, 0))
|
||||
|
||||
results = {}
|
||||
|
||||
if chart_type == 'timeline':
|
||||
# 批量查询分时数据
|
||||
batch_data = client.execute("""
|
||||
SELECT code, timestamp, close, volume
|
||||
FROM stock_minute
|
||||
WHERE code IN %(codes)s
|
||||
AND timestamp BETWEEN %(start)s AND %(end)s
|
||||
ORDER BY code, timestamp
|
||||
""", {
|
||||
'codes': codes,
|
||||
'start': start_time,
|
||||
'end': end_time
|
||||
})
|
||||
|
||||
# 按股票代码分组
|
||||
stock_data = {}
|
||||
for row in batch_data:
|
||||
code = row[0]
|
||||
if code not in stock_data:
|
||||
stock_data[code] = []
|
||||
stock_data[code].append({
|
||||
'time': row[1].strftime('%H:%M'),
|
||||
'price': float(row[2]),
|
||||
'volume': float(row[3])
|
||||
})
|
||||
|
||||
# 组装结果
|
||||
for code in codes:
|
||||
base_code = code.split('.')[0]
|
||||
stock_name = stock_names.get(base_code, f'股票{base_code}')
|
||||
data_list = stock_data.get(code, [])
|
||||
|
||||
results[code] = {
|
||||
'code': code,
|
||||
'name': stock_name,
|
||||
'data': data_list,
|
||||
'trade_date': target_date.strftime('%Y-%m-%d'),
|
||||
'type': 'timeline'
|
||||
}
|
||||
|
||||
elif chart_type == 'daily':
|
||||
# 批量查询日线数据(从MySQL ea_trade表)
|
||||
with engine.connect() as conn:
|
||||
base_codes = list(set([code.split('.')[0] for code in codes]))
|
||||
if base_codes:
|
||||
placeholders = ','.join([f':code{i}' for i in range(len(base_codes))])
|
||||
params = {f'code{i}': code for i, code in enumerate(base_codes)}
|
||||
params['start_date'] = target_date - timedelta(days=60)
|
||||
params['end_date'] = target_date
|
||||
|
||||
daily_result = conn.execute(text(f"""
|
||||
SELECT SECCODE, TRADEDATE, F003N as open, F005N as high, F006N as low, F007N as close, F004N as volume
|
||||
FROM ea_trade
|
||||
WHERE SECCODE IN ({placeholders})
|
||||
AND TRADEDATE BETWEEN :start_date AND :end_date
|
||||
ORDER BY SECCODE, TRADEDATE
|
||||
"""), params).fetchall()
|
||||
|
||||
# 按股票代码分组
|
||||
stock_data = {}
|
||||
for row in daily_result:
|
||||
code_base = row[0]
|
||||
if code_base not in stock_data:
|
||||
stock_data[code_base] = []
|
||||
stock_data[code_base].append({
|
||||
'date': row[1].strftime('%Y-%m-%d') if hasattr(row[1], 'strftime') else str(row[1]),
|
||||
'open': float(row[2]) if row[2] else 0,
|
||||
'high': float(row[3]) if row[3] else 0,
|
||||
'low': float(row[4]) if row[4] else 0,
|
||||
'close': float(row[5]) if row[5] else 0,
|
||||
'volume': float(row[6]) if row[6] else 0
|
||||
})
|
||||
|
||||
# 组装结果
|
||||
for code in codes:
|
||||
base_code = code.split('.')[0]
|
||||
stock_name = stock_names.get(base_code, f'股票{base_code}')
|
||||
data_list = stock_data.get(base_code, [])
|
||||
|
||||
results[code] = {
|
||||
'code': code,
|
||||
'name': stock_name,
|
||||
'data': data_list,
|
||||
'trade_date': target_date.strftime('%Y-%m-%d'),
|
||||
'type': 'daily'
|
||||
}
|
||||
|
||||
print(f"批量K线查询完成: {len(codes)} 只股票, 类型: {chart_type}, 交易日: {target_date}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': results
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"批量K线查询错误: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/stock/<stock_code>/latest-minute', methods=['GET'])
|
||||
def get_latest_minute_data(stock_code):
|
||||
"""获取最新交易日的分钟频数据"""
|
||||
|
||||
@@ -358,6 +358,37 @@ export const stockService = {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 批量获取多只股票的K线数据
|
||||
* @param {string[]} stockCodes - 股票代码数组
|
||||
* @param {string} chartType - 图表类型 (timeline/daily)
|
||||
* @param {string} eventTime - 事件时间
|
||||
* @returns {Promise<Object>} { [stockCode]: data[] }
|
||||
*/
|
||||
getBatchKlineData: async (stockCodes, chartType = 'timeline', eventTime = null) => {
|
||||
try {
|
||||
const requestBody = {
|
||||
codes: stockCodes,
|
||||
type: chartType
|
||||
};
|
||||
if (eventTime) {
|
||||
requestBody.event_time = eventTime;
|
||||
}
|
||||
|
||||
logger.debug('stockService', '批量获取K线数据', { stockCount: stockCodes.length, chartType, eventTime });
|
||||
|
||||
const response = await apiRequest('/api/stock/batch-kline', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
logger.error('stockService', 'getBatchKlineData', error, { stockCodes, chartType });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
getTransmissionChainAnalysis: async (eventId) => {
|
||||
return await apiRequest(`/api/events/${eventId}/transmission`);
|
||||
},
|
||||
|
||||
@@ -122,8 +122,8 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
||||
const canAccessTransmission = hasAccess('max');
|
||||
|
||||
// 子区块折叠状态管理 + 加载追踪
|
||||
// 相关股票默认折叠,只显示数量吸引点击
|
||||
const [isStocksOpen, setIsStocksOpen] = useState(false);
|
||||
// 相关股票默认展开
|
||||
const [isStocksOpen, setIsStocksOpen] = useState(true);
|
||||
const [hasLoadedStocks, setHasLoadedStocks] = useState(false); // 股票列表是否已加载(获取数量)
|
||||
const [hasLoadedQuotes, setHasLoadedQuotes] = useState(false); // 行情数据是否已加载
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/views/Community/components/EventCard/EventPriceDisplay.js
|
||||
import React from 'react';
|
||||
import { HStack, Badge, Text, Tooltip } from '@chakra-ui/react';
|
||||
import React, { useState } from 'react';
|
||||
import { HStack, Box, Text, Tooltip, Progress } from '@chakra-ui/react';
|
||||
import { PriceArrow } from '../../../../utils/priceFormatters';
|
||||
|
||||
/**
|
||||
@@ -8,17 +8,20 @@ import { PriceArrow } from '../../../../utils/priceFormatters';
|
||||
* @param {Object} props
|
||||
* @param {number|null} props.avgChange - 平均涨跌幅
|
||||
* @param {number|null} props.maxChange - 最大涨跌幅
|
||||
* @param {number|null} props.weekChange - 周涨跌幅
|
||||
* @param {number|null} props.expectationScore - 超预期得分(满分100)
|
||||
* @param {boolean} props.compact - 是否为紧凑模式(只显示平均值,默认 false)
|
||||
* @param {boolean} props.inline - 是否内联显示(默认 false)
|
||||
*/
|
||||
const EventPriceDisplay = ({
|
||||
avgChange,
|
||||
maxChange,
|
||||
weekChange,
|
||||
expectationScore,
|
||||
compact = false,
|
||||
inline = false
|
||||
}) => {
|
||||
// 点击切换显示最大超额/平均超额
|
||||
const [showAvg, setShowAvg] = useState(false);
|
||||
|
||||
// 获取颜色方案
|
||||
const getColorScheme = (value) => {
|
||||
if (value == null) return 'gray';
|
||||
@@ -31,12 +34,23 @@ const EventPriceDisplay = ({
|
||||
return `${value > 0 ? '+' : ''}${value.toFixed(2)}%`;
|
||||
};
|
||||
|
||||
// 获取超预期得分的颜色(渐变色系)
|
||||
const getScoreColor = (score) => {
|
||||
if (score == null) return { bg: 'gray.100', color: 'gray.500', progressColor: 'gray' };
|
||||
if (score >= 80) return { bg: 'red.50', color: 'red.600', progressColor: 'red' };
|
||||
if (score >= 60) return { bg: 'orange.50', color: 'orange.600', progressColor: 'orange' };
|
||||
if (score >= 40) return { bg: 'yellow.50', color: 'yellow.700', progressColor: 'yellow' };
|
||||
if (score >= 20) return { bg: 'blue.50', color: 'blue.600', progressColor: 'blue' };
|
||||
return { bg: 'gray.50', color: 'gray.600', progressColor: 'gray' };
|
||||
};
|
||||
|
||||
// 紧凑模式:只显示平均值,内联在标题后
|
||||
if (compact && avgChange != null) {
|
||||
return (
|
||||
<Tooltip label="平均" placement="top">
|
||||
<Badge
|
||||
colorScheme={getColorScheme(avgChange)}
|
||||
<Tooltip label="平均超额" placement="top">
|
||||
<Box
|
||||
bg={avgChange > 0 ? 'red.50' : avgChange < 0 ? 'green.50' : 'gray.100'}
|
||||
color={avgChange > 0 ? 'red.600' : avgChange < 0 ? 'green.600' : 'gray.500'}
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
py={1}
|
||||
@@ -49,71 +63,91 @@ const EventPriceDisplay = ({
|
||||
>
|
||||
<PriceArrow value={avgChange} />
|
||||
{formatPercent(avgChange)}
|
||||
</Badge>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// 详细模式:显示所有价格变动
|
||||
const displayValue = showAvg ? avgChange : maxChange;
|
||||
const displayLabel = showAvg ? '平均超额' : '最大超额';
|
||||
const scoreColors = getScoreColor(expectationScore);
|
||||
|
||||
// 详细模式:显示最大超额(可点击切换)+ 超预期得分
|
||||
return (
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{/* 平均涨幅 - 始终显示,无数据时显示 -- */}
|
||||
<Badge
|
||||
colorScheme={getColorScheme(avgChange)}
|
||||
<HStack spacing={3} flexWrap="wrap">
|
||||
{/* 最大超额/平均超额 - 点击切换 */}
|
||||
<Tooltip
|
||||
label={showAvg ? "点击查看最大超额" : "点击查看平均超额"}
|
||||
placement="top"
|
||||
hasArrow
|
||||
>
|
||||
<Box
|
||||
bg={displayValue > 0 ? 'red.50' : displayValue < 0 ? 'green.50' : 'gray.100'}
|
||||
color={displayValue > 0 ? 'red.600' : displayValue < 0 ? 'green.600' : 'gray.500'}
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
py={0.5}
|
||||
px={2.5}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
cursor="pointer"
|
||||
_hover={{ transform: 'scale(1.05)', boxShadow: 'md' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowAvg(!showAvg);
|
||||
}}
|
||||
_hover={{
|
||||
transform: 'scale(1.02)',
|
||||
boxShadow: 'sm',
|
||||
opacity: 0.9
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
border="1px solid"
|
||||
borderColor={displayValue > 0 ? 'red.200' : displayValue < 0 ? 'green.200' : 'gray.200'}
|
||||
>
|
||||
<HStack spacing={1}>
|
||||
<Text fontSize="xs" opacity={0.8}>平均</Text>
|
||||
<Text fontWeight="bold">
|
||||
{formatPercent(avgChange)}
|
||||
<HStack spacing={1.5}>
|
||||
<Text fontSize="xs" opacity={0.7} fontWeight="medium">{displayLabel}</Text>
|
||||
<Text fontWeight="bold" fontSize="sm">
|
||||
{formatPercent(displayValue)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Badge>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
{/* 最大涨幅 - 始终显示,无数据时显示 -- */}
|
||||
<Badge
|
||||
colorScheme={getColorScheme(maxChange)}
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
py={0.5}
|
||||
borderRadius="md"
|
||||
cursor="pointer"
|
||||
_hover={{ transform: 'scale(1.05)', boxShadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
{/* 超预期得分 - 精致的进度条样式 */}
|
||||
{expectationScore != null && (
|
||||
<Tooltip
|
||||
label={`超预期得分:${expectationScore.toFixed(0)}分(满分100分)`}
|
||||
placement="top"
|
||||
hasArrow
|
||||
>
|
||||
<HStack spacing={1}>
|
||||
<Text fontSize="xs" opacity={0.8}>最大</Text>
|
||||
<Text fontWeight="bold">
|
||||
{formatPercent(maxChange)}
|
||||
<Box
|
||||
bg={scoreColors.bg}
|
||||
px={2.5}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
border="1px solid"
|
||||
borderColor={`${scoreColors.progressColor}.200`}
|
||||
minW="90px"
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
<Text fontSize="xs" color={scoreColors.color} fontWeight="medium" opacity={0.8}>
|
||||
超预期
|
||||
</Text>
|
||||
<Box flex={1} minW="40px">
|
||||
<Progress
|
||||
value={expectationScore}
|
||||
max={100}
|
||||
size="xs"
|
||||
colorScheme={scoreColors.progressColor}
|
||||
borderRadius="full"
|
||||
bg={`${scoreColors.progressColor}.100`}
|
||||
/>
|
||||
</Box>
|
||||
<Text fontSize="xs" fontWeight="bold" color={scoreColors.color}>
|
||||
{expectationScore.toFixed(0)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Badge>
|
||||
|
||||
{/* 周涨幅 - 始终显示,无数据时显示 -- */}
|
||||
<Badge
|
||||
colorScheme={getColorScheme(weekChange)}
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
py={0.5}
|
||||
borderRadius="md"
|
||||
cursor="pointer"
|
||||
_hover={{ transform: 'scale(1.05)', boxShadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<HStack spacing={1}>
|
||||
<Text fontSize="xs" opacity={0.8}>周</Text>
|
||||
{weekChange != null && <PriceArrow value={weekChange} />}
|
||||
<Text fontWeight="bold">
|
||||
{formatPercent(weekChange)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Badge>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// src/views/Community/components/StockDetailPanel/components/MiniTimelineChart.js
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import * as echarts from 'echarts';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
fetchKlineData,
|
||||
getCacheKey,
|
||||
klineDataCache
|
||||
klineDataCache,
|
||||
batchPendingRequests
|
||||
} from '../utils/klineDataCache';
|
||||
|
||||
/**
|
||||
@@ -37,6 +38,25 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 从缓存或API获取数据的函数
|
||||
const loadData = useCallback(() => {
|
||||
if (!stockCode || !mountedRef.current) return;
|
||||
|
||||
// 检查缓存
|
||||
const cacheKey = getCacheKey(stockCode, stableEventTime);
|
||||
const cachedData = klineDataCache.get(cacheKey);
|
||||
|
||||
// 如果有缓存数据,直接使用
|
||||
if (cachedData && cachedData.length > 0) {
|
||||
setData(cachedData);
|
||||
setLoading(false);
|
||||
loadedRef.current = true;
|
||||
dataFetchedRef.current = true;
|
||||
return true; // 表示数据已加载
|
||||
}
|
||||
return false; // 表示需要请求
|
||||
}, [stockCode, stableEventTime]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!stockCode) {
|
||||
setData([]);
|
||||
@@ -50,19 +70,34 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查缓存
|
||||
const cacheKey = getCacheKey(stockCode, stableEventTime);
|
||||
const cachedData = klineDataCache.get(cacheKey);
|
||||
|
||||
// 如果有缓存数据,直接使用
|
||||
if (cachedData && cachedData.length > 0) {
|
||||
setData(cachedData);
|
||||
loadedRef.current = true;
|
||||
dataFetchedRef.current = true;
|
||||
// 尝试从缓存加载
|
||||
if (loadData()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 标记正在请求
|
||||
// 检查是否有正在进行的批量请求
|
||||
const batchKey = `${stableEventTime || 'today'}|timeline`;
|
||||
const pendingBatch = batchPendingRequests.get(batchKey);
|
||||
|
||||
if (pendingBatch) {
|
||||
// 等待批量请求完成后再从缓存读取
|
||||
setLoading(true);
|
||||
dataFetchedRef.current = true;
|
||||
pendingBatch.then(() => {
|
||||
if (mountedRef.current) {
|
||||
loadData();
|
||||
setLoading(false);
|
||||
}
|
||||
}).catch(() => {
|
||||
if (mountedRef.current) {
|
||||
setData([]);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 没有批量请求,发起单独请求
|
||||
dataFetchedRef.current = true;
|
||||
setLoading(true);
|
||||
|
||||
@@ -82,7 +117,7 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
|
||||
loadedRef.current = true;
|
||||
}
|
||||
});
|
||||
}, [stockCode, stableEventTime]); // 注意这里使用 stableEventTime
|
||||
}, [stockCode, stableEventTime, loadData]); // 注意这里使用 stableEventTime
|
||||
|
||||
const chartOption = useMemo(() => {
|
||||
const prices = data.map(item => item.close ?? item.price).filter(v => typeof v === 'number');
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// src/views/Community/components/StockDetailPanel/components/StockTable.js
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
||||
import { Table, Button } from 'antd';
|
||||
import { StarFilled, StarOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import MiniTimelineChart from './MiniTimelineChart';
|
||||
import { preloadBatchKlineData } from '../utils/klineDataCache';
|
||||
import { logger } from '../../../../../utils/logger';
|
||||
|
||||
/**
|
||||
@@ -28,12 +29,31 @@ const StockTable = ({
|
||||
}) => {
|
||||
// 展开/收缩的行
|
||||
const [expandedRows, setExpandedRows] = useState(new Set());
|
||||
const preloadedRef = useRef(false); // 标记是否已预加载
|
||||
|
||||
// 稳定的事件时间,避免重复渲染
|
||||
const stableEventTime = useMemo(() => {
|
||||
return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
}, [eventTime]);
|
||||
|
||||
// 批量预加载K线数据
|
||||
useEffect(() => {
|
||||
if (stocks.length > 0 && !preloadedRef.current) {
|
||||
const stockCodes = stocks.map(s => s.stock_code);
|
||||
logger.debug('StockTable', '批量预加载K线数据', {
|
||||
stockCount: stockCodes.length,
|
||||
eventTime: stableEventTime
|
||||
});
|
||||
preloadBatchKlineData(stockCodes, stableEventTime, 'timeline');
|
||||
preloadedRef.current = true;
|
||||
}
|
||||
}, [stocks, stableEventTime]);
|
||||
|
||||
// 当股票列表变化时重置预加载标记
|
||||
useEffect(() => {
|
||||
preloadedRef.current = false;
|
||||
}, [stocks.length]);
|
||||
|
||||
// 切换行展开状态
|
||||
const toggleRowExpand = useCallback((stockCode) => {
|
||||
setExpandedRows(prev => {
|
||||
|
||||
@@ -4,9 +4,10 @@ import { stockService } from '../../../../../services/eventService';
|
||||
import { logger } from '../../../../../utils/logger';
|
||||
|
||||
// ================= 全局缓存和请求管理 =================
|
||||
export const klineDataCache = new Map(); // 缓存K线数据: key = `${code}|${date}` -> data
|
||||
export const pendingRequests = new Map(); // 正在进行的请求: key = `${code}|${date}` -> Promise
|
||||
export const lastRequestTime = new Map(); // 最后请求时间: key = `${code}|${date}` -> timestamp
|
||||
export const klineDataCache = new Map(); // 缓存K线数据: key = `${code}|${date}|${chartType}` -> data
|
||||
export const pendingRequests = new Map(); // 正在进行的请求: key = `${code}|${date}|${chartType}` -> Promise
|
||||
export const lastRequestTime = new Map(); // 最后请求时间: key = `${code}|${date}|${chartType}` -> timestamp
|
||||
export const batchPendingRequests = new Map(); // 批量请求的 Promise: key = `${eventTime}|${chartType}` -> Promise
|
||||
|
||||
// 请求间隔限制(毫秒)
|
||||
const REQUEST_INTERVAL = 30000; // 30秒内不重复请求同一只股票的数据
|
||||
@@ -157,3 +158,131 @@ export const getCacheStats = () => {
|
||||
cacheKeys: Array.from(klineDataCache.keys())
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量获取多只股票的K线数据(一次API请求)
|
||||
* @param {string[]} stockCodes - 股票代码数组
|
||||
* @param {string} eventTime - 事件时间
|
||||
* @param {string} chartType - 图表类型(timeline/daily)
|
||||
* @returns {Promise<Object>} 股票代码到K线数据的映射 { [stockCode]: data[] }
|
||||
*/
|
||||
export const fetchBatchKlineData = async (stockCodes, eventTime, chartType = 'timeline') => {
|
||||
if (!stockCodes || stockCodes.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const normalizedEventTime = eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : undefined;
|
||||
const batchKey = `${normalizedEventTime || 'today'}|${chartType}`;
|
||||
|
||||
// 过滤出未缓存的股票
|
||||
const uncachedCodes = stockCodes.filter(code => {
|
||||
const cacheKey = getCacheKey(code, eventTime, chartType);
|
||||
return !klineDataCache.has(cacheKey) || shouldRefreshData(cacheKey);
|
||||
});
|
||||
|
||||
logger.debug('klineDataCache', '批量请求分析', {
|
||||
totalCodes: stockCodes.length,
|
||||
uncachedCodes: uncachedCodes.length,
|
||||
cachedCodes: stockCodes.length - uncachedCodes.length
|
||||
});
|
||||
|
||||
// 如果所有股票都有缓存,直接返回缓存数据
|
||||
if (uncachedCodes.length === 0) {
|
||||
const result = {};
|
||||
stockCodes.forEach(code => {
|
||||
const cacheKey = getCacheKey(code, eventTime, chartType);
|
||||
result[code] = klineDataCache.get(cacheKey) || [];
|
||||
});
|
||||
logger.debug('klineDataCache', '所有股票数据来自缓存', { stockCount: stockCodes.length });
|
||||
return result;
|
||||
}
|
||||
|
||||
// 检查是否有正在进行的批量请求
|
||||
if (batchPendingRequests.has(batchKey)) {
|
||||
logger.debug('klineDataCache', '等待进行中的批量请求', { batchKey });
|
||||
return batchPendingRequests.get(batchKey);
|
||||
}
|
||||
|
||||
// 发起批量请求
|
||||
logger.debug('klineDataCache', '发起批量K线数据请求', {
|
||||
batchKey,
|
||||
stockCount: uncachedCodes.length,
|
||||
chartType
|
||||
});
|
||||
|
||||
const requestPromise = stockService
|
||||
.getBatchKlineData(uncachedCodes, chartType, normalizedEventTime)
|
||||
.then((response) => {
|
||||
const batchData = response?.data || {};
|
||||
const now = Date.now();
|
||||
|
||||
// 将批量数据存入缓存
|
||||
Object.entries(batchData).forEach(([code, stockData]) => {
|
||||
const data = Array.isArray(stockData?.data) ? stockData.data : [];
|
||||
const cacheKey = getCacheKey(code, eventTime, chartType);
|
||||
klineDataCache.set(cacheKey, data);
|
||||
lastRequestTime.set(cacheKey, now);
|
||||
});
|
||||
|
||||
// 对于请求中没有返回数据的股票,设置空数组
|
||||
uncachedCodes.forEach(code => {
|
||||
if (!batchData[code]) {
|
||||
const cacheKey = getCacheKey(code, eventTime, chartType);
|
||||
if (!klineDataCache.has(cacheKey)) {
|
||||
klineDataCache.set(cacheKey, []);
|
||||
lastRequestTime.set(cacheKey, now);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 清除批量请求状态
|
||||
batchPendingRequests.delete(batchKey);
|
||||
|
||||
logger.debug('klineDataCache', '批量K线数据请求完成', {
|
||||
batchKey,
|
||||
stockCount: Object.keys(batchData).length
|
||||
});
|
||||
|
||||
// 返回所有请求股票的数据(包括之前缓存的)
|
||||
const result = {};
|
||||
stockCodes.forEach(code => {
|
||||
const cacheKey = getCacheKey(code, eventTime, chartType);
|
||||
result[code] = klineDataCache.get(cacheKey) || [];
|
||||
});
|
||||
return result;
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('klineDataCache', 'fetchBatchKlineData', error, {
|
||||
stockCount: uncachedCodes.length,
|
||||
chartType
|
||||
});
|
||||
// 清除批量请求状态
|
||||
batchPendingRequests.delete(batchKey);
|
||||
|
||||
// 返回已缓存的数据
|
||||
const result = {};
|
||||
stockCodes.forEach(code => {
|
||||
const cacheKey = getCacheKey(code, eventTime, chartType);
|
||||
result[code] = klineDataCache.get(cacheKey) || [];
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
// 保存批量请求
|
||||
batchPendingRequests.set(batchKey, requestPromise);
|
||||
|
||||
return requestPromise;
|
||||
};
|
||||
|
||||
/**
|
||||
* 预加载多只股票的K线数据(后台执行,不阻塞UI)
|
||||
* @param {string[]} stockCodes - 股票代码数组
|
||||
* @param {string} eventTime - 事件时间
|
||||
* @param {string} chartType - 图表类型(timeline/daily)
|
||||
*/
|
||||
export const preloadBatchKlineData = (stockCodes, eventTime, chartType = 'timeline') => {
|
||||
// 异步执行,不返回Promise,不阻塞调用方
|
||||
fetchBatchKlineData(stockCodes, eventTime, chartType).catch(() => {
|
||||
// 静默处理错误,预加载失败不影响用户体验
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user