// src/components/StockChart/TimelineChartModal.tsx - 分时图弹窗组件 import React, { useEffect, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, VStack, HStack, Text, Box, Flex, CircularProgress, Alert, AlertIcon, } from '@chakra-ui/react'; import * as echarts from 'echarts'; import dayjs from 'dayjs'; import { klineDataCache, getCacheKey, fetchKlineData } from '@utils/stock/klineDataCache'; import { selectIsMobile } from '@store/slices/deviceSlice'; import { StockInfo } from './types'; /** * TimelineChartModal 组件 Props */ export interface TimelineChartModalProps { /** 模态框是否打开 */ isOpen: boolean; /** 关闭回调 */ onClose: () => void; /** 股票信息 */ stock: StockInfo | null; /** 事件时间 */ eventTime?: string | null; /** 模态框大小 */ size?: string; } /** * 分时图数据点 */ interface TimelineDataPoint { time: string; price: number; avg_price: number; volume: number; change_percent: number; } const TimelineChartModal: 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([]); // H5 响应式适配 const isMobile = useSelector(selectIsMobile); // 加载分时图数据(优先使用缓存) const loadData = async () => { if (!stock?.stock_code) return; setLoading(true); setError(null); try { // 标准化事件时间 const stableEventTime = eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : ''; // 先检查缓存 const cacheKey = getCacheKey(stock.stock_code, stableEventTime, 'timeline'); const cachedData = klineDataCache.get(cacheKey); if (cachedData && cachedData.length > 0) { console.log('[TimelineChartModal] 使用缓存数据, 数据条数:', cachedData.length); setData(cachedData); setLoading(false); return; } // 缓存没有则请求(会自动存入缓存) console.log('[TimelineChartModal] 缓存未命中,发起请求'); const result = await fetchKlineData(stock.stock_code, stableEventTime, 'timeline'); if (!result || result.length === 0) { throw new Error('暂无分时数据'); } console.log('[TimelineChartModal] 数据条数:', result.length); setData(result); } 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('[TimelineChartModal] DOM元素未找到,无法初始化图表'); return; } console.log('[TimelineChartModal] 初始化图表...'); // 创建图表实例(不使用主题,直接在option中配置背景色) chartInstance.current = echarts.init(chartRef.current); console.log('[TimelineChartModal] 图表实例创建成功'); // 监听窗口大小变化 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('[TimelineChartModal] 无数据,跳过图表更新'); return; } // 如果图表还没初始化,等待200ms后重试(给图表初始化留出时间) const updateChart = () => { if (!chartInstance.current) { console.warn('[TimelineChartModal] 图表实例不存在'); return false; } console.log('[TimelineChartModal] 开始更新图表,数据点:', data.length); const times = data.map((d) => d.time); const prices = data.map((d) => d.price); const avgPrices = data.map((d) => d.avg_price); const volumes = data.map((d) => d.volume); // 计算涨跌颜色 const basePrice = data[0]?.price || 0; const volumeColors = data.map((d) => d.price >= basePrice ? '#ef5350' : '#26a69a' ); // 提取事件发生时间(HH:MM格式) let eventTimeStr: string | null = null; if (eventTime) { try { const eventDate = new Date(eventTime); const hours = eventDate.getHours().toString().padStart(2, '0'); const minutes = eventDate.getMinutes().toString().padStart(2, '0'); eventTimeStr = `${hours}:${minutes}`; console.log('[TimelineChartModal] 事件发生时间:', eventTimeStr); } catch (e) { console.error('[TimelineChartModal] 解析事件时间失败:', e); } } // 图表配置(H5 响应式) const option: echarts.EChartsOption = { backgroundColor: '#1a1a1a', title: { text: `${stock?.stock_name || stock?.stock_code} - 分时图`, 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]; if (!item) return ''; // 安全格式化数字 const safeFixed = (val: any, digits = 2) => val != null && !isNaN(val) ? Number(val).toFixed(digits) : '-'; const changePercent = item.change_percent ?? 0; const changeColor = changePercent >= 0 ? '#ef5350' : '#26a69a'; const changeSign = changePercent >= 0 ? '+' : ''; return `
${item.time || '-'}
价格: ${safeFixed(item.price)}
均价: ${safeFixed(item.avg_price)}
涨跌幅: ${changeSign}${safeFixed(changePercent)}%
成交量: ${item.volume != null ? (item.volume / 100).toFixed(0) : '-'}手
`; }, }, grid: [ { left: isMobile ? '12%' : '5%', right: isMobile ? '5%' : '5%', top: isMobile ? '12%' : '15%', height: isMobile ? '58%' : '55%', }, { left: isMobile ? '12%' : '5%', right: isMobile ? '5%' : '5%', top: isMobile ? '75%' : '75%', height: isMobile ? '18%' : '15%', }, ], xAxis: [ { type: 'category', data: times, gridIndex: 0, axisLine: { lineStyle: { color: '#404040', }, }, axisLabel: { color: '#999', fontSize: isMobile ? 10 : 12, interval: Math.floor(times.length / (isMobile ? 4 : 6)), }, splitLine: { show: true, lineStyle: { color: '#2a2a2a', }, }, }, { type: 'category', data: times, gridIndex: 1, axisLine: { lineStyle: { color: '#404040', }, }, axisLabel: { color: '#999', fontSize: isMobile ? 10 : 12, interval: Math.floor(times.length / (isMobile ? 4 : 6)), }, }, ], 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 != null && !isNaN(value)) ? 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 == null || isNaN(value)) return '-'; if (value >= 10000) { return (value / 10000).toFixed(1) + '万'; } return value.toFixed(0); }, }, }, ], series: [ { name: '价格', type: 'line', data: prices, xAxisIndex: 0, yAxisIndex: 0, smooth: true, symbol: 'none', lineStyle: { color: '#2196f3', width: 2, }, areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: 'rgba(33, 150, 243, 0.3)' }, { offset: 1, color: 'rgba(33, 150, 243, 0.05)' }, ]), }, markLine: eventTimeStr ? { 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: eventTimeStr, label: { formatter: '⚡ 事件发生', }, }, ], } : undefined, }, { name: '均价', type: 'line', data: avgPrices, xAxisIndex: 0, yAxisIndex: 0, smooth: true, symbol: 'none', lineStyle: { color: '#ffa726', width: 1.5, type: 'dashed', }, }, { 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('[TimelineChartModal] 图表option已设置'); // 强制resize以确保图表正确显示 setTimeout(() => { chartInstance.current?.resize(); console.log('[TimelineChartModal] 图表已resize'); }, 100); return true; }; // 立即尝试更新,如果失败则重试 if (!updateChart()) { console.log('[TimelineChartModal] 第一次更新失败,200ms后重试...'); const retryTimer = setTimeout(() => { updateChart(); }, 200); return () => clearTimeout(retryTimer); } }, [data, stock, isMobile]); // 加载数据 useEffect(() => { if (isOpen) { loadData(); } }, [isOpen, stock?.stock_code, eventTime]); if (!stock) return null; return ( {stock.stock_name || stock.stock_code} ({stock.stock_code}) 分时走势图 {error && ( {error} )} {loading && ( 加载分时数据... )}
); }; export default TimelineChartModal;