Files
vf_react/src/components/StockChart/KLineChartModal.tsx
2025-12-04 14:11:37 +08:00

764 lines
23 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, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useSelector } from 'react-redux';
import * as echarts from 'echarts';
import dayjs from 'dayjs';
import { stockService } from '@services/eventService';
import { selectIsMobile } from '@store/slices/deviceSlice';
/**
* 股票信息
*/
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;
}
/**
* 批量K线API响应
*/
interface BatchKlineResponse {
success: boolean;
data: {
[stockCode: string]: {
code: string;
name: string;
data: KLineDataPoint[];
trade_date: string;
type: string;
earliest_date?: string;
};
};
has_more: boolean;
query_start_date?: string;
query_end_date?: string;
}
// 每次加载的天数
const DAYS_PER_LOAD = 60;
// 最大加载天数(一年)
const MAX_DAYS = 365;
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 [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<KLineDataPoint[]>([]);
const [hasMore, setHasMore] = useState(true);
const [earliestDate, setEarliestDate] = useState<string | null>(null);
const [totalDaysLoaded, setTotalDaysLoaded] = useState(0);
// H5 响应式适配
const isMobile = useSelector(selectIsMobile);
// 调试日志
console.log('[KLineChartModal] 渲染状态:', {
isOpen,
stock,
eventTime,
dataLength: data.length,
loading,
loadingMore,
hasMore,
earliestDate
});
// 加载更多历史数据
const loadMoreData = useCallback(async () => {
if (!stock?.stock_code || !hasMore || loadingMore || !earliestDate) return;
console.log('[KLineChartModal] 加载更多历史数据, earliestDate:', earliestDate);
setLoadingMore(true);
try {
const stableEventTime = eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
// 请求更早的数据end_date 设置为当前最早日期的前一天
const endDate = dayjs(earliestDate).subtract(1, 'day').format('YYYY-MM-DD');
const response = await stockService.getBatchKlineData(
[stock.stock_code],
'daily',
stableEventTime,
{ days_before: DAYS_PER_LOAD, end_date: endDate }
) as BatchKlineResponse;
if (response?.success && response.data) {
const stockData = response.data[stock.stock_code];
const newData = stockData?.data || [];
if (newData.length > 0) {
// 将新数据添加到现有数据的前面
setData(prevData => [...newData, ...prevData]);
setEarliestDate(newData[0].time);
setTotalDaysLoaded(prev => prev + DAYS_PER_LOAD);
console.log('[KLineChartModal] 加载了更多数据:', newData.length, '条');
}
// 检查是否还有更多数据
const noMoreData = !response.has_more || totalDaysLoaded + DAYS_PER_LOAD >= MAX_DAYS || newData.length === 0;
setHasMore(!noMoreData);
}
} catch (err) {
console.error('[KLineChartModal] 加载更多数据失败:', err);
} finally {
setLoadingMore(false);
}
}, [stock?.stock_code, hasMore, loadingMore, earliestDate, eventTime, totalDaysLoaded]);
// 初始加载K线数据
const loadData = useCallback(async () => {
if (!stock?.stock_code) return;
setLoading(true);
setError(null);
setData([]);
setHasMore(true);
setEarliestDate(null);
setTotalDaysLoaded(0);
try {
const stableEventTime = eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
// 使用新的带分页参数的接口
const response = await stockService.getBatchKlineData(
[stock.stock_code],
'daily',
stableEventTime,
{ days_before: DAYS_PER_LOAD, end_date: '' }
) as BatchKlineResponse;
if (response?.success && response.data) {
const stockData = response.data[stock.stock_code];
const klineData = stockData?.data || [];
if (klineData.length === 0) {
throw new Error('暂无K线数据');
}
console.log('[KLineChartModal] 初始数据条数:', klineData.length);
setData(klineData);
setEarliestDate(klineData[0]?.time || null);
setTotalDaysLoaded(DAYS_PER_LOAD);
setHasMore(response.has_more !== false);
} else {
throw new Error('数据加载失败');
}
} catch (err) {
const errorMsg = err instanceof Error ? err.message : '数据加载失败';
setError(errorMsg);
} finally {
setLoading(false);
}
}, [stock?.stock_code, eventTime]);
// 用于防抖的 ref
const loadMoreDebounceRef = useRef<NodeJS.Timeout | null>(null);
// 初始化图表
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 (loadMoreDebounceRef.current) {
clearTimeout(loadMoreDebounceRef.current);
}
if (chartInstance.current) {
chartInstance.current.dispose();
chartInstance.current = null;
}
};
}, [isOpen]);
// 监听 dataZoom 事件,当滑到左边界时加载更多数据
useEffect(() => {
if (!chartInstance.current || !hasMore || loadingMore) return;
const handleDataZoom = (params: any) => {
// 获取当前 dataZoom 的 start 值
const start = params.start ?? params.batch?.[0]?.start ?? 0;
// 当 start 接近 0左边界触发加载更多
if (start <= 5 && hasMore && !loadingMore) {
console.log('[KLineChartModal] 检测到滑动到左边界,准备加载更多数据');
// 防抖处理
if (loadMoreDebounceRef.current) {
clearTimeout(loadMoreDebounceRef.current);
}
loadMoreDebounceRef.current = setTimeout(() => {
loadMoreData();
}, 300);
}
};
chartInstance.current.on('datazoom', handleDataZoom);
return () => {
chartInstance.current?.off('datazoom', handleDataZoom);
};
}, [hasMore, loadingMore, loadMoreData]);
// 更新图表数据
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);
}
}
// 图表配置H5 响应式)
const option: echarts.EChartsOption = {
backgroundColor: '#1a1a1a',
title: {
text: `${stock?.stock_name || stock?.stock_code} - 日K线`,
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];
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: isMobile ? '12%' : '5%',
right: isMobile ? '5%' : '5%',
top: isMobile ? '12%' : '12%',
height: isMobile ? '55%' : '60%',
},
{
left: isMobile ? '12%' : '5%',
right: isMobile ? '5%' : '5%',
top: isMobile ? '72%' : '77%',
height: isMobile ? '20%' : '18%',
},
],
xAxis: [
{
type: 'category',
data: dates,
gridIndex: 0,
axisLine: {
lineStyle: {
color: '#404040',
},
},
axisLabel: {
color: '#999',
fontSize: isMobile ? 10 : 12,
interval: Math.floor(dates.length / (isMobile ? 4 : 8)),
},
splitLine: {
show: false,
},
},
{
type: 'category',
data: dates,
gridIndex: 1,
axisLine: {
lineStyle: {
color: '#404040',
},
},
axisLabel: {
color: '#999',
fontSize: isMobile ? 10 : 12,
interval: Math.floor(dates.length / (isMobile ? 4 : 8)),
},
},
],
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.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 >= 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, isMobile]);
// 加载数据
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: isMobile ? '96vw' : '90vw',
maxWidth: isMobile ? 'none' : '1400px',
maxHeight: isMobile ? '85vh' : '85vh',
backgroundColor: '#1a1a1a',
border: '2px solid #ffd700',
boxShadow: '0 0 30px rgba(255, 215, 0, 0.5)',
borderRadius: isMobile ? '12px' : '8px',
zIndex: 10002,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
{/* Header */}
<div
style={{
padding: isMobile ? '12px 16px' : '16px 24px',
borderBottom: '1px solid #404040',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
}}
>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: isMobile ? '8px' : '12px', flexWrap: isMobile ? 'wrap' : 'nowrap' }}>
<span style={{ fontSize: isMobile ? '14px' : '18px', fontWeight: 'bold', color: '#e0e0e0' }}>
{stock.stock_name || stock.stock_code} ({stock.stock_code})
</span>
{data.length > 0 && (
<span style={{ fontSize: isMobile ? '10px' : '12px', color: '#666', fontStyle: 'italic' }}>
{data.length}
{hasMore ? '(向左滑动加载更多)' : '(已加载全部)'}
</span>
)}
{loadingMore && (
<span style={{ fontSize: isMobile ? '10px' : '12px', color: '#3182ce', display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{
width: '12px',
height: '12px',
border: '2px solid #404040',
borderTop: '2px solid #3182ce',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
display: 'inline-block'
}} />
...
</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: isMobile ? '8px' : '16px', marginTop: '4px' }}>
<span style={{ fontSize: isMobile ? '12px' : '14px', color: '#999' }}>K线图</span>
<span style={{ fontSize: isMobile ? '10px' : '12px', color: '#666' }}>
💡 {isMobile ? '滚轮缩放 | 拖动查看' : '鼠标滚轮缩放 | 拖动查看不同时间段'}
</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: isMobile ? '8px' : '16px',
flex: 1,
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}>
{error && (
<div
style={{
backgroundColor: '#2a1a1a',
border: '1px solid #ef5350',
borderRadius: '4px',
padding: isMobile ? '8px 12px' : '12px 16px',
marginBottom: isMobile ? '8px' : '16px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<span style={{ color: '#ef5350' }}></span>
<span style={{ color: '#e0e0e0', fontSize: isMobile ? '12px' : '14px' }}>{error}</span>
</div>
)}
<div style={{ position: 'relative', height: isMobile ? '450px' : '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;