diff --git a/src/components/StockChart/StockChartModal.tsx b/src/components/StockChart/StockChartModal.tsx index 6ed58f59..5bb10da5 100644 --- a/src/components/StockChart/StockChartModal.tsx +++ b/src/components/StockChart/StockChartModal.tsx @@ -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 = ({ isOpen, @@ -106,529 +73,47 @@ const StockChartModal: React.FC = ({ 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, + + // 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 => { - 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 => { - 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 {}; - }; + const { marker } = useEventMarker({ + chart, + data, + eventTime: eventTime || undefined, + eventTitle: '事件发生', + autoCreate: true, + }); + // 守卫子句 if (!stock) return null; return ( - + {stock.stock_name || stock.stock_code} ({stock.stock_code}) - 股票详情 - {chartData && {chartData.trade_date}} + {data.length > 0 && 数据点: {data.length}} + + {/* 重件发生标签 - 仅在有 eventTime 时显示 */} + {eventTime && ( + + 重件发生(影响日) + + )} - {/* 图表区域 */} - + {/* 错误提示 */} + {(chartError || dataError) && ( + + + 图表加载失败:{chartError?.message || dataError?.message} + + )} + + {/* 图表区域 - 响应式高度 */} + {loading && ( = ({ )} -
+
{/* 关联描述 */} diff --git a/src/components/StockChart/config/klineTheme.ts b/src/components/StockChart/config/klineTheme.ts index 05c70912..1ac7edec 100644 --- a/src/components/StockChart/config/klineTheme.ts +++ b/src/components/StockChart/config/klineTheme.ts @@ -290,9 +290,193 @@ export const darkTheme: DeepPartial = { }, }; +/** + * 分时图专用主题配置 + * 特点:面积图样式、均价线、百分比Y轴 + */ +export const timelineTheme: DeepPartial = { + ...lightTheme, + candle: { + type: 'area', // ✅ 面积图模式(分时线) + area: { + lineSize: 2, + lineColor: CHART_COLORS.up, // 默认红色,实际会根据涨跌动态调整 + value: 'close', + backgroundColor: [ + { + offset: 0, + color: 'rgba(239, 83, 80, 0.2)', // 红色半透明渐变(顶部) + }, + { + offset: 1, + color: 'rgba(239, 83, 80, 0.01)', // 红色几乎透明(底部) + }, + ], + }, + priceMark: { + show: true, + high: { + show: false, // 分时图不显示最高最低价标记 + }, + low: { + show: false, + }, + last: { + show: true, + upColor: CHART_COLORS.up, + downColor: CHART_COLORS.down, + noChangeColor: CHART_COLORS.neutral, + line: { + show: true, + style: 'dashed', + dashValue: [4, 2], + size: 1, + }, + text: { + show: true, + size: 12, + paddingLeft: 4, + paddingTop: 2, + paddingRight: 4, + paddingBottom: 2, + borderRadius: 2, + }, + }, + }, + tooltip: { + showRule: 'always', + showType: 'standard', + // ✅ 自定义 Tooltip 标签和格式化 + labels: ['时间: ', '现价: ', '涨跌: ', '均价: ', '昨收: ', '成交量: '], + // 自定义格式化函数(如果 KLineChart 支持) + formatter: (data: any, indicator: any) => { + if (!data) return []; + + const { timestamp, close, volume, prev_close } = data; + const time = new Date(timestamp); + const timeStr = `${String(time.getHours()).padStart(2, '0')}:${String(time.getMinutes()).padStart(2, '0')}`; + + // 计算涨跌幅 + const changePercent = prev_close ? ((close - prev_close) / prev_close * 100).toFixed(2) : '0.00'; + const changeValue = prev_close ? (close - prev_close).toFixed(2) : '0.00'; + + // 成交量转换为手(1手 = 100股) + const volumeHands = Math.floor(volume / 100); + + return [ + { title: '时间', value: timeStr }, + { title: '现价', value: `¥${close?.toFixed(2) || '--'}` }, + { title: '涨跌', value: `${changeValue} (${changePercent}%)` }, + { title: '昨收', value: `¥${prev_close?.toFixed(2) || '--'}` }, + { title: '成交量', value: `${volumeHands.toLocaleString()}手` }, + ]; + }, + text: { + size: 12, + family: 'Helvetica, Arial, sans-serif', + weight: 'normal', + color: CHART_COLORS.text, + }, + }, + }, + yAxis: { + ...lightTheme.yAxis, + type: 'percentage', // ✅ 百分比模式 + position: 'left', // Y轴在左侧 + inside: false, + reverse: false, + tickText: { + ...lightTheme.yAxis?.tickText, + // ✅ 自定义 Y 轴格式化:显示百分比(如 "+2.50%", "-1.20%", "0.00%") + formatter: (value: any) => { + const percent = (value * 100).toFixed(2); + // 处理 0 值:显示 "0.00%" 而非 "-0.00%" 或 "+0.00%" + if (Math.abs(value) < 0.0001) { + return '0.00%'; + } + return value > 0 ? `+${percent}%` : `${percent}%`; + }, + }, + }, + grid: { + show: true, + horizontal: { + show: true, + size: 1, + color: CHART_COLORS.grid, + style: 'solid', // 分时图使用实线网格 + }, + vertical: { + show: false, + }, + }, +}; + +/** + * 分时图深色主题 + */ +export const timelineThemeDark: DeepPartial = { + ...timelineTheme, + ...darkTheme, + candle: { + ...timelineTheme.candle, + tooltip: { + ...timelineTheme.candle?.tooltip, + text: { + ...timelineTheme.candle?.tooltip?.text, + color: CHART_COLORS.textDark, + }, + }, + }, + grid: { + ...timelineTheme.grid, + horizontal: { + ...timelineTheme.grid?.horizontal, + color: CHART_COLORS.gridDark, + }, + }, +}; + /** * 获取主题配置(根据 Chakra UI colorMode) */ export const getTheme = (colorMode: 'light' | 'dark' = 'light'): DeepPartial => { return colorMode === 'dark' ? darkTheme : lightTheme; }; + +/** + * 获取分时图主题配置 + */ +export const getTimelineTheme = (colorMode: 'light' | 'dark' = 'light'): DeepPartial => { + const baseTheme = colorMode === 'dark' ? timelineThemeDark : timelineTheme; + + // ✅ 添加成交量指标样式(蓝色渐变柱状图)+ 成交量单位格式化 + return { + ...baseTheme, + indicator: { + ...baseTheme.indicator, + bars: [ + { + upColor: 'rgba(59, 130, 246, 0.6)', // 蓝色(涨) + downColor: 'rgba(59, 130, 246, 0.6)', // 蓝色(跌)- 分时图成交量统一蓝色 + noChangeColor: 'rgba(59, 130, 246, 0.6)', + } + ], + // ✅ 自定义 Tooltip 格式化(显示完整信息) + tooltip: { + ...baseTheme.indicator?.tooltip, + // 格式化成交量数值:显示为"手"(1手 = 100股) + formatter: (params: any) => { + if (params.name === 'VOL' && params.calcParamsText) { + const volume = params.calcParamsText.match(/\d+/)?.[0]; + if (volume) { + const hands = Math.floor(Number(volume) / 100); + return `成交量: ${hands.toLocaleString()}手`; + } + } + return params.calcParamsText || ''; + }, + }, + }, + }; +}; diff --git a/src/components/StockChart/hooks/useEventMarker.ts b/src/components/StockChart/hooks/useEventMarker.ts index 76b0b734..c0913432 100644 --- a/src/components/StockChart/hooks/useEventMarker.ts +++ b/src/components/StockChart/hooks/useEventMarker.ts @@ -10,6 +10,7 @@ import type { EventMarker, KLineDataPoint } from '../types'; import { createEventMarkerFromTime, createEventMarkerOverlay, + createEventHighlightOverlay, removeAllEventMarkers, } from '../utils/eventMarkerUtils'; import { logger } from '@utils/logger'; @@ -68,6 +69,7 @@ export const useEventMarker = ( const [marker, setMarker] = useState(null); const [markerId, setMarkerId] = useState(null); + const [highlightId, setHighlightId] = useState(null); /** * 创建事件标记 @@ -110,6 +112,18 @@ export const useEventMarker = ( const actualId = Array.isArray(id) ? id[0] : id; setMarkerId(actualId as string); + // 4. 创建黄色高亮背景(事件影响日) + const highlightOverlay = createEventHighlightOverlay(time, data); + if (highlightOverlay) { + const highlightResult = chart.createOverlay(highlightOverlay); + const actualHighlightId = Array.isArray(highlightResult) ? highlightResult[0] : highlightResult; + setHighlightId(actualHighlightId as string); + + logger.info('useEventMarker', 'createMarker', '事件高亮背景创建成功', { + highlightId: actualHighlightId, + }); + } + logger.info('useEventMarker', 'createMarker', '事件标记创建成功', { markerId: actualId, label, @@ -130,25 +144,34 @@ export const useEventMarker = ( * 移除事件标记 */ const removeMarker = useCallback(() => { - if (!chart || !markerId) { + if (!chart) { return; } try { - chart.removeOverlay(markerId); + if (markerId) { + chart.removeOverlay(markerId); + } + if (highlightId) { + chart.removeOverlay(highlightId); + } + setMarker(null); setMarkerId(null); + setHighlightId(null); - logger.debug('useEventMarker', 'removeMarker', '移除事件标记', { + logger.debug('useEventMarker', 'removeMarker', '移除事件标记和高亮', { markerId, + highlightId, chartId: chart.id, }); } catch (err) { logger.error('useEventMarker', 'removeMarker', err as Error, { markerId, + highlightId, }); } - }, [chart, markerId]); + }, [chart, markerId, highlightId]); /** * 移除所有标记 @@ -162,8 +185,9 @@ export const useEventMarker = ( removeAllEventMarkers(chart); setMarker(null); setMarkerId(null); + setHighlightId(null); - logger.debug('useEventMarker', 'removeAllMarkers', '移除所有事件标记', { + logger.debug('useEventMarker', 'removeAllMarkers', '移除所有事件标记和高亮', { chartId: chart.id, }); } catch (err) { @@ -189,15 +213,20 @@ export const useEventMarker = ( // 清理:组件卸载时移除所有标记 useEffect(() => { return () => { - if (chart && markerId) { + if (chart) { try { - chart.removeOverlay(markerId); + if (markerId) { + chart.removeOverlay(markerId); + } + if (highlightId) { + chart.removeOverlay(highlightId); + } } catch (err) { // 忽略清理时的错误 } } }; - }, [chart, markerId]); + }, [chart, markerId, highlightId]); return { marker, diff --git a/src/components/StockChart/hooks/useKLineChart.ts b/src/components/StockChart/hooks/useKLineChart.ts index 6be14294..601fc7a4 100644 --- a/src/components/StockChart/hooks/useKLineChart.ts +++ b/src/components/StockChart/hooks/useKLineChart.ts @@ -5,12 +5,13 @@ */ import { useEffect, useRef, useState } from 'react'; -import { init, dispose } from 'klinecharts'; +import { init, dispose, registerIndicator } from 'klinecharts'; import type { Chart } from 'klinecharts'; import { useColorMode } from '@chakra-ui/react'; -import { getTheme } from '../config/klineTheme'; +import { getTheme, getTimelineTheme } from '../config/klineTheme'; import { CHART_INIT_OPTIONS } from '../config'; import { logger } from '@utils/logger'; +import { avgPriceIndicator } from '../indicators/avgPriceIndicator'; export interface UseKLineChartOptions { /** 图表容器 ID */ @@ -19,6 +20,8 @@ export interface UseKLineChartOptions { height?: number; /** 是否自动调整大小 */ autoResize?: boolean; + /** 图表类型(timeline/daily) */ + chartType?: 'timeline' | 'daily'; } export interface UseKLineChartReturn { @@ -48,57 +51,122 @@ export interface UseKLineChartReturn { export const useKLineChart = ( options: UseKLineChartOptions ): UseKLineChartReturn => { - const { containerId, height = 400, autoResize = true } = options; + const { containerId, height = 400, autoResize = true, chartType = 'daily' } = options; const chartRef = useRef(null); const chartInstanceRef = useRef(null); + const [chartInstance, setChartInstance] = useState(null); // ✅ 新增:chart state(触发重渲染) const [isInitialized, setIsInitialized] = useState(false); const [error, setError] = useState(null); const { colorMode } = useColorMode(); - // 图表初始化 + // 全局注册自定义均价线指标(只执行一次) useEffect(() => { - if (!chartRef.current) { - logger.warn('useKLineChart', 'init', '图表容器未挂载', { containerId }); - return; - } - try { - logger.debug('useKLineChart', 'init', '开始初始化图表', { - containerId, - height, - colorMode, - }); + registerIndicator(avgPriceIndicator); + logger.debug('useKLineChart', '✅ 自定义均价线指标(AVG)注册成功'); + } catch (err) { + // 如果已注册会报错,忽略即可 + logger.debug('useKLineChart', 'AVG指标已注册或注册失败', err); + } + }, []); - // 初始化图表实例(KLineChart 10.0 API) - const chartInstance = init(chartRef.current, { - ...CHART_INIT_OPTIONS, - // 设置初始样式(根据主题) - styles: getTheme(colorMode), - }); - - if (!chartInstance) { - throw new Error('图表初始化失败:返回 null'); + // 图表初始化(添加延迟重试机制,处理 Modal 动画延迟) + useEffect(() => { + // 图表初始化函数 + const initChart = (): boolean => { + if (!chartRef.current) { + logger.warn('useKLineChart', 'init', '图表容器未挂载,将在 50ms 后重试', { containerId }); + return false; } - chartInstanceRef.current = chartInstance; - setIsInitialized(true); - setError(null); + try { + logger.debug('useKLineChart', 'init', '开始初始化图表', { + containerId, + height, + colorMode, + }); - logger.info('useKLineChart', 'init', '图表初始化成功', { - containerId, - chartId: chartInstance.id, - }); - } catch (err) { - const error = err as Error; - logger.error('useKLineChart', 'init', error, { containerId }); - setError(error); - setIsInitialized(false); + // 初始化图表实例(KLineChart 10.0 API) + // ✅ 根据 chartType 选择主题 + const themeStyles = chartType === 'timeline' + ? getTimelineTheme(colorMode) + : getTheme(colorMode); + + const chartInstance = init(chartRef.current, { + ...CHART_INIT_OPTIONS, + // 设置初始样式(根据主题和图表类型) + styles: themeStyles, + }); + + if (!chartInstance) { + throw new Error('图表初始化失败:返回 null'); + } + + chartInstanceRef.current = chartInstance; + setChartInstance(chartInstance); // ✅ 新增:更新 state,触发重渲染 + setIsInitialized(true); + setError(null); + + // ✅ 新增:创建成交量指标窗格 + try { + const volumePaneId = chartInstance.createIndicator('VOL', false, { + height: 100, // 固定高度 100px(约占整体的 20-25%) + }); + + logger.debug('useKLineChart', 'init', '成交量窗格创建成功', { + volumePaneId, + }); + } catch (err) { + logger.warn('useKLineChart', 'init', '成交量窗格创建失败', { + error: err, + }); + // 不阻塞主流程,继续执行 + } + + logger.info('useKLineChart', 'init', '✅ 图表初始化成功', { + containerId, + chartId: chartInstance.id, + }); + + return true; + } catch (err) { + const error = err as Error; + logger.error('useKLineChart', 'init', error, { containerId }); + setError(error); + setIsInitialized(false); + return false; + } + }; + + // 立即尝试初始化 + if (initChart()) { + // 成功,直接返回清理函数 + return () => { + if (chartInstanceRef.current) { + logger.debug('useKLineChart', 'dispose', '销毁图表实例', { + containerId, + chartId: chartInstanceRef.current.id, + }); + + dispose(chartInstanceRef.current); + chartInstanceRef.current = null; + setChartInstance(null); // ✅ 新增:清空 state + setIsInitialized(false); + } + }; } - // 清理函数:销毁图表实例 + // 失败则延迟重试(处理 Modal 动画延迟导致的 DOM 未挂载) + const timer = setTimeout(() => { + logger.debug('useKLineChart', 'init', '执行延迟重试', { containerId }); + initChart(); + }, 50); + + // 清理函数:清除定时器和销毁图表实例 return () => { + clearTimeout(timer); if (chartInstanceRef.current) { logger.debug('useKLineChart', 'dispose', '销毁图表实例', { containerId, @@ -107,11 +175,12 @@ export const useKLineChart = ( dispose(chartInstanceRef.current); chartInstanceRef.current = null; + setChartInstance(null); // ✅ 新增:清空 state setIsInitialized(false); } }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [containerId]); // 只在 containerId 变化时重新初始化 + }, [containerId, chartType]); // containerId 或 chartType 变化时重新初始化 // 主题切换:更新图表样式 useEffect(() => { @@ -120,17 +189,21 @@ export const useKLineChart = ( } try { - const newTheme = getTheme(colorMode); + // ✅ 根据 chartType 选择主题 + const newTheme = chartType === 'timeline' + ? getTimelineTheme(colorMode) + : getTheme(colorMode); chartInstanceRef.current.setStyles(newTheme); logger.debug('useKLineChart', 'updateTheme', '更新图表主题', { colorMode, + chartType, chartId: chartInstanceRef.current.id, }); } catch (err) { - logger.error('useKLineChart', 'updateTheme', err as Error, { colorMode }); + logger.error('useKLineChart', 'updateTheme', err as Error, { colorMode, chartType }); } - }, [colorMode, isInitialized]); + }, [colorMode, chartType, isInitialized]); // 容器尺寸变化:调整图表大小 useEffect(() => { @@ -165,7 +238,7 @@ export const useKLineChart = ( }, [isInitialized, autoResize]); return { - chart: chartInstanceRef.current, + chart: chartInstance, // ✅ 返回 state 而非 ref,确保变化触发重渲染 chartRef, isInitialized, error, diff --git a/src/components/StockChart/hooks/useKLineData.ts b/src/components/StockChart/hooks/useKLineData.ts index e3244f02..693a6cf6 100644 --- a/src/components/StockChart/hooks/useKLineData.ts +++ b/src/components/StockChart/hooks/useKLineData.ts @@ -9,7 +9,8 @@ import type { Chart } from 'klinecharts'; import type { ChartType, KLineDataPoint, RawDataPoint } from '../types'; import { processChartData } from '../utils/dataAdapter'; import { logger } from '@utils/logger'; -import { stockService } from '@services/stockService'; +import { stockService } from '@services/eventService'; +import { klineDataCache, getCacheKey } from '@views/Community/components/StockDetailPanel/utils/klineDataCache'; export interface UseKLineDataOptions { /** KLineChart 实例 */ @@ -91,22 +92,38 @@ export const useKLineData = ( eventTime, }); - // 调用后端 API 获取数据 - const response = await stockService.getKlineData( - stockCode, - chartType, - eventTime - ); + // 1. 先检查缓存 + const cacheKey = getCacheKey(stockCode, eventTime, chartType); + const cachedData = klineDataCache.get(cacheKey); - if (!response || !response.data) { - throw new Error('后端返回数据为空'); + let rawDataList; + + if (cachedData && cachedData.length > 0) { + // 使用缓存数据 + rawDataList = cachedData; + } else { + // 2. 缓存没有数据,调用 API 请求 + const response = await stockService.getKlineData( + stockCode, + chartType, + eventTime + ); + + if (!response || !response.data) { + throw new Error('后端返回数据为空'); + } + + rawDataList = response.data; + + // 3. 将数据写入缓存(避免下次重复请求) + klineDataCache.set(cacheKey, rawDataList); } - const rawDataList = response.data; setRawData(rawDataList); // 数据转换和处理 const processedData = processChartData(rawDataList, chartType, eventTime); + setData(processedData); logger.info('useKLineData', 'loadData', '数据加载成功', { @@ -130,7 +147,7 @@ export const useKLineData = ( }, [stockCode, chartType, eventTime]); /** - * 更新图表数据(使用 DataLoader 模式) + * 更新图表数据(使用 setDataLoader 方法) */ const updateChartData = useCallback( (klineData: KLineDataPoint[]) => { @@ -139,29 +156,120 @@ export const useKLineData = ( } try { - // KLineChart 10.0: 使用 setDataLoader 方法 - chart.setDataLoader({ - getBars: (params) => { - // 将数据传递给图表 - params.callback(klineData, { more: false }); + // 步骤 1: 设置 symbol(必需!getBars 调用的前置条件) + (chart as any).setSymbol({ + ticker: stockCode || 'UNKNOWN', // 股票代码 + pricePrecision: 2, // 价格精度(2位小数) + volumePrecision: 0 // 成交量精度(整数) + }); - logger.debug('useKLineData', 'updateChartData', 'DataLoader 回调', { - dataCount: klineData.length, + // 步骤 2: 设置 period(必需!getBars 调用的前置条件) + const periodType = chartType === 'timeline' ? 'minute' : 'day'; + (chart as any).setPeriod({ + type: periodType, // 分时图=minute,日K=day + span: 1 // 周期跨度(1分钟/1天) + }); + + // 步骤 3: 设置 DataLoader(同步数据加载器) + (chart as any).setDataLoader({ + getBars: (params: any) => { + if (params.type === 'init') { + // 初始化加载:返回完整数据 + params.callback(klineData, false); // false = 无更多数据可加载 + } else if (params.type === 'forward' || params.type === 'backward') { + // 向前/向后加载:我们没有更多数据,返回空数组 + params.callback([], false); + } + } + }); + + // 步骤 4: 触发初始化加载(这会调用 getBars with type="init") + (chart as any).resetData(); + + // 步骤 5: 根据数据量调整可见范围和柱子间距(让 K 线柱子填满图表区域) + setTimeout(() => { + try { + const dataLength = klineData.length; + + if (dataLength > 0) { + // 获取图表容器宽度 + const chartDom = (chart as any).getDom(); + const chartWidth = chartDom?.clientWidth || 1200; + + // 计算最优柱子间距 + // 公式:barSpace = (图表宽度 / 数据数量) * 0.7 + // 0.7 是为了留出一些间距,让图表不会太拥挤 + const optimalBarSpace = Math.max(8, Math.min(50, (chartWidth / dataLength) * 0.7)); + + (chart as any).setBarSpace(optimalBarSpace); + + // 减少右侧空白(默认值可能是 100-200,调小会减少右侧空白) + (chart as any).setOffsetRightDistance(50); + } + } catch (err) { + logger.error('useKLineData', 'updateChartData', err as Error, { + step: '调整可见范围失败', }); - }, - }); + } + }, 100); // 延迟 100ms 确保数据已加载和渲染 - logger.debug('useKLineData', 'updateChartData', '图表数据已更新', { - dataCount: klineData.length, - chartId: chart.id, - }); + // ✅ 步骤 4: 分时图添加均价线(使用自定义 AVG 指标) + if (chartType === 'timeline' && klineData.length > 0) { + setTimeout(() => { + try { + // 在主图窗格创建 AVG 均价线指标 + (chart as any).createIndicator('AVG', true, { + id: 'candle_pane', // 主图窗格 + }); + + console.log('[DEBUG] ✅ 均价线(AVG指标)添加成功'); + } catch (err) { + console.error('[DEBUG] ❌ 均价线添加失败:', err); + } + }, 150); // 延迟 150ms,确保数据加载完成后再创建指标 + + // ✅ 步骤 5: 添加昨收价基准线(灰色虚线) + setTimeout(() => { + try { + const prevClose = klineData[0]?.prev_close; + if (prevClose && prevClose > 0) { + // 创建水平线覆盖层 + (chart as any).createOverlay({ + name: 'horizontalStraightLine', + id: 'prev_close_line', + points: [{ value: prevClose }], + styles: { + line: { + style: 'dashed', + dashValue: [4, 2], + size: 1, + color: '#888888', // 灰色虚线 + }, + }, + extendData: { + label: `昨收: ${prevClose.toFixed(2)}`, + }, + }); + + console.log('[DEBUG] ✅ 昨收价基准线添加成功:', prevClose); + } + } catch (err) { + console.error('[DEBUG] ❌ 昨收价基准线添加失败:', err); + } + }, 200); // 延迟 200ms,确保均价线创建完成后再添加 + } + + logger.debug( + 'useKLineData', + `updateChartData - ${stockCode} (${chartType}) - ${klineData.length}条数据加载成功` + ); } catch (err) { logger.error('useKLineData', 'updateChartData', err as Error, { dataCount: klineData.length, }); } }, - [chart] + [chart, stockCode, chartType] ); /** @@ -172,9 +280,10 @@ export const useKLineData = ( setData(newData); updateChartData(newData); - logger.debug('useKLineData', 'updateData', '手动更新数据', { - newDataCount: newData.length, - }); + logger.debug( + 'useKLineData', + `updateData - ${stockCode} (${chartType}) - ${newData.length}条数据手动更新` + ); }, [updateChartData] ); @@ -189,9 +298,7 @@ export const useKLineData = ( if (chart) { chart.resetData(); - logger.debug('useKLineData', 'clearData', '清空数据', { - chartId: chart.id, - }); + logger.debug('useKLineData', `clearData - chartId: ${(chart as any).id}`); } }, [chart]); diff --git a/src/components/StockChart/indicators/avgPriceIndicator.ts b/src/components/StockChart/indicators/avgPriceIndicator.ts new file mode 100644 index 00000000..3c0e44b8 --- /dev/null +++ b/src/components/StockChart/indicators/avgPriceIndicator.ts @@ -0,0 +1,93 @@ +/** + * 自定义均价线指标 + * + * 用于分时图显示橙黄色均价线 + * 计算公式:累计成交额 / 累计成交量 + */ + +import type { Indicator, KLineData } from 'klinecharts'; + +export const avgPriceIndicator: Indicator = { + name: 'AVG', + shortName: 'AVG', + calcParams: [], + shouldOhlc: false, // 不显示 OHLC 信息 + shouldFormatBigNumber: false, + precision: 2, + minValue: null, + maxValue: null, + + figures: [ + { + key: 'avg', + title: '均价: ', + type: 'line', + }, + ], + + /** + * 计算均价 + * @param dataList K线数据列表 + * @returns 均价数据 + */ + calc: (dataList: KLineData[]) => { + let totalAmount = 0; // 累计成交额 + let totalVolume = 0; // 累计成交量 + + return dataList.map((kLineData) => { + const { close = 0, volume = 0 } = kLineData; + + totalAmount += close * volume; + totalVolume += volume; + + const avgPrice = totalVolume > 0 ? totalAmount / totalVolume : close; + + return { avg: avgPrice }; + }); + }, + + /** + * 绘制样式配置 + */ + styles: { + lines: [ + { + color: '#FF9800', // 橙黄色 + size: 2, + style: 'solid', + smooth: true, + }, + ], + }, + + /** + * Tooltip 格式化(显示均价 + 涨跌幅) + */ + createTooltipDataSource: ({ kLineData, indicator, defaultStyles }: any) => { + if (!indicator?.avg) { + return { + title: { text: '均价', color: defaultStyles.tooltip.text.color }, + value: { text: '--', color: '#FF9800' }, + }; + } + + const avgPrice = indicator.avg; + const prevClose = kLineData?.prev_close; + + // 计算均价涨跌幅 + let changeText = `¥${avgPrice.toFixed(2)}`; + if (prevClose && prevClose > 0) { + const changePercent = ((avgPrice - prevClose) / prevClose * 100).toFixed(2); + const changeValue = (avgPrice - prevClose).toFixed(2); + changeText = `¥${avgPrice.toFixed(2)} (${changeValue}, ${changePercent}%)`; + } + + return { + title: { text: '均价', color: defaultStyles.tooltip.text.color }, + value: { + text: changeText, + color: '#FF9800', + }, + }; + }, +}; diff --git a/src/components/StockChart/types/chart.types.ts b/src/components/StockChart/types/chart.types.ts index c0454fe9..da2f9164 100644 --- a/src/components/StockChart/types/chart.types.ts +++ b/src/components/StockChart/types/chart.types.ts @@ -25,6 +25,8 @@ export interface KLineDataPoint { volume: number; /** 成交额(可选) */ turnover?: number; + /** 昨收价(用于百分比计算和基准线)- 分时图专用 */ + prev_close?: number; } /** @@ -51,6 +53,8 @@ export interface RawDataPoint { volume: number; /** 均价(分时图专用) */ avg_price?: number; + /** 昨收价(用于百分比计算和基准线)- 分时图专用 */ + prev_close?: number; } /** diff --git a/src/components/StockChart/utils/dataAdapter.ts b/src/components/StockChart/utils/dataAdapter.ts index 5608e725..671b7c54 100644 --- a/src/components/StockChart/utils/dataAdapter.ts +++ b/src/components/StockChart/utils/dataAdapter.ts @@ -38,6 +38,7 @@ export const convertToKLineData = ( close: Number(item.close) || 0, volume: Number(item.volume) || 0, turnover: item.turnover ? Number(item.turnover) : undefined, + prev_close: item.prev_close ? Number(item.prev_close) : undefined, // ✅ 新增:昨收价(用于百分比计算和基准线) }; }); } catch (error) { @@ -171,10 +172,66 @@ export const deduplicateData = (data: KLineDataPoint[]): KLineDataPoint[] => { return Array.from(map.values()); }; +/** + * 根据事件时间裁剪数据范围(前后2周) + * + * @param data K线数据 + * @param eventTime 事件时间(ISO字符串) + * @param chartType 图表类型 + * @returns KLineDataPoint[] 裁剪后的数据 + */ +export const trimDataByEventTime = ( + data: KLineDataPoint[], + eventTime: string, + chartType: ChartType +): KLineDataPoint[] => { + if (!eventTime || !data || data.length === 0) { + return data; + } + + try { + const eventTimestamp = dayjs(eventTime).valueOf(); + + // 根据图表类型设置不同的时间范围 + let beforeDays: number; + let afterDays: number; + + if (chartType === 'timeline') { + // 分时图:只显示事件当天(前后0天) + beforeDays = 0; + afterDays = 0; + } else { + // 日K线:显示前后14天(2周) + beforeDays = 14; + afterDays = 14; + } + + const startTime = dayjs(eventTime).subtract(beforeDays, 'day').startOf('day').valueOf(); + const endTime = dayjs(eventTime).add(afterDays, 'day').endOf('day').valueOf(); + + const trimmedData = data.filter((item) => { + return item.timestamp >= startTime && item.timestamp <= endTime; + }); + + logger.debug('dataAdapter', 'trimDataByEventTime', '数据时间范围裁剪完成', { + originalLength: data.length, + trimmedLength: trimmedData.length, + eventTime, + chartType, + dateRange: `${dayjs(startTime).format('YYYY-MM-DD')} ~ ${dayjs(endTime).format('YYYY-MM-DD')}`, + }); + + return trimmedData; + } catch (error) { + logger.error('dataAdapter', 'trimDataByEventTime', error as Error, { eventTime }); + return data; // 出错时返回原始数据 + } +}; + /** * 完整的数据处理流程 * - * 转换 → 验证 → 去重 → 排序 + * 转换 → 验证 → 去重 → 排序 → 时间裁剪(如果有 eventTime) * * @param rawData 后端原始数据 * @param chartType 图表类型 @@ -198,10 +255,16 @@ export const processChartData = ( // 4. 排序 data = sortDataByTime(data); + // 5. 根据事件时间裁剪范围(如果提供了 eventTime) + if (eventTime) { + data = trimDataByEventTime(data, eventTime, chartType); + } + logger.debug('dataAdapter', 'processChartData', '数据处理完成', { rawLength: rawData.length, processedLength: data.length, chartType, + hasEventTime: !!eventTime, }); return data; diff --git a/src/components/StockChart/utils/eventMarkerUtils.ts b/src/components/StockChart/utils/eventMarkerUtils.ts index ed3e4740..b6b6df63 100644 --- a/src/components/StockChart/utils/eventMarkerUtils.ts +++ b/src/components/StockChart/utils/eventMarkerUtils.ts @@ -92,6 +92,61 @@ export const createEventMarkerOverlay = ( } }; +/** + * 创建事件日K线黄色高亮覆盖层(垂直矩形背景) + * + * @param eventTime 事件时间(ISO字符串) + * @param data K线数据 + * @returns OverlayCreate | null 高亮覆盖层配置 + */ +export const createEventHighlightOverlay = ( + eventTime: string, + data: KLineDataPoint[] +): OverlayCreate | null => { + try { + const eventTimestamp = dayjs(eventTime).valueOf(); + const closestPoint = findClosestDataPoint(data, eventTimestamp); + + if (!closestPoint) { + logger.warn('eventMarkerUtils', 'createEventHighlightOverlay', '未找到匹配的数据点'); + return null; + } + + // 创建垂直矩形覆盖层(从图表顶部到底部的黄色半透明背景) + const overlay: OverlayCreate = { + name: 'rect', // 矩形覆盖层 + id: `event-highlight-${eventTimestamp}`, + points: [ + { + timestamp: closestPoint.timestamp, + value: closestPoint.high * 1.05, // 顶部位置(高于最高价5%) + }, + { + timestamp: closestPoint.timestamp, + value: closestPoint.low * 0.95, // 底部位置(低于最低价5%) + }, + ], + styles: { + style: 'fill', + color: 'rgba(255, 193, 7, 0.15)', // 黄色半透明背景(15%透明度) + borderColor: '#FFD54F', // 黄色边框 + borderSize: 2, + borderStyle: 'solid', + }, + }; + + logger.debug('eventMarkerUtils', 'createEventHighlightOverlay', '创建事件高亮覆盖层', { + timestamp: closestPoint.timestamp, + eventTime, + }); + + return overlay; + } catch (error) { + logger.error('eventMarkerUtils', 'createEventHighlightOverlay', error as Error); + return null; + } +}; + /** * 计算标记的 Y 轴位置 * diff --git a/src/mocks/data/kline.js b/src/mocks/data/kline.js index ced4aab1..f614143b 100644 --- a/src/mocks/data/kline.js +++ b/src/mocks/data/kline.js @@ -78,10 +78,16 @@ function generateTimeRange(data, startTime, endTime, basePrice, session) { const volume = Math.floor(Math.random() * 500000000 + 100000000); + // ✅ 修复:为分时图添加完整的 OHLC 字段 + const closePrice = parseFloat(price.toFixed(2)); data.push({ time: formatTime(current), - price: parseFloat(price.toFixed(2)), - close: parseFloat(price.toFixed(2)), + timestamp: current.getTime(), // ✅ 新增:毫秒时间戳 + open: parseFloat((price * 0.9999).toFixed(2)), // ✅ 新增:开盘价(略低于收盘) + high: parseFloat((price * 1.0002).toFixed(2)), // ✅ 新增:最高价(略高于收盘) + low: parseFloat((price * 0.9997).toFixed(2)), // ✅ 新增:最低价(略低于收盘) + close: closePrice, // ✅ 保留:收盘价 + price: closePrice, // ✅ 保留:兼容字段(供 MiniTimelineChart 使用) volume: volume, prev_close: basePrice });