From bcd67ed4108622a97b3d6e580e25114aac3606ad Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Sat, 22 Nov 2025 23:14:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=9B=E5=BB=BA=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=20Hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/StockChart/hooks/index.ts | 15 ++ .../StockChart/hooks/useEventMarker.ts | 209 +++++++++++++++++ .../StockChart/hooks/useKLineChart.ts | 173 ++++++++++++++ .../StockChart/hooks/useKLineData.ts | 222 ++++++++++++++++++ 4 files changed, 619 insertions(+) create mode 100644 src/components/StockChart/hooks/index.ts create mode 100644 src/components/StockChart/hooks/useEventMarker.ts create mode 100644 src/components/StockChart/hooks/useKLineChart.ts create mode 100644 src/components/StockChart/hooks/useKLineData.ts diff --git a/src/components/StockChart/hooks/index.ts b/src/components/StockChart/hooks/index.ts new file mode 100644 index 00000000..8dbc9dc7 --- /dev/null +++ b/src/components/StockChart/hooks/index.ts @@ -0,0 +1,15 @@ +/** + * StockChart 自定义 Hooks 统一导出 + * + * 使用方式: + * import { useKLineChart, useKLineData, useEventMarker } from '@components/StockChart/hooks'; + */ + +export { useKLineChart } from './useKLineChart'; +export type { UseKLineChartOptions, UseKLineChartReturn } from './useKLineChart'; + +export { useKLineData } from './useKLineData'; +export type { UseKLineDataOptions, UseKLineDataReturn } from './useKLineData'; + +export { useEventMarker } from './useEventMarker'; +export type { UseEventMarkerOptions, UseEventMarkerReturn } from './useEventMarker'; diff --git a/src/components/StockChart/hooks/useEventMarker.ts b/src/components/StockChart/hooks/useEventMarker.ts new file mode 100644 index 00000000..76b0b734 --- /dev/null +++ b/src/components/StockChart/hooks/useEventMarker.ts @@ -0,0 +1,209 @@ +/** + * useEventMarker Hook + * + * 管理事件标记的创建、更新和删除 + */ + +import { useEffect, useState, useCallback } from 'react'; +import type { Chart } from 'klinecharts'; +import type { EventMarker, KLineDataPoint } from '../types'; +import { + createEventMarkerFromTime, + createEventMarkerOverlay, + removeAllEventMarkers, +} from '../utils/eventMarkerUtils'; +import { logger } from '@utils/logger'; + +export interface UseEventMarkerOptions { + /** KLineChart 实例 */ + chart: Chart | null; + /** K 线数据(用于定位标记) */ + data: KLineDataPoint[]; + /** 事件时间(ISO 字符串) */ + eventTime?: string; + /** 事件标题(用于标记标签) */ + eventTitle?: string; + /** 是否自动创建标记 */ + autoCreate?: boolean; +} + +export interface UseEventMarkerReturn { + /** 当前标记 */ + marker: EventMarker | null; + /** 标记 ID(已添加到图表) */ + markerId: string | null; + /** 创建标记 */ + createMarker: (time: string, label: string, color?: string) => void; + /** 移除标记 */ + removeMarker: () => void; + /** 移除所有标记 */ + removeAllMarkers: () => void; +} + +/** + * 事件标记管理 Hook + * + * @param options 配置选项 + * @returns UseEventMarkerReturn + * + * @example + * const { marker, createMarker, removeMarker } = useEventMarker({ + * chart, + * data, + * eventTime: '2024-01-01 10:00:00', + * eventTitle: '重大公告', + * autoCreate: true, + * }); + */ +export const useEventMarker = ( + options: UseEventMarkerOptions +): UseEventMarkerReturn => { + const { + chart, + data, + eventTime, + eventTitle = '事件发生', + autoCreate = true, + } = options; + + const [marker, setMarker] = useState(null); + const [markerId, setMarkerId] = useState(null); + + /** + * 创建事件标记 + */ + const createMarker = useCallback( + (time: string, label: string, color?: string) => { + if (!chart || !data || data.length === 0) { + logger.warn('useEventMarker', 'createMarker', '图表或数据未准备好', { + hasChart: !!chart, + dataLength: data?.length || 0, + }); + return; + } + + try { + // 1. 创建事件标记配置 + const eventMarker = createEventMarkerFromTime(time, label, color); + setMarker(eventMarker); + + // 2. 创建 Overlay + const overlay = createEventMarkerOverlay(eventMarker, data); + + if (!overlay) { + logger.warn('useEventMarker', 'createMarker', 'Overlay 创建失败', { + eventMarker, + }); + return; + } + + // 3. 添加到图表 + const id = chart.createOverlay(overlay); + + if (!id || (Array.isArray(id) && id.length === 0)) { + logger.warn('useEventMarker', 'createMarker', '标记添加失败', { + overlay, + }); + return; + } + + const actualId = Array.isArray(id) ? id[0] : id; + setMarkerId(actualId as string); + + logger.info('useEventMarker', 'createMarker', '事件标记创建成功', { + markerId: actualId, + label, + time, + chartId: chart.id, + }); + } catch (err) { + logger.error('useEventMarker', 'createMarker', err as Error, { + time, + label, + }); + } + }, + [chart, data] + ); + + /** + * 移除事件标记 + */ + const removeMarker = useCallback(() => { + if (!chart || !markerId) { + return; + } + + try { + chart.removeOverlay(markerId); + setMarker(null); + setMarkerId(null); + + logger.debug('useEventMarker', 'removeMarker', '移除事件标记', { + markerId, + chartId: chart.id, + }); + } catch (err) { + logger.error('useEventMarker', 'removeMarker', err as Error, { + markerId, + }); + } + }, [chart, markerId]); + + /** + * 移除所有标记 + */ + const removeAllMarkers = useCallback(() => { + if (!chart) { + return; + } + + try { + removeAllEventMarkers(chart); + setMarker(null); + setMarkerId(null); + + logger.debug('useEventMarker', 'removeAllMarkers', '移除所有事件标记', { + chartId: chart.id, + }); + } catch (err) { + logger.error('useEventMarker', 'removeAllMarkers', err as Error); + } + }, [chart]); + + // 自动创建标记(当 eventTime 和数据都准备好时) + useEffect(() => { + if ( + autoCreate && + eventTime && + chart && + data && + data.length > 0 && + !markerId // 避免重复创建 + ) { + createMarker(eventTime, eventTitle); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [eventTime, chart, data, autoCreate]); + + // 清理:组件卸载时移除所有标记 + useEffect(() => { + return () => { + if (chart && markerId) { + try { + chart.removeOverlay(markerId); + } catch (err) { + // 忽略清理时的错误 + } + } + }; + }, [chart, markerId]); + + return { + marker, + markerId, + createMarker, + removeMarker, + removeAllMarkers, + }; +}; diff --git a/src/components/StockChart/hooks/useKLineChart.ts b/src/components/StockChart/hooks/useKLineChart.ts new file mode 100644 index 00000000..6be14294 --- /dev/null +++ b/src/components/StockChart/hooks/useKLineChart.ts @@ -0,0 +1,173 @@ +/** + * useKLineChart Hook + * + * 管理 KLineChart 实例的初始化、配置和销毁 + */ + +import { useEffect, useRef, useState } from 'react'; +import { init, dispose } from 'klinecharts'; +import type { Chart } from 'klinecharts'; +import { useColorMode } from '@chakra-ui/react'; +import { getTheme } from '../config/klineTheme'; +import { CHART_INIT_OPTIONS } from '../config'; +import { logger } from '@utils/logger'; + +export interface UseKLineChartOptions { + /** 图表容器 ID */ + containerId: string; + /** 图表高度(px) */ + height?: number; + /** 是否自动调整大小 */ + autoResize?: boolean; +} + +export interface UseKLineChartReturn { + /** KLineChart 实例 */ + chart: Chart | null; + /** 容器 Ref */ + chartRef: React.RefObject; + /** 是否已初始化 */ + isInitialized: boolean; + /** 初始化错误 */ + error: Error | null; +} + +/** + * KLineChart 初始化和生命周期管理 Hook + * + * @param options 配置选项 + * @returns UseKLineChartReturn + * + * @example + * const { chart, chartRef, isInitialized } = useKLineChart({ + * containerId: 'kline-chart', + * height: 400, + * autoResize: true, + * }); + */ +export const useKLineChart = ( + options: UseKLineChartOptions +): UseKLineChartReturn => { + const { containerId, height = 400, autoResize = true } = options; + + const chartRef = useRef(null); + const chartInstanceRef = useRef(null); + 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, + }); + + // 初始化图表实例(KLineChart 10.0 API) + const chartInstance = init(chartRef.current, { + ...CHART_INIT_OPTIONS, + // 设置初始样式(根据主题) + styles: getTheme(colorMode), + }); + + if (!chartInstance) { + throw new Error('图表初始化失败:返回 null'); + } + + chartInstanceRef.current = chartInstance; + setIsInitialized(true); + setError(null); + + 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); + } + + // 清理函数:销毁图表实例 + return () => { + if (chartInstanceRef.current) { + logger.debug('useKLineChart', 'dispose', '销毁图表实例', { + containerId, + chartId: chartInstanceRef.current.id, + }); + + dispose(chartInstanceRef.current); + chartInstanceRef.current = null; + setIsInitialized(false); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [containerId]); // 只在 containerId 变化时重新初始化 + + // 主题切换:更新图表样式 + useEffect(() => { + if (!chartInstanceRef.current || !isInitialized) { + return; + } + + try { + const newTheme = getTheme(colorMode); + chartInstanceRef.current.setStyles(newTheme); + + logger.debug('useKLineChart', 'updateTheme', '更新图表主题', { + colorMode, + chartId: chartInstanceRef.current.id, + }); + } catch (err) { + logger.error('useKLineChart', 'updateTheme', err as Error, { colorMode }); + } + }, [colorMode, isInitialized]); + + // 容器尺寸变化:调整图表大小 + useEffect(() => { + if (!chartInstanceRef.current || !isInitialized || !autoResize) { + return; + } + + const handleResize = () => { + if (chartInstanceRef.current) { + chartInstanceRef.current.resize(); + logger.debug('useKLineChart', 'resize', '调整图表大小'); + } + }; + + // 监听窗口大小变化 + window.addEventListener('resize', handleResize); + + // 使用 ResizeObserver 监听容器大小变化(更精确) + let resizeObserver: ResizeObserver | null = null; + if (chartRef.current && typeof ResizeObserver !== 'undefined') { + resizeObserver = new ResizeObserver(handleResize); + resizeObserver.observe(chartRef.current); + } + + return () => { + window.removeEventListener('resize', handleResize); + if (resizeObserver && chartRef.current) { + resizeObserver.unobserve(chartRef.current); + resizeObserver.disconnect(); + } + }; + }, [isInitialized, autoResize]); + + return { + chart: chartInstanceRef.current, + chartRef, + isInitialized, + error, + }; +}; diff --git a/src/components/StockChart/hooks/useKLineData.ts b/src/components/StockChart/hooks/useKLineData.ts new file mode 100644 index 00000000..e3244f02 --- /dev/null +++ b/src/components/StockChart/hooks/useKLineData.ts @@ -0,0 +1,222 @@ +/** + * useKLineData Hook + * + * 管理 K 线数据的加载、转换和更新 + */ + +import { useEffect, useState, useCallback } from 'react'; +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'; + +export interface UseKLineDataOptions { + /** KLineChart 实例 */ + chart: Chart | null; + /** 股票代码 */ + stockCode: string; + /** 图表类型 */ + chartType: ChartType; + /** 事件时间(用于调整数据加载范围) */ + eventTime?: string; + /** 是否自动加载数据 */ + autoLoad?: boolean; +} + +export interface UseKLineDataReturn { + /** 处理后的 K 线数据 */ + data: KLineDataPoint[]; + /** 原始数据 */ + rawData: RawDataPoint[]; + /** 是否加载中 */ + loading: boolean; + /** 加载错误 */ + error: Error | null; + /** 手动加载数据 */ + loadData: () => Promise; + /** 更新数据 */ + updateData: (newData: KLineDataPoint[]) => void; + /** 清空数据 */ + clearData: () => void; +} + +/** + * K 线数据加载和管理 Hook + * + * @param options 配置选项 + * @returns UseKLineDataReturn + * + * @example + * const { data, loading, error, loadData } = useKLineData({ + * chart, + * stockCode: '600000.SH', + * chartType: 'daily', + * eventTime: '2024-01-01 10:00:00', + * autoLoad: true, + * }); + */ +export const useKLineData = ( + options: UseKLineDataOptions +): UseKLineDataReturn => { + const { + chart, + stockCode, + chartType, + eventTime, + autoLoad = true, + } = options; + + const [data, setData] = useState([]); + const [rawData, setRawData] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + /** + * 加载数据(从后端 API) + */ + const loadData = useCallback(async () => { + if (!stockCode) { + logger.warn('useKLineData', 'loadData', '股票代码为空', { chartType }); + return; + } + + setLoading(true); + setError(null); + + try { + logger.debug('useKLineData', 'loadData', '开始加载数据', { + stockCode, + chartType, + eventTime, + }); + + // 调用后端 API 获取数据 + const response = await stockService.getKlineData( + stockCode, + chartType, + eventTime + ); + + if (!response || !response.data) { + throw new Error('后端返回数据为空'); + } + + const rawDataList = response.data; + setRawData(rawDataList); + + // 数据转换和处理 + const processedData = processChartData(rawDataList, chartType, eventTime); + setData(processedData); + + logger.info('useKLineData', 'loadData', '数据加载成功', { + stockCode, + chartType, + rawCount: rawDataList.length, + processedCount: processedData.length, + }); + } catch (err) { + const error = err as Error; + logger.error('useKLineData', 'loadData', error, { + stockCode, + chartType, + }); + setError(error); + setData([]); + setRawData([]); + } finally { + setLoading(false); + } + }, [stockCode, chartType, eventTime]); + + /** + * 更新图表数据(使用 DataLoader 模式) + */ + const updateChartData = useCallback( + (klineData: KLineDataPoint[]) => { + if (!chart || klineData.length === 0) { + return; + } + + try { + // KLineChart 10.0: 使用 setDataLoader 方法 + chart.setDataLoader({ + getBars: (params) => { + // 将数据传递给图表 + params.callback(klineData, { more: false }); + + logger.debug('useKLineData', 'updateChartData', 'DataLoader 回调', { + dataCount: klineData.length, + }); + }, + }); + + logger.debug('useKLineData', 'updateChartData', '图表数据已更新', { + dataCount: klineData.length, + chartId: chart.id, + }); + } catch (err) { + logger.error('useKLineData', 'updateChartData', err as Error, { + dataCount: klineData.length, + }); + } + }, + [chart] + ); + + /** + * 手动更新数据(外部调用) + */ + const updateData = useCallback( + (newData: KLineDataPoint[]) => { + setData(newData); + updateChartData(newData); + + logger.debug('useKLineData', 'updateData', '手动更新数据', { + newDataCount: newData.length, + }); + }, + [updateChartData] + ); + + /** + * 清空数据 + */ + const clearData = useCallback(() => { + setData([]); + setRawData([]); + setError(null); + + if (chart) { + chart.resetData(); + logger.debug('useKLineData', 'clearData', '清空数据', { + chartId: chart.id, + }); + } + }, [chart]); + + // 自动加载数据(当 stockCode/chartType/eventTime 变化时) + useEffect(() => { + if (autoLoad && stockCode && chart) { + loadData(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [stockCode, chartType, eventTime, autoLoad, chart]); + + // 数据变化时更新图表 + useEffect(() => { + if (data.length > 0 && chart) { + updateChartData(data); + } + }, [data, chart, updateChartData]); + + return { + data, + rawData, + loading, + error, + loadData, + updateData, + clearData, + }; +};