- KLineChartModal: 日K线图使用 aspectRatio 替代固定高度 - StockChartKLineModal: K线图高度改为响应式 min(400px, 60vh) - TimelineChartModal: 分时图弹窗大小与日K线统一,maxWidth: 1400px 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
538 lines
16 KiB
TypeScript
538 lines
16 KiB
TypeScript
// 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<TimelineChartModalProps> = ({
|
||
isOpen,
|
||
onClose,
|
||
stock,
|
||
eventTime,
|
||
size = '5xl',
|
||
}) => {
|
||
const chartRef = useRef<HTMLDivElement>(null);
|
||
const chartInstance = useRef<echarts.ECharts | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [data, setData] = useState<TimelineDataPoint[]>([]);
|
||
|
||
// 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 `
|
||
<div style="padding: 8px;">
|
||
<div style="font-weight: bold; margin-bottom: 8px;">${item.time || '-'}</div>
|
||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||
<span>价格:</span>
|
||
<span style="color: ${changeColor}; font-weight: bold; margin-left: 20px;">${safeFixed(item.price)}</span>
|
||
</div>
|
||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||
<span>均价:</span>
|
||
<span style="color: #ffa726; margin-left: 20px;">${safeFixed(item.avg_price)}</span>
|
||
</div>
|
||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||
<span>涨跌幅:</span>
|
||
<span style="color: ${changeColor}; margin-left: 20px;">${changeSign}${safeFixed(changePercent)}%</span>
|
||
</div>
|
||
<div style="display: flex; justify-content: space-between;">
|
||
<span>成交量:</span>
|
||
<span style="margin-left: 20px;">${item.volume != null ? (item.volume / 100).toFixed(0) : '-'}手</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
},
|
||
},
|
||
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 (
|
||
<Modal isOpen={isOpen} onClose={onClose} size={size} isCentered>
|
||
<ModalOverlay bg="blackAlpha.700" />
|
||
<ModalContent
|
||
w={isMobile ? '96vw' : '90vw'}
|
||
maxW={isMobile ? '96vw' : '1400px'}
|
||
borderRadius={isMobile ? '12px' : '8px'}
|
||
bg="#1a1a1a"
|
||
border="2px solid #ffd700"
|
||
boxShadow="0 0 30px rgba(255, 215, 0, 0.5)"
|
||
overflow="visible"
|
||
>
|
||
<ModalHeader pb={isMobile ? 2 : 3} borderBottomWidth="1px" borderColor="#404040">
|
||
<VStack align="flex-start" spacing={0}>
|
||
<HStack>
|
||
<Text fontSize={isMobile ? 'md' : 'lg'} fontWeight="bold" color="#e0e0e0">
|
||
{stock.stock_name || stock.stock_code} ({stock.stock_code})
|
||
</Text>
|
||
</HStack>
|
||
<Text fontSize={isMobile ? 'xs' : 'sm'} color="#999">
|
||
分时走势图
|
||
</Text>
|
||
</VStack>
|
||
</ModalHeader>
|
||
<ModalCloseButton color="#999" _hover={{ color: '#e0e0e0' }} />
|
||
<ModalBody p={isMobile ? 2 : 4}>
|
||
{error && (
|
||
<Alert status="error" bg="#2a1a1a" borderColor="#ef5350" mb={4}>
|
||
<AlertIcon color="#ef5350" />
|
||
<Text color="#e0e0e0">{error}</Text>
|
||
</Alert>
|
||
)}
|
||
|
||
<Box position="relative" w="100%">
|
||
{loading && (
|
||
<Flex
|
||
position="absolute"
|
||
top="0"
|
||
left="0"
|
||
right="0"
|
||
bottom="0"
|
||
bg="rgba(26, 26, 26, 0.7)"
|
||
zIndex="10"
|
||
alignItems="center"
|
||
justifyContent="center"
|
||
>
|
||
<VStack spacing={4}>
|
||
<CircularProgress isIndeterminate color="blue.400" />
|
||
<Text color="#e0e0e0">加载分时数据...</Text>
|
||
</VStack>
|
||
</Flex>
|
||
)}
|
||
{/* 使用 aspect-ratio 保持图表宽高比,与日K线保持一致 */}
|
||
<Box
|
||
ref={chartRef}
|
||
w="100%"
|
||
sx={{
|
||
aspectRatio: isMobile ? '1.8 / 1' : '2.5 / 1',
|
||
}}
|
||
minH={isMobile ? '280px' : '400px'}
|
||
/>
|
||
</Box>
|
||
</ModalBody>
|
||
</ModalContent>
|
||
</Modal>
|
||
);
|
||
};
|
||
|
||
export default TimelineChartModal;
|