616 lines
18 KiB
TypeScript
616 lines
18 KiB
TypeScript
// src/components/StockChart/KLineChartModal.tsx - K线图弹窗组件
|
||
import React, { useEffect, useRef, useState } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
import * as echarts from 'echarts';
|
||
import dayjs from 'dayjs';
|
||
import { klineDataCache, getCacheKey, fetchKlineData } from '@views/Community/components/StockDetailPanel/utils/klineDataCache';
|
||
|
||
/**
|
||
* 股票信息
|
||
*/
|
||
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[]>([]);
|
||
|
||
// 调试日志
|
||
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 stableEventTime = eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||
|
||
// 先检查缓存
|
||
const cacheKey = getCacheKey(stock.stock_code, stableEventTime, 'daily');
|
||
const cachedData = klineDataCache.get(cacheKey);
|
||
|
||
if (cachedData && cachedData.length > 0) {
|
||
console.log('[KLineChartModal] 使用缓存数据, 数据条数:', cachedData.length);
|
||
setData(cachedData);
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
|
||
// 缓存没有则请求(会自动存入缓存)
|
||
console.log('[KLineChartModal] 缓存未命中,发起请求');
|
||
const result = await fetchKlineData(stock.stock_code, stableEventTime, 'daily');
|
||
|
||
if (!result || result.length === 0) {
|
||
throw new Error('暂无K线数据');
|
||
}
|
||
|
||
console.log('[KLineChartModal] 数据条数:', 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('[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 `
|
||
<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',
|
||
},
|
||
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 = (
|
||
<>
|
||
{/* 遮罩层 */}
|
||
<div
|
||
style={{
|
||
position: 'fixed',
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||
zIndex: 10001,
|
||
}}
|
||
onClick={onClose}
|
||
/>
|
||
{/* 弹窗内容 */}
|
||
<div
|
||
style={{
|
||
position: 'fixed',
|
||
top: '50%',
|
||
left: '50%',
|
||
transform: 'translate(-50%, -50%)',
|
||
width: '90vw',
|
||
maxWidth: '1400px',
|
||
maxHeight: '85vh',
|
||
backgroundColor: '#1a1a1a',
|
||
border: '2px solid #ffd700',
|
||
boxShadow: '0 0 30px rgba(255, 215, 0, 0.5)',
|
||
borderRadius: '8px',
|
||
zIndex: 10002,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
{/* Header */}
|
||
<div
|
||
style={{
|
||
padding: '16px 24px',
|
||
borderBottom: '1px solid #404040',
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'flex-start',
|
||
}}
|
||
>
|
||
<div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||
<span style={{ fontSize: '18px', fontWeight: 'bold', color: '#e0e0e0' }}>
|
||
{stock.stock_name || stock.stock_code} ({stock.stock_code})
|
||
</span>
|
||
{data.length > 0 && (
|
||
<span style={{ fontSize: '12px', color: '#666', fontStyle: 'italic' }}>
|
||
共{data.length}个交易日(最多1年)
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', marginTop: '4px' }}>
|
||
<span style={{ fontSize: '14px', color: '#999' }}>日K线图</span>
|
||
<span style={{ fontSize: '12px', color: '#666' }}>
|
||
💡 鼠标滚轮缩放 | 拖动查看不同时间段
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={onClose}
|
||
style={{
|
||
background: 'none',
|
||
border: 'none',
|
||
color: '#999',
|
||
fontSize: '24px',
|
||
cursor: 'pointer',
|
||
padding: '0 8px',
|
||
lineHeight: '1',
|
||
}}
|
||
onMouseOver={(e) => (e.currentTarget.style.color = '#e0e0e0')}
|
||
onMouseOut={(e) => (e.currentTarget.style.color = '#999')}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
{/* Body */}
|
||
<div style={{ padding: '16px', flex: 1, overflow: 'auto' }}>
|
||
{error && (
|
||
<div
|
||
style={{
|
||
backgroundColor: '#2a1a1a',
|
||
border: '1px solid #ef5350',
|
||
borderRadius: '4px',
|
||
padding: '12px 16px',
|
||
marginBottom: '16px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '8px',
|
||
}}
|
||
>
|
||
<span style={{ color: '#ef5350' }}>⚠</span>
|
||
<span style={{ color: '#e0e0e0' }}>{error}</span>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ position: 'relative', height: '680px', width: '100%' }}>
|
||
{loading && (
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
backgroundColor: 'rgba(26, 26, 26, 0.7)',
|
||
zIndex: 10,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: '16px',
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
width: '40px',
|
||
height: '40px',
|
||
border: '3px solid #404040',
|
||
borderTop: '3px solid #3182ce',
|
||
borderRadius: '50%',
|
||
animation: 'spin 1s linear infinite',
|
||
}}
|
||
/>
|
||
<span style={{ color: '#e0e0e0' }}>加载K线数据...</span>
|
||
</div>
|
||
)}
|
||
<div ref={chartRef} style={{ width: '100%', height: '100%' }} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/* 添加旋转动画的 CSS */}
|
||
<style>{`
|
||
@keyframes spin {
|
||
0% { transform: rotate(0deg); }
|
||
100% { transform: rotate(360deg); }
|
||
}
|
||
`}</style>
|
||
</>
|
||
);
|
||
|
||
return createPortal(modalContent, portalContainer);
|
||
};
|
||
|
||
export default KLineChartModal;
|