// src/components/StockChart/KLineChartModal.tsx - K线图弹窗组件 import React, { useEffect, useRef, useState, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { useSelector } from 'react-redux'; import * as echarts from 'echarts'; import dayjs from 'dayjs'; import { stockService } from '@services/eventService'; import { selectIsMobile } from '@store/slices/deviceSlice'; /** * 股票信息 */ interface StockInfo { stock_code: string; stock_name?: string; } /** * KLineChartModal 组件 Props */ export interface KLineChartModalProps { /** 模态框是否打开 */ isOpen: boolean; /** 关闭回调 */ onClose: () => void; /** 股票信息 */ stock: StockInfo | null; /** 事件时间 */ eventTime?: string | null; /** 模态框大小 */ size?: string; } /** * K线数据点 */ interface KLineDataPoint { time: string; open: number; high: number; low: number; close: number; 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, stock, eventTime, size = '5xl', }) => { 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); // H5 响应式适配 const isMobile = useSelector(selectIsMobile); // 调试日志 console.log('[KLineChartModal] 渲染状态:', { isOpen, stock, eventTime, dataLength: data.length, loading, loadingMore, hasMore, earliestDate }); // 加载更多历史数据 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 response = await stockService.getBatchKlineData( [stock.stock_code], 'daily', stableEventTime, { days_before: DAYS_PER_LOAD, end_date: '' } ) as BatchKlineResponse; 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('数据加载失败'); } } catch (err) { const errorMsg = err instanceof Error ? err.message : '数据加载失败'; setError(errorMsg); } finally { setLoading(false); } }, [stock?.stock_code, eventTime]); // 用于防抖的 ref const loadMoreDebounceRef = useRef(null); // 初始化图表 useEffect(() => { if (!isOpen) return; // 延迟初始化,确保 Modal 动画完成后 DOM 已经渲染 const timer = setTimeout(() => { if (!chartRef.current) { console.error('[KLineChartModal] DOM元素未找到,无法初始化图表'); return; } console.log('[KLineChartModal] 初始化图表...'); // 创建图表实例(不使用主题,直接在option中配置背景色) chartInstance.current = echarts.init(chartRef.current); console.log('[KLineChartModal] 图表实例创建成功'); // 监听窗口大小变化 const handleResize = () => { chartInstance.current?.resize(); }; window.addEventListener('resize', handleResize); return () => { window.removeEventListener('resize', handleResize); }; }, 100); // 延迟100ms等待Modal完全打开 return () => { clearTimeout(timer); if (loadMoreDebounceRef.current) { clearTimeout(loadMoreDebounceRef.current); } if (chartInstance.current) { chartInstance.current.dispose(); chartInstance.current = null; } }; }, [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) { console.log('[KLineChartModal] 无数据,跳过图表更新'); return; } const updateChart = () => { if (!chartInstance.current) { console.warn('[KLineChartModal] 图表实例不存在'); return false; } console.log('[KLineChartModal] 开始更新图表,数据点:', data.length); const dates = data.map((d) => d.time); const klineData = data.map((d) => [d.open, d.close, d.low, d.high]); const volumes = data.map((d) => d.volume); // 计算成交量柱子颜色(涨为红,跌为绿) const volumeColors = data.map((d) => d.close >= d.open ? '#ef5350' : '#26a69a' ); // 提取事件发生日期(YYYY-MM-DD格式) let eventDateStr: string | null = null; if (eventTime) { try { const eventDate = new Date(eventTime); const year = eventDate.getFullYear(); const month = (eventDate.getMonth() + 1).toString().padStart(2, '0'); const day = eventDate.getDate().toString().padStart(2, '0'); eventDateStr = `${year}-${month}-${day}`; console.log('[KLineChartModal] 事件发生日期:', eventDateStr); } catch (e) { console.error('[KLineChartModal] 解析事件日期失败:', e); } } // 图表配置(H5 响应式) const option: echarts.EChartsOption = { backgroundColor: '#1a1a1a', title: { text: `${stock?.stock_name || stock?.stock_code} - 日K线`, left: 'center', top: isMobile ? 5 : 10, textStyle: { color: '#e0e0e0', fontSize: isMobile ? 14 : 18, fontWeight: 'bold', }, }, tooltip: { trigger: 'axis', backgroundColor: 'rgba(30, 30, 30, 0.95)', borderColor: '#404040', borderWidth: 1, textStyle: { color: '#e0e0e0', }, axisPointer: { type: 'cross', crossStyle: { color: '#999', }, }, formatter: (params: any) => { const dataIndex = params[0]?.dataIndex; if (dataIndex === undefined) return ''; const item = data[dataIndex]; const change = item.close - item.open; const changePercent = (change / item.open) * 100; const changeColor = change >= 0 ? '#ef5350' : '#26a69a'; const changeSign = change >= 0 ? '+' : ''; return `
${item.time}
开盘: ${item.open.toFixed(2)}
收盘: ${item.close.toFixed(2)}
最高: ${item.high.toFixed(2)}
最低: ${item.low.toFixed(2)}
涨跌额: ${changeSign}${change.toFixed(2)}
涨跌幅: ${changeSign}${changePercent.toFixed(2)}%
成交量: ${(item.volume / 100).toFixed(0)}手
`; }, }, grid: [ { left: isMobile ? '12%' : '5%', right: isMobile ? '5%' : '5%', top: isMobile ? '12%' : '12%', height: isMobile ? '55%' : '60%', }, { left: isMobile ? '12%' : '5%', right: isMobile ? '5%' : '5%', top: isMobile ? '72%' : '77%', height: isMobile ? '20%' : '18%', }, ], xAxis: [ { type: 'category', data: dates, gridIndex: 0, axisLine: { lineStyle: { color: '#404040', }, }, axisLabel: { color: '#999', fontSize: isMobile ? 10 : 12, interval: Math.floor(dates.length / (isMobile ? 4 : 8)), }, splitLine: { show: false, }, }, { type: 'category', data: dates, gridIndex: 1, axisLine: { lineStyle: { color: '#404040', }, }, axisLabel: { color: '#999', fontSize: isMobile ? 10 : 12, interval: Math.floor(dates.length / (isMobile ? 4 : 8)), }, }, ], yAxis: [ { scale: true, gridIndex: 0, splitNumber: isMobile ? 4 : 5, splitLine: { show: true, lineStyle: { color: '#2a2a2a', }, }, axisLine: { lineStyle: { color: '#404040', }, }, axisLabel: { color: '#999', fontSize: isMobile ? 10 : 12, formatter: (value: number) => value.toFixed(2), }, }, { scale: true, gridIndex: 1, splitNumber: isMobile ? 2 : 3, splitLine: { show: false, }, axisLine: { lineStyle: { color: '#404040', }, }, axisLabel: { color: '#999', fontSize: isMobile ? 10 : 12, formatter: (value: number) => { if (value >= 100000000) { return (value / 100000000).toFixed(1) + '亿'; } else if (value >= 10000) { return (value / 10000).toFixed(1) + '万'; } return value.toFixed(0); }, }, }, ], series: [ { name: 'K线', type: 'candlestick', data: klineData, xAxisIndex: 0, yAxisIndex: 0, itemStyle: { color: '#ef5350', // 涨 color0: '#26a69a', // 跌 borderColor: '#ef5350', borderColor0: '#26a69a', }, markLine: eventDateStr ? { silent: false, symbol: 'none', label: { show: true, position: 'insideEndTop', formatter: '事件发生', color: '#ffd700', fontSize: 12, fontWeight: 'bold', backgroundColor: 'rgba(0, 0, 0, 0.7)', padding: [4, 8], borderRadius: 4, }, lineStyle: { color: '#ffd700', width: 2, type: 'solid', }, data: [ { xAxis: eventDateStr, label: { formatter: '⚡ 事件发生', }, }, ], } : undefined, }, { name: '成交量', type: 'bar', data: volumes, xAxisIndex: 1, yAxisIndex: 1, itemStyle: { color: (params: any) => { return volumeColors[params.dataIndex]; }, }, }, ], dataZoom: [ { type: 'inside', xAxisIndex: [0, 1], start: 0, end: 100, }, ], }; chartInstance.current.setOption(option); console.log('[KLineChartModal] 图表option已设置'); // 强制resize以确保图表正确显示 setTimeout(() => { chartInstance.current?.resize(); console.log('[KLineChartModal] 图表已resize'); }, 100); return true; }; // 立即尝试更新,如果失败则重试 if (!updateChart()) { console.log('[KLineChartModal] 第一次更新失败,200ms后重试...'); const retryTimer = setTimeout(() => { updateChart(); }, 200); return () => clearTimeout(retryTimer); } }, [data, stock, isMobile]); // 加载数据 useEffect(() => { if (isOpen) { loadData(); } }, [isOpen, stock?.stock_code, eventTime]); // 创建或获取 Portal 容器 useEffect(() => { let container = document.getElementById('kline-modal-root'); if (!container) { container = document.createElement('div'); container.id = 'kline-modal-root'; container.style.cssText = 'position: fixed; top: 0; left: 0; z-index: 10000;'; document.body.appendChild(container); } return () => { // 组件卸载时不删除容器,因为可能会被复用 }; }, []); if (!stock) return null; console.log('[KLineChartModal] 渲染 Modal, isOpen:', isOpen); // 获取 Portal 容器 const portalContainer = document.getElementById('kline-modal-root') || document.body; // 如果不显示则返回 null if (!isOpen) return null; const modalContent = ( <> {/* 遮罩层 */}
{/* 弹窗内容 */}
{/* Header */}
{stock.stock_name || stock.stock_code} ({stock.stock_code}) {data.length > 0 && ( 共{data.length}个交易日 {hasMore ? '(向左滑动加载更多)' : '(已加载全部)'} )} {loadingMore && ( 加载更多... )}
日K线图 💡 {isMobile ? '滚轮缩放 | 拖动查看' : '鼠标滚轮缩放 | 拖动查看不同时间段'}
{/* Body */}
{error && (
{error}
)}
{loading && (
加载K线数据...
)}
{/* 添加旋转动画的 CSS */} ); return createPortal(modalContent, portalContainer); }; export default KLineChartModal;