diff --git a/app.py b/app.py index a4e83821..b3718e0f 100755 --- a/app.py +++ b/app.py @@ -5801,6 +5801,23 @@ def get_stock_quotes(): if not codes: return jsonify({'success': False, 'error': '请提供股票代码'}), 400 + # 标准化股票代码(确保带后缀,用于 ClickHouse 查询) + def normalize_stock_code(code): + """将股票代码标准化为带后缀格式(如 300274.SZ)""" + if '.' in code: + return code # 已经带后缀 + # 根据代码规则添加后缀:6/0/3开头为深圳,其他为上海 + if code.startswith(('6',)): + return f"{code}.SH" + else: + return f"{code}.SZ" + + # 保留原始代码用于返回结果,同时创建标准化代码用于 ClickHouse 查询 + original_codes = codes + normalized_codes = [normalize_stock_code(code) for code in codes] + # 创建原始代码到标准化代码的映射 + code_mapping = dict(zip(original_codes, normalized_codes)) + # 处理事件时间 if event_time_str: try: @@ -5829,13 +5846,12 @@ def get_stock_quotes(): # 构建代码到名称的映射 base_name_map = {row[0]: row[1] for row in result} - # 为每个完整代码(带后缀)分配名称 - for code in codes: - base_code = code.split('.')[0] - if base_code in base_name_map: - stock_names[code] = base_name_map[base_code] - else: - stock_names[code] = f"股票{base_code}" + # 为原始代码和标准化代码都分配名称 + for orig_code, norm_code in code_mapping.items(): + base_code = orig_code.split('.')[0] + name = base_name_map.get(base_code, f"股票{base_code}") + stock_names[orig_code] = name + stock_names[norm_code] = name def get_trading_day_and_times(event_datetime): event_date = event_datetime.date() @@ -5948,11 +5964,11 @@ def get_stock_quotes(): # 构建代码到收盘价的映射(需要匹配完整代码格式) base_close_map = {row[0]: float(row[1]) if row[1] else None for row in prev_close_result} - # 为每个完整代码(带后缀)分配收盘价 - for code in codes: - base_code = code.split('.')[0] + # 为每个标准化代码(带后缀)分配收盘价,用于 ClickHouse 查询结果匹配 + for norm_code in normalized_codes: + base_code = norm_code.split('.')[0] if base_code in base_close_map: - prev_close_map[code] = base_close_map[base_code] + prev_close_map[norm_code] = base_close_map[base_code] print(f"前一交易日({prev_trading_day})收盘价查询返回 {len(prev_close_result)} 条数据") @@ -5974,7 +5990,7 @@ def get_stock_quotes(): """ batch_data = client.execute(batch_price_query, { - 'codes': codes, + 'codes': normalized_codes, # 使用标准化后的代码查询 ClickHouse 'start': start_datetime, 'end': end_datetime }) @@ -5998,41 +6014,43 @@ def get_stock_quotes(): 'change': change_pct } - # 组装结果(所有股票) - for code in codes: - price_info = price_data_map.get(code) + # 组装结果(所有股票)- 使用原始代码作为 key 返回 + for orig_code in original_codes: + norm_code = code_mapping[orig_code] + price_info = price_data_map.get(norm_code) if price_info: - results[code] = { + results[orig_code] = { 'price': price_info['price'], 'change': price_info['change'], - 'name': stock_names.get(code, f'股票{code.split(".")[0]}') + 'name': stock_names.get(orig_code, stock_names.get(norm_code, f'股票{orig_code.split(".")[0]}')) } else: # 批量查询没有返回的股票 - results[code] = { + results[orig_code] = { 'price': None, 'change': None, - 'name': stock_names.get(code, f'股票{code.split(".")[0]}') + 'name': stock_names.get(orig_code, stock_names.get(norm_code, f'股票{orig_code.split(".")[0]}')) } except Exception as e: print(f"批量查询 ClickHouse 失败: {e},回退到逐只查询") # 降级方案:逐只股票查询(使用前一交易日收盘价计算涨跌幅) - for code in codes: + for orig_code in original_codes: + norm_code = code_mapping[orig_code] try: - # 查询当前价格 + # 查询当前价格(使用标准化代码查询 ClickHouse) current_data = client.execute(""" SELECT close FROM stock_minute WHERE code = %(code)s AND timestamp >= %(start)s AND timestamp <= %(end)s ORDER BY timestamp DESC LIMIT 1 - """, {'code': code, 'start': start_datetime, 'end': end_datetime}) + """, {'code': norm_code, 'start': start_datetime, 'end': end_datetime}) last_price = float(current_data[0][0]) if current_data and current_data[0] and current_data[0][0] else None # 从 MySQL ea_trade 表查询前一交易日收盘价 prev_close = None if prev_trading_day and last_price is not None: - base_code = code.split('.')[0] + base_code = orig_code.split('.')[0] with engine.connect() as conn: prev_result = conn.execute(text(""" SELECT F007N as close_price @@ -6046,14 +6064,15 @@ def get_stock_quotes(): if last_price is not None and prev_close is not None and prev_close > 0: change_pct = (last_price - prev_close) / prev_close * 100 - results[code] = { + # 使用原始代码作为 key 返回 + results[orig_code] = { 'price': last_price, 'change': change_pct, - 'name': stock_names.get(code, f'股票{code.split(".")[0]}') + 'name': stock_names.get(orig_code, f'股票{orig_code.split(".")[0]}') } except Exception as inner_e: - print(f"Error processing stock {code}: {inner_e}") - results[code] = {'price': None, 'change': None, 'name': stock_names.get(code, f'股票{code.split(".")[0]}')} + print(f"Error processing stock {orig_code}: {inner_e}") + results[orig_code] = {'price': None, 'change': None, 'name': stock_names.get(orig_code, f'股票{orig_code.split(".")[0]}')} # 返回标准格式 return jsonify({'success': True, 'data': results}) @@ -6303,6 +6322,23 @@ def get_batch_kline_data(): if len(codes) > 50: return jsonify({'success': False, 'error': '单次最多查询50只股票'}), 400 + # 标准化股票代码(确保带后缀,用于 ClickHouse 查询) + def normalize_stock_code(code): + """将股票代码标准化为带后缀格式(如 300274.SZ)""" + if '.' in code: + return code # 已经带后缀 + # 根据代码规则添加后缀:6开头为上海,其他为深圳 + if code.startswith(('6',)): + return f"{code}.SH" + else: + return f"{code}.SZ" + + # 保留原始代码用于返回结果,同时创建标准化代码用于 ClickHouse 查询 + original_codes = codes + normalized_codes = [normalize_stock_code(code) for code in codes] + code_mapping = dict(zip(original_codes, normalized_codes)) + reverse_mapping = dict(zip(normalized_codes, original_codes)) + try: event_datetime = datetime.fromisoformat(event_time) if event_time else datetime.now() except ValueError: @@ -6333,10 +6369,10 @@ def get_batch_kline_data(): target_date = next_trade_date if not target_date: - # 返回空数据 + # 返回空数据(使用原始代码作为 key) return jsonify({ 'success': True, - 'data': {code: {'data': [], 'trade_date': event_datetime.date().strftime('%Y-%m-%d'), 'type': chart_type} for code in codes} + 'data': {code: {'data': [], 'trade_date': event_datetime.date().strftime('%Y-%m-%d'), 'type': chart_type} for code in original_codes} }) start_time = datetime.combine(target_date, dt_time(9, 30)) @@ -6345,7 +6381,7 @@ def get_batch_kline_data(): results = {} if chart_type == 'timeline': - # 批量查询分时数据 + # 批量查询分时数据(使用标准化代码查询 ClickHouse) batch_data = client.execute(""" SELECT code, timestamp, close, volume FROM stock_minute @@ -6353,31 +6389,32 @@ def get_batch_kline_data(): AND timestamp BETWEEN %(start)s AND %(end)s ORDER BY code, timestamp """, { - 'codes': codes, + 'codes': normalized_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({ + norm_code = row[0] + if norm_code not in stock_data: + stock_data[norm_code] = [] + stock_data[norm_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] + # 组装结果(使用原始代码作为 key 返回) + for orig_code in original_codes: + norm_code = code_mapping[orig_code] + base_code = orig_code.split('.')[0] stock_name = stock_names.get(base_code, f'股票{base_code}') - data_list = stock_data.get(code, []) + data_list = stock_data.get(norm_code, []) - results[code] = { - 'code': code, + results[orig_code] = { + 'code': orig_code, 'name': stock_name, 'data': data_list, 'trade_date': target_date.strftime('%Y-%m-%d'), @@ -6417,14 +6454,14 @@ def get_batch_kline_data(): 'volume': float(row[6]) if row[6] else 0 }) - # 组装结果 - for code in codes: - base_code = code.split('.')[0] + # 组装结果(使用原始代码作为 key 返回) + for orig_code in original_codes: + base_code = orig_code.split('.')[0] stock_name = stock_names.get(base_code, f'股票{base_code}') data_list = stock_data.get(base_code, []) - results[code] = { - 'code': code, + results[orig_code] = { + 'code': orig_code, 'name': stock_name, 'data': data_list, 'trade_date': target_date.strftime('%Y-%m-%d'), diff --git a/src/views/Community/components/StockDetailPanel/components/MiniTimelineChart.js b/src/views/Community/components/StockDetailPanel/components/MiniTimelineChart.js index e517188d..161a9980 100644 --- a/src/views/Community/components/StockDetailPanel/components/MiniTimelineChart.js +++ b/src/views/Community/components/StockDetailPanel/components/MiniTimelineChart.js @@ -17,9 +17,11 @@ import { * @param {string} stockCode - 股票代码 * @param {string} eventTime - 事件时间(可选) * @param {Function} onClick - 点击回调(可选) + * @param {Array} preloadedData - 预加载的K线数据(可选,由父组件批量加载后传入) + * @param {boolean} loading - 外部加载状态(可选) * @returns {JSX.Element} */ -const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eventTime, onClick }) { +const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eventTime, onClick, preloadedData, loading: externalLoading }) { const [data, setData] = useState([]); const [loading, setLoading] = useState(false); const mountedRef = useRef(true); @@ -65,6 +67,15 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve return; } + // 优先使用预加载的数据(由父组件批量请求后传入) + if (preloadedData !== undefined) { + setData(preloadedData || []); + setLoading(false); + loadedRef.current = true; + dataFetchedRef.current = true; + return; + } + // 如果已经请求过数据,不再重复请求 if (dataFetchedRef.current) { return; @@ -75,6 +86,12 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve return; } + // 如果外部正在加载,等待外部加载完成 + if (externalLoading) { + setLoading(true); + return; + } + // 检查批量请求的函数 const checkBatchAndLoad = () => { // 再次检查缓存(批量请求可能已完成) @@ -119,7 +136,6 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve } // 延迟一小段时间再检查(等待批量请求启动) - // 因为 StockTable 的 useEffect 可能还没执行 setLoading(true); const timeoutId = setTimeout(() => { if (!mountedRef.current || dataFetchedRef.current) return; @@ -129,7 +145,7 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve return; } - // 仍然没有批量请求,发起单独请求 + // 仍然没有批量请求,发起单独请求(备用方案) dataFetchedRef.current = true; fetchKlineData(stockCode, stableEventTime) @@ -147,10 +163,10 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve loadedRef.current = true; } }); - }, 50); // 延迟 50ms 等待批量请求启动 + }, 100); // 延迟 100ms 等待批量请求 return () => clearTimeout(timeoutId); - }, [stockCode, stableEventTime, loadData]); // 注意这里使用 stableEventTime + }, [stockCode, stableEventTime, loadData, preloadedData, externalLoading]); // 添加 preloadedData 和 externalLoading 依赖 const chartOption = useMemo(() => { const prices = data.map(item => item.close ?? item.price).filter(v => typeof v === 'number'); @@ -249,10 +265,12 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve ); }, (prevProps, nextProps) => { - // 自定义比较函数,只有当stockCode、eventTime或onClick变化时才重新渲染 + // 自定义比较函数 return prevProps.stockCode === nextProps.stockCode && prevProps.eventTime === nextProps.eventTime && - prevProps.onClick === nextProps.onClick; + prevProps.onClick === nextProps.onClick && + prevProps.preloadedData === nextProps.preloadedData && + prevProps.loading === nextProps.loading; }); export default MiniTimelineChart; diff --git a/src/views/Community/components/StockDetailPanel/components/StockTable.js b/src/views/Community/components/StockDetailPanel/components/StockTable.js index d272ea2f..1669c647 100644 --- a/src/views/Community/components/StockDetailPanel/components/StockTable.js +++ b/src/views/Community/components/StockDetailPanel/components/StockTable.js @@ -4,7 +4,7 @@ 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 { fetchBatchKlineData, klineDataCache, getCacheKey } from '../utils/klineDataCache'; import { logger } from '../../../../../utils/logger'; /** @@ -29,27 +29,71 @@ const StockTable = ({ }) => { // 展开/收缩的行 const [expandedRows, setExpandedRows] = useState(new Set()); + // K线数据状态:{ [stockCode]: data[] } + const [klineDataMap, setKlineDataMap] = useState({}); + const [klineLoading, setKlineLoading] = useState(false); // 稳定的事件时间,避免重复渲染 const stableEventTime = useMemo(() => { return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : ''; }, [eventTime]); - // 批量预加载K线数据 - // 使用 stocks 的 JSON 字符串作为依赖项的 key,避免引用变化导致重复预加载 + // 批量加载K线数据 + // 使用 stocks 的 JSON 字符串作为依赖项的 key,避免引用变化导致重复加载 const stocksKey = useMemo(() => { return stocks.map(s => s.stock_code).sort().join(','); }, [stocks]); useEffect(() => { - if (stocks.length > 0) { - const stockCodes = stocks.map(s => s.stock_code); - logger.debug('StockTable', '批量预加载K线数据', { - stockCount: stockCodes.length, - eventTime: stableEventTime - }); - preloadBatchKlineData(stockCodes, stableEventTime, 'timeline'); + if (stocks.length === 0) { + setKlineDataMap({}); + return; } + + const stockCodes = stocks.map(s => s.stock_code); + + // 先检查缓存,只请求未缓存的 + const cachedData = {}; + const uncachedCodes = []; + stockCodes.forEach(code => { + const cacheKey = getCacheKey(code, stableEventTime, 'timeline'); + const cached = klineDataCache.get(cacheKey); + if (cached !== undefined) { + cachedData[code] = cached; + } else { + uncachedCodes.push(code); + } + }); + + // 如果全部缓存命中,直接使用 + if (uncachedCodes.length === 0) { + setKlineDataMap(cachedData); + logger.debug('StockTable', 'K线数据全部来自缓存', { stockCount: stockCodes.length }); + return; + } + + logger.debug('StockTable', '批量加载K线数据', { + totalCount: stockCodes.length, + cachedCount: Object.keys(cachedData).length, + uncachedCount: uncachedCodes.length, + eventTime: stableEventTime + }); + + setKlineLoading(true); + + // 批量请求未缓存的数据 + fetchBatchKlineData(stockCodes, stableEventTime, 'timeline') + .then((batchData) => { + // 合并缓存数据和新数据 + setKlineDataMap({ ...cachedData, ...batchData }); + setKlineLoading(false); + }) + .catch((error) => { + logger.error('StockTable', '批量加载K线数据失败', error); + // 失败时使用已有的缓存数据 + setKlineDataMap(cachedData); + setKlineLoading(false); + }); }, [stocksKey, stableEventTime]); // 使用 stocksKey 而非 stocks 对象引用 // 切换行展开状态 @@ -175,6 +219,8 @@ const StockTable = ({ ), }, @@ -225,7 +271,7 @@ const StockTable = ({ ); }, }, - ], [quotes, stableEventTime, expandedRows, toggleRowExpand, watchlistSet, onWatchlistToggle]); + ], [quotes, stableEventTime, expandedRows, toggleRowExpand, watchlistSet, onWatchlistToggle, klineDataMap, klineLoading]); return (