From f361cb55f4d6b5b0c019d097f138858ccada9ef6 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Sat, 22 Nov 2025 23:14:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=8E=B0=E5=9C=A8=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E4=B8=BB=E7=BB=84=E4=BB=B6=EF=BC=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StockChart/StockChartKLineModal.tsx | 287 +++++++++++++++++ .../StockChart/config/klineTheme.ts | 298 ++++++++++++++++++ src/components/StockChart/types/index.ts | 25 ++ 3 files changed, 610 insertions(+) create mode 100644 src/components/StockChart/StockChartKLineModal.tsx create mode 100644 src/components/StockChart/config/klineTheme.ts create mode 100644 src/components/StockChart/types/index.ts diff --git a/src/components/StockChart/StockChartKLineModal.tsx b/src/components/StockChart/StockChartKLineModal.tsx new file mode 100644 index 00000000..1db9a9bb --- /dev/null +++ b/src/components/StockChart/StockChartKLineModal.tsx @@ -0,0 +1,287 @@ +/** + * StockChartKLineModal - K 线图表模态框组件 + * + * 使用 KLineChart 库实现的专业金融图表组件 + * 替换原有的 ECharts 实现(StockChartAntdModal.js) + */ + +import React, { useState, useCallback, useMemo } from 'react'; +import { Modal, Button, Radio, Select, Space, Spin, Alert } from 'antd'; +import type { RadioChangeEvent } from 'antd'; +import { + LineChartOutlined, + BarChartOutlined, + SettingOutlined, + ReloadOutlined, +} from '@ant-design/icons'; +import { Box } from '@chakra-ui/react'; + +// 自定义 Hooks +import { useKLineChart, useKLineData, useEventMarker } from './hooks'; + +// 类型定义 +import type { ChartType, StockInfo } from './types'; + +// 配置常量 +import { + CHART_TYPE_CONFIG, + CHART_HEIGHTS, + INDICATORS, + DEFAULT_SUB_INDICATORS, +} from './config'; + +// 工具函数 +import { createSubIndicators } from './utils'; + +// 日志 +import { logger } from '@utils/logger'; + +// ==================== 组件 Props ==================== + +export interface StockChartKLineModalProps { + /** 是否显示模态框 */ + visible: boolean; + /** 关闭模态框回调 */ + onClose: () => void; + /** 股票信息 */ + stock: StockInfo; + /** 事件时间(ISO 字符串,可选) */ + eventTime?: string; + /** 事件标题(用于标记标签,可选) */ + eventTitle?: string; +} + +// ==================== 主组件 ==================== + +const StockChartKLineModal: React.FC = ({ + visible, + onClose, + stock, + eventTime, + eventTitle, +}) => { + // ==================== 状态管理 ==================== + + /** 图表类型(分时图/日K线) */ + const [chartType, setChartType] = useState('daily'); + + /** 选中的副图指标 */ + const [selectedIndicators, setSelectedIndicators] = useState( + DEFAULT_SUB_INDICATORS + ); + + // ==================== 自定义 Hooks ==================== + + /** 图表实例管理 */ + const { chart, chartRef, isInitialized, error: chartError } = useKLineChart({ + containerId: `kline-chart-${stock.stock_code}`, + height: CHART_HEIGHTS.main, + autoResize: true, + }); + + /** 数据加载管理 */ + const { + data, + loading: dataLoading, + error: dataError, + loadData, + } = useKLineData({ + chart, + stockCode: stock.stock_code, + chartType, + eventTime, + autoLoad: visible, // 模态框打开时自动加载 + }); + + /** 事件标记管理 */ + const { marker } = useEventMarker({ + chart, + data, + eventTime, + eventTitle, + autoCreate: true, + }); + + // ==================== 事件处理 ==================== + + /** + * 切换图表类型(分时图 ↔ 日K线) + */ + const handleChartTypeChange = useCallback((e: RadioChangeEvent) => { + const newType = e.target.value as ChartType; + setChartType(newType); + + logger.debug('StockChartKLineModal', 'handleChartTypeChange', '切换图表类型', { + newType, + }); + }, []); + + /** + * 切换副图指标 + */ + const handleIndicatorChange = useCallback( + (values: string[]) => { + setSelectedIndicators(values); + + if (!chart) { + return; + } + + // 先移除所有副图指标(KLineChart 会自动移除) + // 然后创建新的指标 + createSubIndicators(chart, values); + + logger.debug('StockChartKLineModal', 'handleIndicatorChange', '切换副图指标', { + indicators: values, + }); + }, + [chart] + ); + + /** + * 刷新数据 + */ + const handleRefresh = useCallback(() => { + loadData(); + logger.debug('StockChartKLineModal', 'handleRefresh', '刷新数据'); + }, [loadData]); + + // ==================== 计算属性 ==================== + + /** 是否有错误 */ + const hasError = useMemo(() => { + return !!chartError || !!dataError; + }, [chartError, dataError]); + + /** 错误消息 */ + const errorMessage = useMemo(() => { + if (chartError) { + return `图表初始化失败: ${chartError.message}`; + } + if (dataError) { + return `数据加载失败: ${dataError.message}`; + } + return null; + }, [chartError, dataError]); + + /** 模态框标题 */ + const modalTitle = useMemo(() => { + return `${stock.stock_name}(${stock.stock_code}) - ${CHART_TYPE_CONFIG[chartType].label}`; + }, [stock, chartType]); + + /** 是否显示加载状态 */ + const showLoading = useMemo(() => { + return dataLoading || !isInitialized; + }, [dataLoading, isInitialized]); + + // ==================== 副作用 ==================== + + // 无副作用,都在 Hooks 中管理 + + // ==================== 渲染 ==================== + + return ( + + {/* 工具栏 */} + + + {/* 图表类型切换 */} + + + 分时图 + + + 日K线 + + + + {/* 副图指标选择 */} + + + {/* 刷新按钮 */} + + + + + {/* 错误提示 */} + {hasError && ( + + )} + + {/* 图表容器 */} + + {/* 加载遮罩 */} + {showLoading && ( + + + + )} + + {/* KLineChart 容器 */} +
+ + + {/* 数据信息(调试用,生产环境可移除) */} + {process.env.NODE_ENV === 'development' && ( + + + 数据点数: {data.length} + 事件标记: {marker ? marker.label : '无'} + 图表ID: {chart?.id || '未初始化'} + + + )} + + ); +}; + +export default StockChartKLineModal; diff --git a/src/components/StockChart/config/klineTheme.ts b/src/components/StockChart/config/klineTheme.ts new file mode 100644 index 00000000..05c70912 --- /dev/null +++ b/src/components/StockChart/config/klineTheme.ts @@ -0,0 +1,298 @@ +/** + * KLineChart 主题配置 + * + * 适配 klinecharts@10.0.0-beta1 + * 参考: https://github.com/klinecharts/KLineChart/blob/main/docs/en-US/guide/styles.md + */ + +import type { DeepPartial, Styles } from 'klinecharts'; + +/** + * 图表主题颜色配置 + */ +export const CHART_COLORS = { + // 涨跌颜色(中国市场习惯:红涨绿跌) + up: '#ef5350', // 上涨红色 + down: '#26a69a', // 下跌绿色 + neutral: '#888888', // 平盘灰色 + + // 主题色(继承自 Argon Dashboard) + primary: '#1b3bbb', // Navy 500 + secondary: '#728fea', // Navy 300 + background: '#ffffff', + backgroundDark: '#1B254B', + + // 文本颜色 + text: '#333333', + textSecondary: '#888888', + textDark: '#ffffff', + + // 网格颜色 + grid: '#e0e0e0', + gridDark: '#2d3748', + + // 边框颜色 + border: '#e0e0e0', + borderDark: '#2d3748', + + // 事件标记颜色 + eventMarker: '#ff9800', + eventMarkerText: '#ffffff', +}; + +/** + * 浅色主题配置(默认) + */ +export const lightTheme: DeepPartial = { + candle: { + type: 'candle_solid', // 实心蜡烛图 + bar: { + upColor: CHART_COLORS.up, + downColor: CHART_COLORS.down, + noChangeColor: CHART_COLORS.neutral, + }, + priceMark: { + show: true, + high: { + color: CHART_COLORS.up, + }, + low: { + color: CHART_COLORS.down, + }, + }, + tooltip: { + showRule: 'always', + showType: 'standard', + labels: ['时间: ', '开: ', '收: ', '高: ', '低: ', '成交量: '], + text: { + size: 12, + family: 'Helvetica, Arial, sans-serif', + weight: 'normal', + color: CHART_COLORS.text, + }, + }, + }, + indicator: { + tooltip: { + showRule: 'always', + showType: 'standard', + text: { + size: 12, + family: 'Helvetica, Arial, sans-serif', + weight: 'normal', + color: CHART_COLORS.text, + }, + }, + }, + xAxis: { + axisLine: { + show: true, + color: CHART_COLORS.border, + }, + tickLine: { + show: true, + length: 3, + color: CHART_COLORS.border, + }, + tickText: { + show: true, + color: CHART_COLORS.textSecondary, + family: 'Helvetica, Arial, sans-serif', + weight: 'normal', + size: 12, + }, + }, + yAxis: { + axisLine: { + show: true, + color: CHART_COLORS.border, + }, + tickLine: { + show: true, + length: 3, + color: CHART_COLORS.border, + }, + tickText: { + show: true, + color: CHART_COLORS.textSecondary, + family: 'Helvetica, Arial, sans-serif', + weight: 'normal', + size: 12, + }, + type: 'normal', // 'normal' | 'percentage' | 'log' + }, + grid: { + show: true, + horizontal: { + show: true, + size: 1, + color: CHART_COLORS.grid, + style: 'dashed', + }, + vertical: { + show: false, // 垂直网格线通常关闭,避免过于密集 + }, + }, + separator: { + size: 1, + color: CHART_COLORS.border, + }, + crosshair: { + show: true, + horizontal: { + show: true, + line: { + show: true, + style: 'dashed', + dashValue: [4, 2], + size: 1, + color: CHART_COLORS.primary, + }, + text: { + show: true, + color: CHART_COLORS.textDark, + size: 12, + family: 'Helvetica, Arial, sans-serif', + weight: 'normal', + backgroundColor: CHART_COLORS.primary, + }, + }, + vertical: { + show: true, + line: { + show: true, + style: 'dashed', + dashValue: [4, 2], + size: 1, + color: CHART_COLORS.primary, + }, + text: { + show: true, + color: CHART_COLORS.textDark, + size: 12, + family: 'Helvetica, Arial, sans-serif', + weight: 'normal', + backgroundColor: CHART_COLORS.primary, + }, + }, + }, + overlay: { + // 事件标记覆盖层样式 + point: { + color: CHART_COLORS.eventMarker, + borderColor: CHART_COLORS.eventMarker, + borderSize: 1, + radius: 5, + activeColor: CHART_COLORS.eventMarker, + activeBorderColor: CHART_COLORS.eventMarker, + activeBorderSize: 2, + activeRadius: 6, + }, + line: { + style: 'solid', + smooth: false, + color: CHART_COLORS.eventMarker, + size: 1, + dashedValue: [2, 2], + }, + text: { + style: 'fill', + color: CHART_COLORS.eventMarkerText, + size: 12, + family: 'Helvetica, Arial, sans-serif', + weight: 'normal', + offset: [0, 0], + }, + rect: { + style: 'fill', + color: CHART_COLORS.eventMarker, + borderColor: CHART_COLORS.eventMarker, + borderSize: 1, + borderRadius: 4, + borderStyle: 'solid', + borderDashedValue: [2, 2], + }, + }, +}; + +/** + * 深色主题配置 + */ +export const darkTheme: DeepPartial = { + ...lightTheme, + candle: { + ...lightTheme.candle, + tooltip: { + ...lightTheme.candle?.tooltip, + text: { + ...lightTheme.candle?.tooltip?.text, + color: CHART_COLORS.textDark, + }, + }, + }, + indicator: { + ...lightTheme.indicator, + tooltip: { + ...lightTheme.indicator?.tooltip, + text: { + ...lightTheme.indicator?.tooltip?.text, + color: CHART_COLORS.textDark, + }, + }, + }, + xAxis: { + ...lightTheme.xAxis, + axisLine: { + show: true, + color: CHART_COLORS.borderDark, + }, + tickLine: { + show: true, + length: 3, + color: CHART_COLORS.borderDark, + }, + tickText: { + ...lightTheme.xAxis?.tickText, + color: CHART_COLORS.textDark, + }, + }, + yAxis: { + ...lightTheme.yAxis, + axisLine: { + show: true, + color: CHART_COLORS.borderDark, + }, + tickLine: { + show: true, + length: 3, + color: CHART_COLORS.borderDark, + }, + tickText: { + ...lightTheme.yAxis?.tickText, + color: CHART_COLORS.textDark, + }, + }, + grid: { + show: true, + horizontal: { + show: true, + size: 1, + color: CHART_COLORS.gridDark, + style: 'dashed', + }, + vertical: { + show: false, + }, + }, + separator: { + size: 1, + color: CHART_COLORS.borderDark, + }, +}; + +/** + * 获取主题配置(根据 Chakra UI colorMode) + */ +export const getTheme = (colorMode: 'light' | 'dark' = 'light'): DeepPartial => { + return colorMode === 'dark' ? darkTheme : lightTheme; +}; diff --git a/src/components/StockChart/types/index.ts b/src/components/StockChart/types/index.ts new file mode 100644 index 00000000..204a38d6 --- /dev/null +++ b/src/components/StockChart/types/index.ts @@ -0,0 +1,25 @@ +/** + * StockChart 类型定义统一导出 + * + * 使用方式: + * import type { KLineDataPoint, StockInfo } from '@components/StockChart/types'; + */ + +// 图表相关类型 +export type { + KLineDataPoint, + RawDataPoint, + ChartType, + ChartConfig, + EventMarker, + DataLoaderCallbackParams, + DataLoaderGetBarsParams, +} from './chart.types'; + +// 股票相关类型 +export type { + StockInfo, + ChartDataResponse, + StockQuote, + EventInfo, +} from './stock.types';