diff --git a/src/components/StockChart/KLineChartModal.tsx b/src/components/StockChart/KLineChartModal.tsx new file mode 100644 index 00000000..67ef561d --- /dev/null +++ b/src/components/StockChart/KLineChartModal.tsx @@ -0,0 +1,427 @@ +// src/components/StockChart/KLineChartModal.tsx - K线图弹窗组件 +import React, { useEffect, useRef, useState } from 'react'; +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalCloseButton, + ModalBody, + VStack, + HStack, + Text, + Box, + Flex, + CircularProgress, + Alert, + AlertIcon, +} from '@chakra-ui/react'; +import * as echarts from 'echarts'; +import { stockService } from '@services/eventService'; +import { logger } from '@utils/logger'; + +/** + * 股票信息 + */ +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 = ({ + isOpen, + onClose, + stock, + eventTime, + size = '5xl', +}) => { + const chartRef = useRef(null); + const chartInstance = useRef(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState([]); + + // 加载K线数据 + const loadData = async () => { + if (!stock?.stock_code) return; + + setLoading(true); + setError(null); + + try { + logger.debug('KLineChartModal', 'loadData', '开始加载K线数据', { + stockCode: stock.stock_code, + eventTime, + }); + + const response = await stockService.getKlineData( + stock.stock_code, + 'daily', + eventTime || undefined + ); + + if (!response || !response.data || response.data.length === 0) { + throw new Error('暂无K线数据'); + } + + setData(response.data); + logger.info('KLineChartModal', 'loadData', 'K线数据加载成功', { + dataCount: response.data.length, + }); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : '数据加载失败'; + logger.error('KLineChartModal', 'loadData', err as Error); + setError(errorMsg); + } finally { + setLoading(false); + } + }; + + // 初始化图表 + useEffect(() => { + if (!chartRef.current || !isOpen) return; + + // 创建图表实例 + chartInstance.current = echarts.init(chartRef.current, 'dark'); + + // 监听窗口大小变化 + const handleResize = () => { + chartInstance.current?.resize(); + }; + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + chartInstance.current?.dispose(); + chartInstance.current = null; + }; + }, [isOpen]); + + // 更新图表数据 + useEffect(() => { + if (!chartInstance.current || data.length === 0) return; + + 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' + ); + + // 图表配置 + 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 ` +
+
${item.time}
+
+ 开盘: + ${item.open.toFixed(2)} +
+
+ 收盘: + ${item.close.toFixed(2)} +
+
+ 最高: + ${item.high.toFixed(2)} +
+
+ 最低: + ${item.low.toFixed(2)} +
+
+ 涨跌额: + ${changeSign}${change.toFixed(2)} +
+
+ 涨跌幅: + ${changeSign}${changePercent.toFixed(2)}% +
+
+ 成交量: + ${(item.volume / 100).toFixed(0)}手 +
+
+ `; + }, + }, + grid: [ + { + left: '5%', + right: '5%', + top: '15%', + height: '55%', + }, + { + left: '5%', + right: '5%', + top: '75%', + height: '15%', + }, + ], + 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', + }, + }, + { + name: '成交量', + type: 'bar', + data: volumes, + xAxisIndex: 1, + yAxisIndex: 1, + itemStyle: { + color: (params: any) => { + return volumeColors[params.dataIndex]; + }, + }, + }, + ], + dataZoom: [ + { + type: 'inside', + xAxisIndex: [0, 1], + start: 50, + end: 100, + }, + { + type: 'slider', + xAxisIndex: [0, 1], + start: 50, + end: 100, + bottom: '2%', + height: 20, + textStyle: { + color: '#999', + }, + borderColor: '#404040', + fillerColor: 'rgba(33, 150, 243, 0.2)', + handleStyle: { + color: '#2196f3', + }, + }, + ], + }; + + chartInstance.current.setOption(option); + }, [data, stock]); + + // 加载数据 + useEffect(() => { + if (isOpen) { + loadData(); + } + }, [isOpen, stock?.stock_code, eventTime]); + + if (!stock) return null; + + return ( + + + + + + + + {stock.stock_name || stock.stock_code} ({stock.stock_code}) + + + + 日K线图 + + + + + + {error && ( + + + {error} + + )} + + + {loading && ( + + + + 加载K线数据... + + + )} +
+ + + + + ); +}; + +export default KLineChartModal; diff --git a/src/components/StockChart/TimelineChartModal.tsx b/src/components/StockChart/TimelineChartModal.tsx new file mode 100644 index 00000000..cb049be5 --- /dev/null +++ b/src/components/StockChart/TimelineChartModal.tsx @@ -0,0 +1,419 @@ +// src/components/StockChart/TimelineChartModal.tsx - 分时图弹窗组件 +import React, { useEffect, useRef, useState } from 'react'; +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalCloseButton, + ModalBody, + VStack, + HStack, + Text, + Box, + Flex, + CircularProgress, + Alert, + AlertIcon, +} from '@chakra-ui/react'; +import * as echarts from 'echarts'; +import { stockService } from '@services/eventService'; +import { logger } from '@utils/logger'; + +/** + * 股票信息 + */ +interface StockInfo { + stock_code: string; + stock_name?: string; +} + +/** + * 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 = ({ + isOpen, + onClose, + stock, + eventTime, + size = '5xl', +}) => { + const chartRef = useRef(null); + const chartInstance = useRef(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState([]); + + // 加载分时图数据 + const loadData = async () => { + if (!stock?.stock_code) return; + + setLoading(true); + setError(null); + + try { + logger.debug('TimelineChartModal', 'loadData', '开始加载分时图数据', { + stockCode: stock.stock_code, + eventTime, + }); + + const response = await stockService.getKlineData( + stock.stock_code, + 'minute', + eventTime || undefined + ); + + if (!response || !response.data || response.data.length === 0) { + throw new Error('暂无分时数据'); + } + + setData(response.data); + logger.info('TimelineChartModal', 'loadData', '分时图数据加载成功', { + dataCount: response.data.length, + }); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : '数据加载失败'; + logger.error('TimelineChartModal', 'loadData', err as Error); + setError(errorMsg); + } finally { + setLoading(false); + } + }; + + // 初始化图表 + useEffect(() => { + if (!chartRef.current || !isOpen) return; + + // 创建图表实例 + chartInstance.current = echarts.init(chartRef.current, 'dark'); + + // 监听窗口大小变化 + const handleResize = () => { + chartInstance.current?.resize(); + }; + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + chartInstance.current?.dispose(); + chartInstance.current = null; + }; + }, [isOpen]); + + // 更新图表数据 + useEffect(() => { + if (!chartInstance.current || data.length === 0) return; + + 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' + ); + + // 图表配置 + const option: echarts.EChartsOption = { + backgroundColor: '#1a1a1a', + title: { + text: `${stock?.stock_name || stock?.stock_code} - 分时图`, + 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 changeColor = item.change_percent >= 0 ? '#ef5350' : '#26a69a'; + const changeSign = item.change_percent >= 0 ? '+' : ''; + + return ` +
+
${item.time}
+
+ 价格: + ${item.price.toFixed(2)} +
+
+ 均价: + ${item.avg_price.toFixed(2)} +
+
+ 涨跌幅: + ${changeSign}${item.change_percent.toFixed(2)}% +
+
+ 成交量: + ${(item.volume / 100).toFixed(0)}手 +
+
+ `; + }, + }, + grid: [ + { + left: '5%', + right: '5%', + top: '15%', + height: '55%', + }, + { + left: '5%', + right: '5%', + top: '75%', + height: '15%', + }, + ], + xAxis: [ + { + type: 'category', + data: times, + gridIndex: 0, + axisLine: { + lineStyle: { + color: '#404040', + }, + }, + axisLabel: { + color: '#999', + interval: Math.floor(times.length / 6), + }, + splitLine: { + show: true, + lineStyle: { + color: '#2a2a2a', + }, + }, + }, + { + type: 'category', + data: times, + gridIndex: 1, + axisLine: { + lineStyle: { + color: '#404040', + }, + }, + axisLabel: { + color: '#999', + interval: Math.floor(times.length / 6), + }, + }, + ], + 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 >= 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)' }, + ]), + }, + }, + { + 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); + }, [data, stock]); + + // 加载数据 + useEffect(() => { + if (isOpen) { + loadData(); + } + }, [isOpen, stock?.stock_code, eventTime]); + + if (!stock) return null; + + return ( + + + + + + + + {stock.stock_name || stock.stock_code} ({stock.stock_code}) + + + + 分时走势图 + + + + + + {error && ( + + + {error} + + )} + + + {loading && ( + + + + 加载分时数据... + + + )} +
+ + + + + ); +}; + +export default TimelineChartModal; diff --git a/src/views/Community/components/DynamicNewsDetail/StockListItem.js b/src/views/Community/components/DynamicNewsDetail/StockListItem.js index e91de2e6..c6de6078 100644 --- a/src/views/Community/components/DynamicNewsDetail/StockListItem.js +++ b/src/views/Community/components/DynamicNewsDetail/StockListItem.js @@ -18,7 +18,8 @@ import { import { StarIcon } from '@chakra-ui/icons'; import MiniTimelineChart from '../StockDetailPanel/components/MiniTimelineChart'; import MiniKLineChart from './MiniKLineChart'; -import StockChartModal from '../../../../components/StockChart/StockChartModal'; +import TimelineChartModal from '../../../../components/StockChart/TimelineChartModal'; +import KLineChartModal from '../../../../components/StockChart/KLineChartModal'; import CitedContent from '../../../../components/Citation/CitedContent'; import { getChangeColor } from '../../../../utils/colorUtils'; import { PROFESSIONAL_COLORS } from '../../../../constants/professionalTheme'; @@ -51,8 +52,8 @@ const StockListItem = ({ const dividerColor = PROFESSIONAL_COLORS.border.default; const [isDescExpanded, setIsDescExpanded] = useState(false); - const [isModalOpen, setIsModalOpen] = useState(false); - const [modalChartType, setModalChartType] = useState('timeline'); // 跟踪用户点击的图表类型 + const [isTimelineModalOpen, setIsTimelineModalOpen] = useState(false); + const [isKLineModalOpen, setIsKLineModalOpen] = useState(false); const handleViewDetail = () => { const stockCode = stock.stock_code.split('.')[0]; @@ -204,8 +205,7 @@ const StockListItem = ({ bg="rgba(59, 130, 246, 0.1)" onClick={(e) => { e.stopPropagation(); - setModalChartType('timeline'); // 设置为分时图 - setIsModalOpen(true); + setIsTimelineModalOpen(true); }} cursor="pointer" flexShrink={0} @@ -247,8 +247,7 @@ const StockListItem = ({ bg="rgba(168, 85, 247, 0.1)" onClick={(e) => { e.stopPropagation(); - setModalChartType('daily'); // 设置为日K线 - setIsModalOpen(true); + setIsKLineModalOpen(true); }} cursor="pointer" flexShrink={0} @@ -380,15 +379,23 @@ const StockListItem = ({ - {/* 股票详情弹窗 - 未打开时不渲染 */} - {isModalOpen && ( - setIsModalOpen(false)} + {/* 分时图弹窗 */} + {isTimelineModalOpen && ( + setIsTimelineModalOpen(false)} + stock={stock} + eventTime={eventTime} + /> + )} + + {/* K线图弹窗 */} + {isKLineModalOpen && ( + setIsKLineModalOpen(false)} stock={stock} eventTime={eventTime} - size="6xl" - initialChartType={modalChartType} // 传递用户点击的图表类型 /> )}