Files
vf_react/src/components/StockChart/KLineChartModal.tsx
2025-11-24 19:28:52 +08:00

474 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// src/components/StockChart/KLineChartModal.tsx - K线图弹窗组件
import React, { useEffect, useRef, useState } from 'react';
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 { stockService } from '@services/eventService';
import { logger } from '@utils/logger';
/**
* 股票信息
*/
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<KLineChartModalProps> = ({
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<KLineDataPoint[]>([]);
// 加载K线数据
const loadData = async () => {
if (!stock?.stock_code) return;
setLoading(true);
setError(null);
try {
logger.debug('KLineChartModal', 'loadData', '开始加载K线数据', {
stockCode: stock.stock_code,
eventTime,
});
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);
logger.info('KLineChartModal', 'loadData', 'K线数据加载成功', {
dataCount: response.data.length,
});
} catch (err) {
const errorMsg = err instanceof Error ? err.message : '数据加载失败';
logger.error('KLineChartModal', 'loadData', err as Error);
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'
);
// 图表配置
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 `
<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="margin-left: 20px;">${item.open.toFixed(2)}</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span>收盘:</span>
<span style="color: ${changeColor}; font-weight: bold; margin-left: 20px;">${item.close.toFixed(2)}</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span>最高:</span>
<span style="margin-left: 20px;">${item.high.toFixed(2)}</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span>最低:</span>
<span style="margin-left: 20px;">${item.low.toFixed(2)}</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span>涨跌额:</span>
<span style="color: ${changeColor}; margin-left: 20px;">${changeSign}${change.toFixed(2)}</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span>涨跌幅:</span>
<span style="color: ${changeColor}; margin-left: 20px;">${changeSign}${changePercent.toFixed(2)}%</span>
</div>
<div style="display: flex; justify-content: space-between;">
<span>成交量:</span>
<span style="margin-left: 20px;">${(item.volume / 100).toFixed(0)}手</span>
</div>
</div>
`;
},
},
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',
},
},
{
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]);
if (!stock) return null;
return (
<Modal isOpen={isOpen} onClose={onClose} size={size}>
<ModalOverlay bg="blackAlpha.700" />
<ModalContent
maxW="90vw"
maxH="85vh"
bg="#1a1a1a"
borderColor="#404040"
borderWidth="1px"
>
<ModalHeader pb={3} borderBottomWidth="1px" borderColor="#404040">
<VStack align="flex-start" spacing={1}>
<HStack spacing={3}>
<Text fontSize="lg" fontWeight="bold" color="#e0e0e0">
{stock.stock_name || stock.stock_code} ({stock.stock_code})
</Text>
{data.length > 0 && (
<Text fontSize="xs" color="#666" fontStyle="italic">
{data.length}2
</Text>
)}
</HStack>
<HStack spacing={4}>
<Text fontSize="sm" color="#999">
K线图
</Text>
<Text fontSize="xs" color="#666">
💡 |
</Text>
</HStack>
</VStack>
</ModalHeader>
<ModalCloseButton color="#999" _hover={{ color: '#e0e0e0' }} />
<ModalBody p={4}>
{error && (
<Alert status="error" bg="#2a1a1a" borderColor="#ef5350" mb={4}>
<AlertIcon color="#ef5350" />
<Text color="#e0e0e0">{error}</Text>
</Alert>
)}
<Box position="relative" h="680px" 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">K线数据...</Text>
</VStack>
</Flex>
)}
<div ref={chartRef} style={{ width: '100%', height: '100%' }} />
</Box>
</ModalBody>
</ModalContent>
</Modal>
);
};
export default KLineChartModal;