diff --git a/src/components/StockChart/StockChartAntdModal.js b/src/components/StockChart/StockChartAntdModal.js deleted file mode 100644 index c90b9169..00000000 --- a/src/components/StockChart/StockChartAntdModal.js +++ /dev/null @@ -1,594 +0,0 @@ -// src/components/StockChart/StockChartAntdModal.js - Antd版本的股票图表组件 -import React, { useState, useEffect, useRef } from 'react'; -import { Modal, Button, Spin, Typography } from 'antd'; -import ReactECharts from 'echarts-for-react'; -import { echarts } from '@lib/echarts'; -import dayjs from 'dayjs'; -import { stockService } from '../../services/eventService'; -import CitedContent from '../Citation/CitedContent'; -import { logger } from '../../utils/logger'; -import RiskDisclaimer from '../RiskDisclaimer'; - -const { Text } = Typography; - -const StockChartAntdModal = ({ - open = false, - onCancel, - stock, - eventTime, - fixed = false, - width = 800 -}) => { - const chartRef = useRef(null); - const chartInstanceRef = useRef(null); - const [activeChartType, setActiveChartType] = useState('timeline'); - const [loading, setLoading] = useState(false); - const [chartData, setChartData] = useState(null); - const [preloadedData, setPreloadedData] = useState({}); - - // 预加载数据 - const preloadData = async (type) => { - if (!stock?.stock_code || preloadedData[type]) return; - - try { - // 统一的事件时间处理逻辑:盘后事件推到次日开盘 - let adjustedEventTime = eventTime; - if (eventTime) { - try { - const eventMoment = dayjs(eventTime); - if (eventMoment.isValid()) { - // 如果是15:00之后的事件,推到下一个交易日的9:30 - if (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('StockChartAntdModal', '事件时间解析失败', { - eventTime, - error: e.message - }); - } - } - - const response = await stockService.getKlineData(stock.stock_code, type, adjustedEventTime); - setPreloadedData(prev => ({...prev, [type]: response})); - logger.debug('StockChartAntdModal', '数据预加载成功', { - stockCode: stock.stock_code, - type, - dataLength: response?.data?.length || 0 - }); - } catch (err) { - logger.error('StockChartAntdModal', 'preloadData', err, { - stockCode: stock?.stock_code, - type - }); - } - }; - - // 预加载数据的effect - useEffect(() => { - if (open && stock?.stock_code) { - // 预加载两种图表类型的数据 - preloadData('timeline'); - preloadData('daily'); - } - }, [open, stock?.stock_code, eventTime]); - - // 加载图表数据 - useEffect(() => { - const loadChartData = async () => { - if (!stock?.stock_code) return; - - try { - setLoading(true); - - // 先尝试使用预加载的数据 - let data = preloadedData[activeChartType]; - - if (!data) { - // 如果预加载数据不存在,则立即请求 - let adjustedEventTime = eventTime; - if (eventTime) { - try { - const eventMoment = dayjs(eventTime); - if (eventMoment.isValid()) { - // 如果是15:00之后的事件,推到下一个交易日的9:30 - if (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('StockChartAntdModal', '事件时间解析失败', { - eventTime, - error: e.message - }); - } - } - - data = await stockService.getKlineData(stock.stock_code, activeChartType, adjustedEventTime); - } - - setChartData(data); - logger.debug('StockChartAntdModal', '图表数据加载成功', { - stockCode: stock.stock_code, - chartType: activeChartType, - dataLength: data?.data?.length || 0 - }); - } catch (error) { - logger.error('StockChartAntdModal', 'loadChartData', error, { - stockCode: stock?.stock_code, - chartType: activeChartType - }); - } finally { - setLoading(false); - } - }; - - if (stock && stock.stock_code) { - loadChartData(); - } - }, [stock?.stock_code, activeChartType, eventTime]); - - // 生成图表配置 - const getChartOption = () => { - if (!chartData || !chartData.data) { - return { - title: { text: '暂无数据', left: 'center' }, - xAxis: { type: 'category', data: [] }, - yAxis: { type: 'value' }, - series: [{ data: [], type: 'line' }] - }; - } - - const data = chartData.data; - const tradeDate = chartData.trade_date; - - // 处理数据格式 - let times = []; - let prices = []; - let opens = []; - let highs = []; - let lows = []; - let closes = []; - let volumes = []; - - if (Array.isArray(data)) { - times = data.map(item => item.time || item.date || item.timestamp); - prices = data.map(item => item.close || item.price || item.value); - opens = data.map(item => item.open); - highs = data.map(item => item.high); - lows = data.map(item => item.low); - closes = data.map(item => item.close); - volumes = data.map(item => item.volume); - } else if (data.times && data.prices) { - times = data.times; - prices = data.prices; - opens = data.opens || []; - highs = data.highs || []; - lows = data.lows || []; - closes = data.closes || []; - volumes = data.volumes || []; - } - - // 生成K线数据结构 - const klineData = times.map((t, i) => [opens[i], closes[i], lows[i], highs[i]]); - - // 计算事件标记线位置 - let markLineData = []; - if (eventTime && times.length > 0) { - const eventMoment = dayjs(eventTime); - const eventDate = eventMoment.format('YYYY-MM-DD'); - - if (activeChartType === 'timeline') { - // 分时图:在相同交易日内定位具体时间 - if (eventDate === tradeDate) { - const eventTime = eventMoment.format('HH:mm'); - 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; - } - } - - markLineData = [{ - name: '事件发生', - xAxis: nearestIdx, - label: { - formatter: '事件发生', - position: 'middle', - color: '#FFD700', - fontSize: 12 - }, - lineStyle: { - color: '#FFD700', - type: 'solid', - width: 2 - } - }]; - } - } else if (activeChartType === 'daily') { - // 日K线:定位到交易日 - let targetIndex = -1; - - // 1. 先尝试找到完全匹配的日期 - targetIndex = times.findIndex(time => time === eventDate); - - // 2. 如果没有完全匹配,找到第一个大于等于事件日期的交易日 - if (targetIndex === -1) { - for (let i = 0; i < times.length; i++) { - if (times[i] >= eventDate) { - targetIndex = i; - break; - } - } - } - - // 3. 如果事件日期晚于所有交易日,则标记在最后一个交易日 - if (targetIndex === -1 && eventDate > times[times.length - 1]) { - targetIndex = times.length - 1; - } - - // 4. 如果事件日期早于所有交易日,则标记在第一个交易日 - if (targetIndex === -1 && eventDate < times[0]) { - targetIndex = 0; - } - - if (targetIndex >= 0) { - let labelText = '事件发生'; - let labelPosition = 'middle'; - - // 根据事件时间和交易日的关系调整标签 - if (eventDate === times[targetIndex]) { - if (eventMoment.hour() >= 15) { - labelText = '事件发生\n(盘后)'; - } else if (eventMoment.hour() < 9 || (eventMoment.hour() === 9 && eventMoment.minute() < 30)) { - labelText = '事件发生\n(盘前)'; - } - } else if (eventDate < times[targetIndex]) { - labelText = '事件发生\n(前一日)'; - labelPosition = 'start'; - } else { - labelText = '事件发生\n(影响日)'; - labelPosition = 'end'; - } - - markLineData = [{ - 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 - } - }]; - } - } - } - - // 分时图 - if (activeChartType === 'timeline') { - const avgPrices = data.map(item => item.avg_price); - - // 获取昨收盘价作为基准 - const prevClose = chartData.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'; - - 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) { - const d = params[0]?.dataIndex ?? 0; - const price = prices[d]; - const avgPrice = avgPrices[d]; - const volume = volumes[d]; - - // 安全计算涨跌幅,处理 undefined/null/0 的情况 - const safeCalcPercent = (val, base) => { - if (val == null || base == null || base === 0) return 0; - return ((val - base) / base * 100); - }; - - const priceChangePercent = safeCalcPercent(price, prevClose); - const avgChangePercent = safeCalcPercent(avgPrice, prevClose); - const priceColor = priceChangePercent >= 0 ? '#ef5350' : '#26a69a'; - const avgColor = avgChangePercent >= 0 ? '#ef5350' : '#26a69a'; - - // 安全格式化数字 - const safeFixed = (val, digits = 2) => (val != null && !isNaN(val)) ? val.toFixed(digits) : '-'; - const formatPercent = (val) => { - if (val == null || isNaN(val)) return '-'; - return (val >= 0 ? '+' : '') + val.toFixed(2) + '%'; - }; - - return `时间:${times[d] || '-'}
现价:¥${safeFixed(price)} (${formatPercent(priceChangePercent)})
均价:¥${safeFixed(avgPrice)} (${formatPercent(avgChangePercent)})
昨收:¥${safeFixed(prevClose)}
成交量:${volume != null ? Math.round(volume/100) + '手' : '-'}`; - } - }, - grid: [ - { left: '10%', right: '10%', height: '50%', top: '15%' }, - { left: '10%', right: '10%', top: '70%', height: '20%' } - ], - 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) { - if (value == null || isNaN(value)) return '-'; - return (value >= 0 ? '+' : '') + value.toFixed(2) + '%'; - } - }, - splitLine: { - show: true, - lineStyle: { - color: '#f0f0f0' - } - } - }, - { - type: 'value', - gridIndex: 0, - scale: false, - position: 'right', - axisLabel: { - formatter: function(value) { - if (value == null || isNaN(value)) return '-'; - return (value >= 0 ? '+' : '') + value.toFixed(2) + '%'; - } - } - }, - { type: 'value', gridIndex: 1, scale: true, axisLabel: { formatter: v => (v != null && !isNaN(v)) ? 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 - } - }, - ...markLineData - ], - 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 (activeChartType === 'daily') { - 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) { - const kline = params[0]; - const volume = params[1]; - if (!kline || !kline.data) return ''; - let tooltipHtml = `日期: ${times[kline.dataIndex]}
开盘: ¥${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: times, scale: true, boundaryGap: true, gridIndex: 0 }, - { type: 'category', gridIndex: 1, data: times, axisLabel: { show: false } } - ], - yAxis: [ - { scale: true, splitArea: { show: true }, gridIndex: 0 }, - { scale: true, gridIndex: 1, axisLabel: { formatter: (value) => 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: markLineData, - 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: closes[index] >= opens[index] ? '#ef5350' : '#26a69a' - } - })) - } - ] - }; - } - }; - - return ( - -
- {/* 图表类型切换按钮 */} -
- - -
- - {/* 图表容器 */} -
- {loading ? ( -
- -
- ) : ( - { - setTimeout(() => chart.resize(), 50); - }} - /> - )} -
- - {/* 关联描述 */} - {stock?.relation_desc?.data ? ( - // 使用引用组件(带研报来源) - - ) : stock?.relation_desc ? ( - // 降级显示(无引用数据) -
- 关联描述: - {stock.relation_desc}(AI合成) -
- ) : null} - - {/* 风险提示 */} - -
-
- ); -}; - -export default StockChartAntdModal; \ No newline at end of file