From 0f410c55a5ef8ea2f50005b4283b32e233b229ad Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Sun, 23 Nov 2025 14:34:37 +0800 Subject: [PATCH] feat: StockChartModal.tsx --- ...hartModal.js => StockChartModal.js.backup} | 0 src/components/StockChart/StockChartModal.tsx | 689 ++++++++++++++++++ 2 files changed, 689 insertions(+) rename src/components/StockChart/{StockChartModal.js => StockChartModal.js.backup} (100%) create mode 100644 src/components/StockChart/StockChartModal.tsx diff --git a/src/components/StockChart/StockChartModal.js b/src/components/StockChart/StockChartModal.js.backup similarity index 100% rename from src/components/StockChart/StockChartModal.js rename to src/components/StockChart/StockChartModal.js.backup diff --git a/src/components/StockChart/StockChartModal.tsx b/src/components/StockChart/StockChartModal.tsx new file mode 100644 index 00000000..6ed58f59 --- /dev/null +++ b/src/components/StockChart/StockChartModal.tsx @@ -0,0 +1,689 @@ +// src/components/StockChart/StockChartModal.tsx - 统一的股票图表组件 +import React, { useState, useEffect, useRef } from 'react'; +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalCloseButton, + ModalBody, + Button, + ButtonGroup, + VStack, + HStack, + Text, + Badge, + Box, + 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'; + +/** + * 图表类型 + */ +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; +} + +/** + * 股票信息 + */ +interface StockInfo { + stock_code: string; + stock_name?: string; + relation_desc?: RelationDescType; +} + +/** + * StockChartModal 组件 Props + */ +export interface StockChartModalProps { + /** 模态框是否打开 */ + isOpen: boolean; + + /** 关闭回调 */ + onClose: () => void; + + /** 股票信息 */ + stock: StockInfo | null; + + /** 事件时间 */ + eventTime?: string | null; + + /** 是否使用 Chakra UI(保留字段,当前未使用) */ + isChakraUI?: boolean; + + /** 模态框大小 */ + size?: string; + + /** 初始图表类型 */ + initialChartType?: ChartType; +} + +/** + * ECharts 实例(带自定义 resizeHandler) + */ +interface EChartsInstance extends ECharts { + resizeHandler?: () => void; +} + +const StockChartModal: React.FC = ({ + isOpen, + onClose, + stock, + eventTime, + isChakraUI = true, + size = '6xl', + initialChartType = 'timeline', +}) => { + const chartRef = useRef(null); + const chartInstanceRef = useRef(null); + const [chartType, setChartType] = useState(initialChartType); + const [loading, setLoading] = useState(false); + const [chartData, setChartData] = useState(null); + const [preloadedData, setPreloadedData] = useState>({ + timeline: undefined, + daily: undefined, + }); + + // 预加载数据 + const preloadData = async (type: ChartType): Promise => { + if (!stock || preloadedData[type]) return; + + 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 => { + 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]}
现价:¥${prices[ + idx + ]?.toFixed(2)} (${priceChangePercent >= 0 ? '+' : ''}${priceChangePercent.toFixed( + 2 + )}%)
均价:¥${avgPrices[idx]?.toFixed( + 2 + )} (${avgChangePercent >= 0 ? '+' : ''}${avgChangePercent.toFixed( + 2 + )}%)
昨收:¥${prevClose?.toFixed(2)}
成交量:${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}
开盘: ¥${kline.data[0]}
收盘: ¥${kline.data[1]}
最低: ¥${kline.data[2]}
最高: ¥${kline.data[3]}`; + + if (volume && volume.data) { + tooltipHtml += `
成交量: ${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 {}; + }; + + if (!stock) return null; + + return ( + + + + + + + + {stock.stock_name || stock.stock_code} ({stock.stock_code}) - 股票详情 + + {chartData && {chartData.trade_date}} + + + + + + + + + + {/* 图表区域 */} + + {loading && ( + + + + 加载图表数据... + + + )} +
+ + + {/* 关联描述 */} + + + {/* 风险提示 */} + + + + + + + ); +}; + +export default StockChartModal;