// src/components/StockChart/KLineChartModal.tsx - K线图弹窗组件 import React, { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import * as echarts from 'echarts'; import { stockService } from '@services/eventService'; /** * 股票信息 */ 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; } const KLineChartModal: React.FC = ({ isOpen, onClose, stock, eventTime, size = '5xl', }) => { const chartRef = useRef(null); const chartInstance = useRef(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [data, setData] = useState([]); // 调试日志 console.log('[KLineChartModal] 渲染状态:', { isOpen, stock, eventTime, dataLength: data.length, loading, error }); // 加载K线数据 const loadData = async () => { if (!stock?.stock_code) return; setLoading(true); setError(null); try { const response = await stockService.getKlineData( stock.stock_code, 'daily', eventTime || undefined ); console.log('[KLineChartModal] API响应:', response); if (!response || !response.data || response.data.length === 0) { throw new Error('暂无K线数据'); } console.log('[KLineChartModal] 数据条数:', response.data.length); setData(response.data); } catch (err) { const errorMsg = err instanceof Error ? err.message : '数据加载失败'; setError(errorMsg); } finally { setLoading(false); } }; // 初始化图表 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 (chartInstance.current) { chartInstance.current.dispose(); chartInstance.current = null; } }; }, [isOpen]); // 更新图表数据 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); } } // 图表配置 const option: echarts.EChartsOption = { backgroundColor: '#1a1a1a', title: { text: `${stock?.stock_name || stock?.stock_code} - 日K线`, left: 'center', top: 10, textStyle: { color: '#e0e0e0', fontSize: 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: '5%', right: '5%', top: '12%', height: '60%', }, { left: '5%', right: '5%', top: '77%', height: '18%', }, ], xAxis: [ { type: 'category', data: dates, gridIndex: 0, axisLine: { lineStyle: { color: '#404040', }, }, axisLabel: { color: '#999', interval: Math.floor(dates.length / 8), }, splitLine: { show: false, }, }, { type: 'category', data: dates, gridIndex: 1, axisLine: { lineStyle: { color: '#404040', }, }, axisLabel: { color: '#999', interval: Math.floor(dates.length / 8), }, }, ], yAxis: [ { scale: true, gridIndex: 0, splitLine: { show: true, lineStyle: { color: '#2a2a2a', }, }, axisLine: { lineStyle: { color: '#404040', }, }, axisLabel: { color: '#999', formatter: (value: number) => value.toFixed(2), }, }, { scale: true, gridIndex: 1, splitLine: { show: false, }, axisLine: { lineStyle: { color: '#404040', }, }, axisLabel: { color: '#999', 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]); // 加载数据 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}个交易日(最多1年) )}
日K线图 💡 鼠标滚轮缩放 | 拖动查看不同时间段
{/* Body */}
{error && (
{error}
)}
{loading && (
加载K线数据...
)}
{/* 添加旋转动画的 CSS */} ); return createPortal(modalContent, portalContainer); }; export default KLineChartModal;