diff --git a/app.py b/app.py index 897279a9..fc1b8735 100755 --- a/app.py +++ b/app.py @@ -6314,14 +6314,22 @@ def get_stock_kline(stock_code): @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: '', ... } } } + 请求体:{ + codes: string[], + type: 'timeline'|'daily', + event_time?: string, + days_before?: number, # 查询事件日期前多少天的数据,默认60,最大365 + end_date?: string # 分页加载时指定结束日期(用于加载更早的数据) + } + 返回:{ success: true, data: { [code]: { data: [], trade_date: '', ... } }, has_more: boolean } """ try: data = request.json codes = data.get('codes', []) chart_type = data.get('type', 'timeline') event_time = data.get('event_time') + days_before = min(int(data.get('days_before', 60)), 365) # 默认60天,最多365天 + custom_end_date = data.get('end_date') # 用于分页加载更早数据 if not codes: return jsonify({'success': False, 'error': '请提供股票代码列表'}), 400 @@ -6435,10 +6443,21 @@ def get_batch_kline_data(): 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)} + + # 确定查询的日期范围 + # 如果指定了 custom_end_date,用于分页加载更早的数据 + if custom_end_date: + try: + end_date_obj = datetime.strptime(custom_end_date, '%Y-%m-%d').date() + except ValueError: + end_date_obj = target_date + else: + end_date_obj = target_date + # TRADEDATE 是整数格式 YYYYMMDD,需要转换日期格式 - start_date = target_date - timedelta(days=60) + start_date = end_date_obj - timedelta(days=days_before) params['start_date'] = int(start_date.strftime('%Y%m%d')) - params['end_date'] = int(target_date.strftime('%Y%m%d')) + params['end_date'] = int(end_date_obj.strftime('%Y%m%d')) daily_result = conn.execute(text(f""" SELECT SECCODE, TRADEDATE, F003N as open, F005N as high, F006N as low, F007N as close, F004N as volume @@ -6473,24 +6492,40 @@ def get_batch_kline_data(): }) # 组装结果(使用原始代码作为 key 返回) + # 同时计算最早日期,用于判断是否还有更多数据 + earliest_dates = {} 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, []) + # 记录每只股票的最早日期 + if data_list: + earliest_dates[orig_code] = data_list[0]['time'] + results[orig_code] = { 'code': orig_code, 'name': stock_name, 'data': data_list, 'trade_date': target_date.strftime('%Y-%m-%d'), - 'type': 'daily' + 'type': 'daily', + 'earliest_date': data_list[0]['time'] if data_list else None } - print(f"批量K线查询完成: {len(codes)} 只股票, 类型: {chart_type}, 交易日: {target_date}") + # 计算是否还有更多历史数据(基于事件日期往前推365天) + event_date = event_datetime.date() + one_year_ago = event_date - timedelta(days=365) + # 如果当前查询的起始日期还没到一年前,则还有更多数据 + has_more = start_date > one_year_ago if chart_type == 'daily' else False + + print(f"批量K线查询完成: {len(codes)} 只股票, 类型: {chart_type}, 交易日: {target_date}, days_before: {days_before}, has_more: {has_more}") return jsonify({ 'success': True, - 'data': results + 'data': results, + 'has_more': has_more, + 'query_start_date': start_date.strftime('%Y-%m-%d') if chart_type == 'daily' else None, + 'query_end_date': end_date_obj.strftime('%Y-%m-%d') if chart_type == 'daily' else None }) except Exception as e: diff --git a/src/components/StockChart/KLineChartModal.tsx b/src/components/StockChart/KLineChartModal.tsx index 893670a4..75492d53 100644 --- a/src/components/StockChart/KLineChartModal.tsx +++ b/src/components/StockChart/KLineChartModal.tsx @@ -1,9 +1,9 @@ // src/components/StockChart/KLineChartModal.tsx - K线图弹窗组件 -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState, useCallback } from 'react'; import { createPortal } from 'react-dom'; import * as echarts from 'echarts'; import dayjs from 'dayjs'; -import { klineDataCache, getCacheKey, fetchKlineData } from '@views/Community/components/StockDetailPanel/utils/klineDataCache'; +import { stockService } from '@services/eventService'; /** * 股票信息 @@ -41,6 +41,31 @@ interface KLineDataPoint { volume: number; } +/** + * 批量K线API响应 + */ +interface BatchKlineResponse { + success: boolean; + data: { + [stockCode: string]: { + code: string; + name: string; + data: KLineDataPoint[]; + trade_date: string; + type: string; + earliest_date?: string; + }; + }; + has_more: boolean; + query_start_date?: string; + query_end_date?: string; +} + +// 每次加载的天数 +const DAYS_PER_LOAD = 60; +// 最大加载天数(一年) +const MAX_DAYS = 365; + const KLineChartModal: React.FC = ({ isOpen, onClose, @@ -51,8 +76,12 @@ const KLineChartModal: React.FC = ({ const chartRef = useRef(null); const chartInstance = useRef(null); const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); const [data, setData] = useState([]); + const [hasMore, setHasMore] = useState(true); + const [earliestDate, setEarliestDate] = useState(null); + const [totalDaysLoaded, setTotalDaysLoaded] = useState(0); // 调试日志 console.log('[KLineChartModal] 渲染状态:', { @@ -61,48 +90,102 @@ const KLineChartModal: React.FC = ({ eventTime, dataLength: data.length, loading, - error + loadingMore, + hasMore, + earliestDate }); - // 加载K线数据(优先使用缓存) - const loadData = async () => { + // 加载更多历史数据 + const loadMoreData = useCallback(async () => { + if (!stock?.stock_code || !hasMore || loadingMore || !earliestDate) return; + + console.log('[KLineChartModal] 加载更多历史数据, earliestDate:', earliestDate); + setLoadingMore(true); + + try { + const stableEventTime = eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : ''; + + // 请求更早的数据,end_date 设置为当前最早日期的前一天 + const endDate = dayjs(earliestDate).subtract(1, 'day').format('YYYY-MM-DD'); + + const response = await stockService.getBatchKlineData( + [stock.stock_code], + 'daily', + stableEventTime, + { days_before: DAYS_PER_LOAD, end_date: endDate } + ) as BatchKlineResponse; + + if (response?.success && response.data) { + const stockData = response.data[stock.stock_code]; + const newData = stockData?.data || []; + + if (newData.length > 0) { + // 将新数据添加到现有数据的前面 + setData(prevData => [...newData, ...prevData]); + setEarliestDate(newData[0].time); + setTotalDaysLoaded(prev => prev + DAYS_PER_LOAD); + console.log('[KLineChartModal] 加载了更多数据:', newData.length, '条'); + } + + // 检查是否还有更多数据 + const noMoreData = !response.has_more || totalDaysLoaded + DAYS_PER_LOAD >= MAX_DAYS || newData.length === 0; + setHasMore(!noMoreData); + } + } catch (err) { + console.error('[KLineChartModal] 加载更多数据失败:', err); + } finally { + setLoadingMore(false); + } + }, [stock?.stock_code, hasMore, loadingMore, earliestDate, eventTime, totalDaysLoaded]); + + // 初始加载K线数据 + const loadData = useCallback(async () => { if (!stock?.stock_code) return; setLoading(true); setError(null); + setData([]); + setHasMore(true); + setEarliestDate(null); + setTotalDaysLoaded(0); try { - // 标准化事件时间 const stableEventTime = eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : ''; - // 先检查缓存 - const cacheKey = getCacheKey(stock.stock_code, stableEventTime, 'daily'); - const cachedData = klineDataCache.get(cacheKey); + // 使用新的带分页参数的接口 + const response = await stockService.getBatchKlineData( + [stock.stock_code], + 'daily', + stableEventTime, + { days_before: DAYS_PER_LOAD, end_date: '' } + ) as BatchKlineResponse; - if (cachedData && cachedData.length > 0) { - console.log('[KLineChartModal] 使用缓存数据, 数据条数:', cachedData.length); - setData(cachedData); - setLoading(false); - return; + if (response?.success && response.data) { + const stockData = response.data[stock.stock_code]; + const klineData = stockData?.data || []; + + if (klineData.length === 0) { + throw new Error('暂无K线数据'); + } + + console.log('[KLineChartModal] 初始数据条数:', klineData.length); + setData(klineData); + setEarliestDate(klineData[0]?.time || null); + setTotalDaysLoaded(DAYS_PER_LOAD); + setHasMore(response.has_more !== false); + } else { + throw new Error('数据加载失败'); } - - // 缓存没有则请求(会自动存入缓存) - console.log('[KLineChartModal] 缓存未命中,发起请求'); - const result = await fetchKlineData(stock.stock_code, stableEventTime, 'daily'); - - if (!result || result.length === 0) { - throw new Error('暂无K线数据'); - } - - console.log('[KLineChartModal] 数据条数:', result.length); - setData(result); } catch (err) { const errorMsg = err instanceof Error ? err.message : '数据加载失败'; setError(errorMsg); } finally { setLoading(false); } - }; + }, [stock?.stock_code, eventTime]); + + // 用于防抖的 ref + const loadMoreDebounceRef = useRef(null); // 初始化图表 useEffect(() => { @@ -135,6 +218,9 @@ const KLineChartModal: React.FC = ({ return () => { clearTimeout(timer); + if (loadMoreDebounceRef.current) { + clearTimeout(loadMoreDebounceRef.current); + } if (chartInstance.current) { chartInstance.current.dispose(); chartInstance.current = null; @@ -142,6 +228,35 @@ const KLineChartModal: React.FC = ({ }; }, [isOpen]); + // 监听 dataZoom 事件,当滑到左边界时加载更多数据 + useEffect(() => { + if (!chartInstance.current || !hasMore || loadingMore) return; + + const handleDataZoom = (params: any) => { + // 获取当前 dataZoom 的 start 值 + const start = params.start ?? params.batch?.[0]?.start ?? 0; + + // 当 start 接近 0(左边界)时,触发加载更多 + if (start <= 5 && hasMore && !loadingMore) { + console.log('[KLineChartModal] 检测到滑动到左边界,准备加载更多数据'); + + // 防抖处理 + if (loadMoreDebounceRef.current) { + clearTimeout(loadMoreDebounceRef.current); + } + loadMoreDebounceRef.current = setTimeout(() => { + loadMoreData(); + }, 300); + } + }; + + chartInstance.current.on('datazoom', handleDataZoom); + + return () => { + chartInstance.current?.off('datazoom', handleDataZoom); + }; + }, [hasMore, loadingMore, loadMoreData]); + // 更新图表数据 useEffect(() => { if (data.length === 0) { @@ -515,7 +630,22 @@ const KLineChartModal: React.FC = ({ {data.length > 0 && ( - 共{data.length}个交易日(最多1年) + 共{data.length}个交易日 + {hasMore ? '(向左滑动加载更多)' : '(已加载全部)'} + + )} + {loadingMore && ( + + + 加载更多... )} diff --git a/src/services/eventService.js b/src/services/eventService.js index 7c9b8fc4..c0311bb4 100755 --- a/src/services/eventService.js +++ b/src/services/eventService.js @@ -364,9 +364,12 @@ export const stockService = { * @param {string[]} stockCodes - 股票代码数组 * @param {string} chartType - 图表类型 (timeline/daily) * @param {string} eventTime - 事件时间 - * @returns {Promise} { [stockCode]: data[] } + * @param {Object} options - 额外选项 + * @param {number} options.days_before - 查询事件日期前多少天的数据,默认60,最大365 + * @param {string} options.end_date - 分页加载时指定结束日期(用于加载更早的数据) + * @returns {Promise} { success, data: { [stockCode]: data[] }, has_more, query_start_date, query_end_date } */ - getBatchKlineData: async (stockCodes, chartType = 'timeline', eventTime = null) => { + getBatchKlineData: async (stockCodes, chartType = 'timeline', eventTime = null, options = {}) => { try { const requestBody = { codes: stockCodes, @@ -375,8 +378,15 @@ export const stockService = { if (eventTime) { requestBody.event_time = eventTime; } + // 添加分页参数 + if (options.days_before) { + requestBody.days_before = options.days_before; + } + if (options.end_date) { + requestBody.end_date = options.end_date; + } - logger.debug('stockService', '批量获取K线数据', { stockCount: stockCodes.length, chartType, eventTime }); + logger.debug('stockService', '批量获取K线数据', { stockCount: stockCodes.length, chartType, eventTime, options }); const response = await apiRequest('/api/stock/batch-kline', { method: 'POST',