feat: StockChartModal.tsx 替换 KLine 实现

This commit is contained in:
zdl
2025-11-24 13:59:29 +08:00
parent b4dcbd1db9
commit 2f125a9207
10 changed files with 768 additions and 630 deletions

View File

@@ -1,5 +1,5 @@
// src/components/StockChart/StockChartModal.tsx - 统一的股票图表组件
import React, { useState, useEffect, useRef } from 'react';
// src/components/StockChart/StockChartModal.tsx - 统一的股票图表组件KLineChart 实现)
import React, { useState } from 'react';
import {
Modal,
ModalOverlay,
@@ -17,44 +17,17 @@ import {
Flex,
CircularProgress,
} from '@chakra-ui/react';
import * as echarts from 'echarts';
import type { EChartsOption, ECharts } from 'echarts';
import dayjs from 'dayjs';
import { stockService } from '../../services/eventService';
import { logger } from '../../utils/logger';
import RiskDisclaimer from '../RiskDisclaimer';
import { RelationDescription } from '../StockRelation';
import type { RelationDescType } from '../StockRelation';
import { useKLineChart, useKLineData, useEventMarker } from './hooks';
import { Alert, AlertIcon } from '@chakra-ui/react';
/**
* 图表类型
*/
type ChartType = 'timeline' | 'daily';
/**
* K线数据项
*/
interface KLineDataItem {
time: string;
date?: string;
open: number;
high: number;
low: number;
close: number;
price?: number;
volume: number;
avg_price?: number;
}
/**
* 图表数据结构
*/
interface ChartData {
data: KLineDataItem[];
trade_date: string;
prev_close?: number;
}
/**
* 股票信息
*/
@@ -90,12 +63,6 @@ export interface StockChartModalProps {
initialChartType?: ChartType;
}
/**
* ECharts 实例(带自定义 resizeHandler
*/
interface EChartsInstance extends ECharts {
resizeHandler?: () => void;
}
const StockChartModal: React.FC<StockChartModalProps> = ({
isOpen,
@@ -106,529 +73,47 @@ const StockChartModal: React.FC<StockChartModalProps> = ({
size = '6xl',
initialChartType = 'timeline',
}) => {
const chartRef = useRef<HTMLDivElement>(null);
const chartInstanceRef = useRef<EChartsInstance | null>(null);
// 状态管理
const [chartType, setChartType] = useState<ChartType>(initialChartType);
const [loading, setLoading] = useState(false);
const [chartData, setChartData] = useState<ChartData | null>(null);
const [preloadedData, setPreloadedData] = useState<Record<ChartType, ChartData | undefined>>({
timeline: undefined,
daily: undefined,
// KLineChart Hooks
const { chart, chartRef, isInitialized, error: chartError } = useKLineChart({
containerId: `kline-chart-${stock?.stock_code || 'default'}`,
height: 500,
autoResize: true,
chartType, // ✅ 传递 chartType让 Hook 根据类型应用不同样式
});
// 预加载数据
const preloadData = async (type: ChartType): Promise<void> => {
if (!stock || preloadedData[type]) return;
const { data, loading, error: dataError } = useKLineData({
chart,
stockCode: stock?.stock_code || '',
chartType,
eventTime: eventTime || undefined,
autoLoad: true, // 改为 true让 Hook 内部根据 stockCode 和 chart 判断是否加载
});
try {
let adjustedEventTime = eventTime;
if (eventTime) {
try {
const eventMoment = dayjs(eventTime);
if (eventMoment.isValid() && eventMoment.hour() >= 15) {
const nextDay = eventMoment.clone().add(1, 'day');
nextDay.hour(9).minute(30).second(0).millisecond(0);
adjustedEventTime = nextDay.format('YYYY-MM-DD HH:mm');
}
} catch (e) {
logger.warn('StockChartModal', '事件时间解析失败', {
eventTime,
error: (e as Error).message,
});
}
}
const response = await stockService.getKlineData(stock.stock_code, type, adjustedEventTime);
setPreloadedData((prev) => ({ ...prev, [type]: response }));
} catch (err) {
logger.error('StockChartModal', 'preloadData', err, {
stockCode: stock?.stock_code,
type,
});
}
};
useEffect(() => {
if (isOpen && stock) {
// 预加载两种图表类型的数据
preloadData('timeline');
preloadData('daily');
// 清理图表实例
return () => {
if (chartInstanceRef.current) {
window.removeEventListener('resize', chartInstanceRef.current.resizeHandler!);
chartInstanceRef.current.dispose();
chartInstanceRef.current = null;
}
};
}
}, [isOpen, stock, eventTime]);
useEffect(() => {
if (isOpen && stock) {
loadChartData(chartType);
}
}, [chartType, isOpen, stock]);
const loadChartData = async (type: ChartType): Promise<void> => {
if (!stock) return;
try {
setLoading(true);
// 先尝试使用预加载的数据
let response = preloadedData[type];
if (!response) {
// 如果预加载数据不存在,则立即请求
let adjustedEventTime = eventTime;
if (eventTime) {
try {
const eventMoment = dayjs(eventTime);
if (eventMoment.isValid() && eventMoment.hour() >= 15) {
const nextDay = eventMoment.clone().add(1, 'day');
nextDay.hour(9).minute(30).second(0).millisecond(0);
adjustedEventTime = nextDay.format('YYYY-MM-DD HH:mm');
}
} catch (e) {
logger.warn('StockChartModal', '事件时间解析失败', {
eventTime,
error: (e as Error).message,
});
}
}
response = await stockService.getKlineData(stock.stock_code, type, adjustedEventTime);
}
setChartData(response);
// 初始化图表
if (chartRef.current && !chartInstanceRef.current) {
const chart = echarts.init(chartRef.current) as EChartsInstance;
chart.resizeHandler = () => chart.resize();
window.addEventListener('resize', chart.resizeHandler);
chartInstanceRef.current = chart;
}
if (chartInstanceRef.current) {
const option = generateChartOption(response, type, eventTime);
chartInstanceRef.current.setOption(option, true);
}
} catch (err) {
logger.error('StockChartModal', 'loadChartData', err, {
stockCode: stock?.stock_code,
chartType: type,
});
} finally {
setLoading(false);
}
};
const generateChartOption = (
data: ChartData,
type: ChartType,
originalEventTime?: string | null
): EChartsOption => {
if (!data || !data.data || data.data.length === 0) {
return {
title: {
text: '暂无数据',
left: 'center',
top: 'center',
textStyle: { color: '#999', fontSize: 16 },
},
};
}
const stockData = data.data;
const tradeDate = data.trade_date;
// 分时图
if (type === 'timeline') {
const times = stockData.map((item) => item.time);
const prices = stockData.map((item) => item.close || item.price || 0);
const avgPrices = stockData.map((item) => item.avg_price || 0);
const volumes = stockData.map((item) => item.volume);
// 获取昨收盘价作为基准
const prevClose = data.prev_close || (prices.length > 0 ? prices[0] : 0);
// 计算涨跌幅数据
const changePercentData = prices.map((price) => ((price - prevClose) / prevClose) * 100);
const avgChangePercentData = avgPrices.map(
(avgPrice) => ((avgPrice - prevClose) / prevClose) * 100
);
const currentPrice = prices[prices.length - 1];
const currentChange = ((currentPrice - prevClose) / prevClose) * 100;
const isUp = currentChange >= 0;
const lineColor = isUp ? '#ef5350' : '#26a69a';
// 计算事件标记线位置
const eventMarkLineData: any[] = [];
if (originalEventTime && times.length > 0) {
const eventMoment = dayjs(originalEventTime);
const eventDate = eventMoment.format('YYYY-MM-DD');
if (eventDate === tradeDate) {
// 找到最接近的时间点
let nearestIdx = 0;
const eventMinutes = eventMoment.hour() * 60 + eventMoment.minute();
for (let i = 0; i < times.length; i++) {
const [h, m] = times[i].split(':').map(Number);
const timeMinutes = h * 60 + m;
const currentDiff = Math.abs(timeMinutes - eventMinutes);
const nearestDiff = Math.abs(
times[nearestIdx].split(':').map(Number)[0] * 60 +
times[nearestIdx].split(':').map(Number)[1] -
eventMinutes
);
if (currentDiff < nearestDiff) {
nearestIdx = i;
}
}
eventMarkLineData.push({
name: '事件发生',
xAxis: nearestIdx,
label: {
formatter: '事件发生',
position: 'middle',
color: '#FFD700',
fontSize: 12,
},
lineStyle: {
color: '#FFD700',
type: 'solid',
width: 2,
},
});
}
}
return {
title: {
text: `${stock!.stock_name || stock!.stock_code} - 分时图`,
left: 'center',
textStyle: { fontSize: 16, fontWeight: 'bold' },
},
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
formatter: function (params: any) {
if (!params || params.length === 0) return '';
const point = params[0];
const idx = point.dataIndex;
const priceChangePercent = ((prices[idx] - prevClose) / prevClose) * 100;
const avgChangePercent = ((avgPrices[idx] - prevClose) / prevClose) * 100;
const priceColor = priceChangePercent >= 0 ? '#ef5350' : '#26a69a';
const avgColor = avgChangePercent >= 0 ? '#ef5350' : '#26a69a';
return `时间:${times[idx]}<br/>现价:<span style="color: ${priceColor}">¥${prices[
idx
]?.toFixed(2)} (${priceChangePercent >= 0 ? '+' : ''}${priceChangePercent.toFixed(
2
)}%)</span><br/>均价:<span style="color: ${avgColor}">¥${avgPrices[idx]?.toFixed(
2
)} (${avgChangePercent >= 0 ? '+' : ''}${avgChangePercent.toFixed(
2
)}%)</span><br/>昨收:¥${prevClose?.toFixed(2)}<br/>成交量:${Math.round(
volumes[idx] / 100
)}`;
},
},
grid: [
{ left: '10%', right: '10%', height: '60%', top: '15%' },
{ left: '10%', right: '10%', top: '80%', height: '15%' },
],
xAxis: [
{ type: 'category', data: times, gridIndex: 0, boundaryGap: false },
{ type: 'category', data: times, gridIndex: 1, axisLabel: { show: false } },
],
yAxis: [
{
type: 'value',
gridIndex: 0,
scale: false,
position: 'left',
axisLabel: {
formatter: function (value: number) {
return (value >= 0 ? '+' : '') + value.toFixed(2) + '%';
},
},
splitLine: {
show: true,
lineStyle: {
color: '#f0f0f0',
},
},
},
{
type: 'value',
gridIndex: 0,
scale: false,
position: 'right',
axisLabel: {
formatter: function (value: number) {
return (value >= 0 ? '+' : '') + value.toFixed(2) + '%';
},
},
},
{
type: 'value',
gridIndex: 1,
scale: true,
axisLabel: { formatter: (v: number) => Math.round(v / 100) + '手' },
},
],
dataZoom: [
{ type: 'inside', xAxisIndex: [0, 1], start: 0, end: 100 },
{ show: true, xAxisIndex: [0, 1], type: 'slider', bottom: '0%' },
],
series: [
{
name: '分时价',
type: 'line',
xAxisIndex: 0,
yAxisIndex: 0,
data: changePercentData,
smooth: true,
showSymbol: false,
lineStyle: { color: lineColor, width: 2 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: isUp ? 'rgba(239, 83, 80, 0.3)' : 'rgba(38, 166, 154, 0.3)',
},
{
offset: 1,
color: isUp ? 'rgba(239, 83, 80, 0.05)' : 'rgba(38, 166, 154, 0.05)',
},
]),
},
markLine: {
symbol: 'none',
data: [
// 昨收盘价基准线 (0%)
{
yAxis: 0,
lineStyle: {
color: '#666',
type: 'dashed',
width: 1.5,
opacity: 0.8,
},
label: {
show: true,
formatter: '昨收盘价',
position: 'insideEndTop',
color: '#666',
fontSize: 12,
},
},
...eventMarkLineData,
],
animation: false,
},
},
{
name: '均价线',
type: 'line',
xAxisIndex: 0,
yAxisIndex: 1,
data: avgChangePercentData,
smooth: true,
showSymbol: false,
lineStyle: { color: '#FFA500', width: 1 },
},
{
name: '成交量',
type: 'bar',
xAxisIndex: 1,
yAxisIndex: 2,
data: volumes,
itemStyle: { color: '#b0c4de', opacity: 0.6 },
},
],
};
}
// 日K线
if (type === 'daily') {
const dates = stockData.map((item) => item.time || item.date || '');
const klineData = stockData.map((item) => [item.open, item.close, item.low, item.high]);
const volumes = stockData.map((item) => item.volume);
// 计算事件标记线位置
const eventMarkLineData: any[] = [];
if (originalEventTime && dates.length > 0) {
const eventMoment = dayjs(originalEventTime);
const eventDate = eventMoment.format('YYYY-MM-DD');
// 找到事件发生日期或最接近的交易日
let targetIndex = -1;
// 1. 先尝试找到完全匹配的日期
targetIndex = dates.findIndex((date) => date === eventDate);
// 2. 如果没有完全匹配,找到第一个大于等于事件日期的交易日
if (targetIndex === -1) {
for (let i = 0; i < dates.length; i++) {
if (dates[i] >= eventDate) {
targetIndex = i;
break;
}
}
}
// 3. 如果事件日期晚于所有交易日,则标记在最后一个交易日
if (targetIndex === -1 && eventDate > dates[dates.length - 1]) {
targetIndex = dates.length - 1;
}
// 4. 如果事件日期早于所有交易日,则标记在第一个交易日
if (targetIndex === -1 && eventDate < dates[0]) {
targetIndex = 0;
}
if (targetIndex >= 0) {
let labelText = '事件发生';
let labelPosition: any = 'middle';
// 根据事件时间和交易日的关系调整标签
if (eventDate === dates[targetIndex]) {
if (eventMoment.hour() >= 15) {
labelText = '事件发生\n(盘后)';
} else if (eventMoment.hour() < 9 || (eventMoment.hour() === 9 && eventMoment.minute() < 30)) {
labelText = '事件发生\n(盘前)';
}
} else if (eventDate < dates[targetIndex]) {
labelText = '事件发生\n(前一日)';
labelPosition = 'start';
} else {
labelText = '事件发生\n(影响日)';
labelPosition = 'end';
}
eventMarkLineData.push({
name: '事件发生',
xAxis: targetIndex,
label: {
formatter: labelText,
position: labelPosition,
color: '#FFD700',
fontSize: 12,
backgroundColor: 'rgba(0,0,0,0.5)',
padding: [4, 8],
borderRadius: 4,
},
lineStyle: {
color: '#FFD700',
type: 'solid',
width: 2,
},
});
}
}
return {
title: {
text: `${stock!.stock_name || stock!.stock_code} - 日K线`,
left: 'center',
textStyle: { fontSize: 16, fontWeight: 'bold' },
},
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
formatter: function (params: any) {
if (!params || params.length === 0) return '';
const kline = params[0];
const volume = params[1];
if (!kline || !kline.data) return '';
let tooltipHtml = `日期: ${kline.axisValue}<br/>开盘: ¥${kline.data[0]}<br/>收盘: ¥${kline.data[1]}<br/>最低: ¥${kline.data[2]}<br/>最高: ¥${kline.data[3]}`;
if (volume && volume.data) {
tooltipHtml += `<br/>成交量: ${Math.round(volume.data / 100)}`;
}
return tooltipHtml;
},
},
grid: [
{ left: '10%', right: '10%', height: '60%' },
{ left: '10%', right: '10%', top: '75%', height: '20%' },
],
xAxis: [
{ type: 'category', data: dates, boundaryGap: true, gridIndex: 0 },
{ type: 'category', data: dates, gridIndex: 1, axisLabel: { show: false } },
],
yAxis: [
{ type: 'value', scale: true, splitArea: { show: true }, gridIndex: 0 },
{
scale: true,
gridIndex: 1,
axisLabel: { formatter: (value: number) => Math.round(value / 100) + '手' },
},
],
dataZoom: [
{ type: 'inside', xAxisIndex: [0, 1], start: 70, end: 100 },
{ show: true, xAxisIndex: [0, 1], type: 'slider', bottom: '0%', start: 70, end: 100 },
],
series: [
{
name: 'K线',
type: 'candlestick',
yAxisIndex: 0,
data: klineData,
markLine: {
symbol: 'none',
data: eventMarkLineData,
animation: false,
},
itemStyle: {
color: '#ef5350',
color0: '#26a69a',
borderColor: '#ef5350',
borderColor0: '#26a69a',
},
},
{
name: '成交量',
type: 'bar',
xAxisIndex: 1,
yAxisIndex: 1,
data: volumes.map((volume, index) => ({
value: volume,
itemStyle: {
color: stockData[index].close >= stockData[index].open ? '#ef5350' : '#26a69a',
},
})),
},
],
};
}
return {};
};
const { marker } = useEventMarker({
chart,
data,
eventTime: eventTime || undefined,
eventTitle: '事件发生',
autoCreate: true,
});
// 守卫子句
if (!stock) return null;
return (
<Modal isOpen={isOpen} onClose={onClose} size={size}>
<ModalOverlay />
<ModalContent maxW="90vw" maxH="90vh" overflow="hidden">
<ModalHeader pb={4}>
<ModalHeader pb={4} position="relative">
<VStack align="flex-start" spacing={2}>
<HStack>
<Text fontSize="lg" fontWeight="bold">
{stock.stock_name || stock.stock_code} ({stock.stock_code}) -
</Text>
{chartData && <Badge colorScheme="blue">{chartData.trade_date}</Badge>}
{data.length > 0 && <Badge colorScheme="blue">: {data.length}</Badge>}
</HStack>
<ButtonGroup size="sm">
<Button
@@ -647,11 +132,46 @@ const StockChartModal: React.FC<StockChartModalProps> = ({
</Button>
</ButtonGroup>
</VStack>
{/* 重件发生标签 - 仅在有 eventTime 时显示 */}
{eventTime && (
<Badge
colorScheme="yellow"
fontSize="sm"
px={3}
py={1}
borderRadius="md"
position="absolute"
top="4"
right="12"
boxShadow="sm"
>
()
</Badge>
)}
</ModalHeader>
<ModalCloseButton />
<ModalBody p={0} overflowY="auto" maxH="calc(90vh - 120px)">
{/* 图表区域 */}
<Box h="500px" w="100%" position="relative">
{/* 错误提示 */}
{(chartError || dataError) && (
<Alert status="error" mx={4} mt={4}>
<AlertIcon />
{chartError?.message || dataError?.message}
</Alert>
)}
{/* 图表区域 - 响应式高度 */}
<Box
h={{
base: "calc(60vh - 100px)", // 移动端60% 视口高度 - 100px
md: "calc(70vh - 150px)", // 平板70% 视口高度 - 150px
lg: "calc(80vh - 200px)" // 桌面80% 视口高度 - 200px
}}
minH="350px" // 最小高度:确保可用性
maxH="650px" // 最大高度:避免过大
w="100%"
position="relative"
>
{loading && (
<Flex
position="absolute"
@@ -670,7 +190,11 @@ const StockChartModal: React.FC<StockChartModalProps> = ({
</VStack>
</Flex>
)}
<div ref={chartRef} style={{ height: '100%', width: '100%', minHeight: '500px' }} />
<div
ref={chartRef}
id={`kline-chart-${stock.stock_code}`}
style={{ width: '100%', height: '100%' }}
/>
</Box>
{/* 关联描述 */}