Files
vf_react/src/components/StockChart/TimelineChartModal.tsx
zdl 2720946ccf fix(types): 修复 ECharts 类型导出和组件类型冲突
- echarts.ts: 将 EChartsOption 改为 EChartsCoreOption 的类型别名
- FuiCorners: 移除 extends BoxProps,position 重命名为 corner
- KLineChartModal/TimelineChartModal/ConcentrationCard: 使用导入的 EChartsOption
- LoadingState: 新增骨架屏 variant 支持
- FinancialPanorama: 使用骨架屏加载状态
- useFinancialData/financialService: 优化数据获取逻辑
- Company/index: 简化组件结构

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 18:42:19 +08:00

538 lines
16 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/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 { echarts, type ECharts, type EChartsOption } from '@lib/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: 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;