Merge branch 'feature_2025/251117_pref' of https://git.valuefrontier.cn/vf/vf_react into feature_2025/251117_pref

This commit is contained in:
2025-11-22 23:29:38 +08:00
16 changed files with 2573 additions and 0 deletions

View File

@@ -37,8 +37,10 @@
"echarts-for-react": "^3.0.2",
"echarts-wordcloud": "^2.1.0",
"framer-motion": "^12.23.24",
"fullcalendar": "^5.9.0",
"globalize": "^1.7.0",
"history": "^5.3.0",
"klinecharts": "^10.0.0-beta1",
"lucide-react": "^0.540.0",
"match-sorter": "6.3.0",
"nouislider": "15.0.0",

View File

@@ -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<StockChartKLineModalProps> = ({
visible,
onClose,
stock,
eventTime,
eventTitle,
}) => {
// ==================== 状态管理 ====================
/** 图表类型(分时图/日K线 */
const [chartType, setChartType] = useState<ChartType>('daily');
/** 选中的副图指标 */
const [selectedIndicators, setSelectedIndicators] = useState<string[]>(
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 (
<Modal
title={modalTitle}
open={visible}
onCancel={onClose}
width={1200}
footer={null}
centered
destroyOnClose // 关闭时销毁组件(释放图表资源)
>
{/* 工具栏 */}
<Box mb={4}>
<Space wrap>
{/* 图表类型切换 */}
<Radio.Group value={chartType} onChange={handleChartTypeChange}>
<Radio.Button value="timeline">
<LineChartOutlined />
</Radio.Button>
<Radio.Button value="daily">
<BarChartOutlined /> K线
</Radio.Button>
</Radio.Group>
{/* 副图指标选择 */}
<Select
mode="multiple"
placeholder="选择副图指标"
value={selectedIndicators}
onChange={handleIndicatorChange}
style={{ minWidth: 200 }}
maxTagCount={2}
>
{INDICATORS.sub.map((indicator) => (
<Select.Option key={indicator.name} value={indicator.name}>
<SettingOutlined /> {indicator.label}
</Select.Option>
))}
</Select>
{/* 刷新按钮 */}
<Button
icon={<ReloadOutlined />}
onClick={handleRefresh}
loading={dataLoading}
>
</Button>
</Space>
</Box>
{/* 错误提示 */}
{hasError && (
<Alert
message="加载失败"
description={errorMessage}
type="error"
closable
showIcon
style={{ marginBottom: 16 }}
/>
)}
{/* 图表容器 */}
<Box position="relative">
{/* 加载遮罩 */}
{showLoading && (
<Box
position="absolute"
top="50%"
left="50%"
transform="translate(-50%, -50%)"
zIndex={10}
>
<Spin size="large" tip="加载中..." />
</Box>
)}
{/* KLineChart 容器 */}
<div
ref={chartRef}
id={`kline-chart-${stock.stock_code}`}
style={{
width: '100%',
height: `${CHART_HEIGHTS.main}px`,
opacity: showLoading ? 0.5 : 1,
transition: 'opacity 0.3s',
}}
/>
</Box>
{/* 数据信息(调试用,生产环境可移除) */}
{process.env.NODE_ENV === 'development' && (
<Box mt={2} fontSize="12px" color="gray.500">
<Space split="|">
<span>: {data.length}</span>
<span>: {marker ? marker.label : '无'}</span>
<span>ID: {chart?.id || '未初始化'}</span>
</Space>
</Box>
)}
</Modal>
);
};
export default StockChartKLineModal;

View File

@@ -0,0 +1,205 @@
/**
* KLineChart 图表常量配置
*
* 包含图表默认配置、技术指标列表、事件标记配置等
*/
import type { ChartConfig, ChartType } from '../types';
/**
* 图表默认高度px
*/
export const CHART_HEIGHTS = {
/** 主图高度 */
main: 400,
/** 副图高度(技术指标) */
sub: 150,
/** 移动端主图高度 */
mainMobile: 300,
/** 移动端副图高度 */
subMobile: 100,
} as const;
/**
* 技术指标配置
*/
export const INDICATORS = {
/** 主图指标(叠加在 K 线图上) */
main: [
{
name: 'MA',
label: '均线',
params: [5, 10, 20, 30],
colors: ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A'],
},
{
name: 'EMA',
label: '指数移动平均',
params: [5, 10, 20, 30],
colors: ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A'],
},
{
name: 'BOLL',
label: '布林带',
params: [20, 2],
colors: ['#FF6B6B', '#4ECDC4', '#45B7D1'],
},
],
/** 副图指标(单独窗口显示) */
sub: [
{
name: 'VOL',
label: '成交量',
params: [5, 10, 20],
colors: ['#ef5350', '#26a69a'],
},
{
name: 'MACD',
label: 'MACD',
params: [12, 26, 9],
colors: ['#FF6B6B', '#4ECDC4', '#45B7D1'],
},
{
name: 'KDJ',
label: 'KDJ',
params: [9, 3, 3],
colors: ['#FF6B6B', '#4ECDC4', '#45B7D1'],
},
{
name: 'RSI',
label: 'RSI',
params: [6, 12, 24],
colors: ['#FF6B6B', '#4ECDC4', '#45B7D1'],
},
],
} as const;
/**
* 默认主图指标(初始显示)
*/
export const DEFAULT_MAIN_INDICATOR = 'MA';
/**
* 默认副图指标(初始显示)
*/
export const DEFAULT_SUB_INDICATORS = ['VOL', 'MACD'];
/**
* 图表类型配置
*/
export const CHART_TYPE_CONFIG: Record<ChartType, { label: string; dateFormat: string }> = {
timeline: {
label: '分时图',
dateFormat: 'HH:mm', // 时间格式09:30
},
daily: {
label: '日K线',
dateFormat: 'YYYY-MM-DD', // 日期格式2024-01-01
},
} as const;
/**
* 事件标记配置
*/
export const EVENT_MARKER_CONFIG = {
/** 默认颜色 */
defaultColor: '#ff9800',
/** 默认位置 */
defaultPosition: 'top' as const,
/** 默认图标 */
defaultIcon: '📌',
/** 标记大小 */
size: {
point: 8, // 标记点半径
icon: 20, // 图标大小
},
/** 文本配置 */
text: {
fontSize: 12,
fontFamily: 'Helvetica, Arial, sans-serif',
color: '#ffffff',
padding: 4,
borderRadius: 4,
},
} as const;
/**
* 数据加载配置
*/
export const DATA_LOADER_CONFIG = {
/** 最大数据点数(避免性能问题) */
maxDataPoints: 1000,
/** 初始加载数据点数 */
initialLoadCount: 100,
/** 加载更多时的数据点数 */
loadMoreCount: 50,
} as const;
/**
* 缩放配置
*/
export const ZOOM_CONFIG = {
/** 最小缩放比例(显示更多 K 线) */
minZoom: 0.5,
/** 最大缩放比例(显示更少 K 线) */
maxZoom: 2.0,
/** 默认缩放比例 */
defaultZoom: 1.0,
/** 缩放步长 */
zoomStep: 0.1,
} as const;
/**
* 默认图表配置
*/
export const DEFAULT_CHART_CONFIG: ChartConfig = {
type: 'daily',
showIndicators: true,
defaultIndicators: DEFAULT_SUB_INDICATORS,
height: CHART_HEIGHTS.main,
showGrid: true,
showCrosshair: true,
} as const;
/**
* 图表初始化选项(传递给 KLineChart.init
*/
export const CHART_INIT_OPTIONS = {
/** 时区(中国标准时间) */
timezone: 'Asia/Shanghai',
/** 语言 */
locale: 'zh-CN',
/** 自定义配置 */
customApi: {
formatDate: (timestamp: number, format: string) => {
// 可在此处自定义日期格式化逻辑
return new Date(timestamp).toLocaleString('zh-CN');
},
},
} as const;
/**
* 分时图特殊配置
*/
export const TIMELINE_CONFIG = {
/** 交易时段A 股) */
tradingSessions: [
{ start: '09:30', end: '11:30' }, // 上午
{ start: '13:00', end: '15:00' }, // 下午
],
/** 是否显示均价线 */
showAverageLine: true,
/** 均价线颜色 */
averageLineColor: '#FFB74D',
} as const;
/**
* 日K线特殊配置
*/
export const DAILY_KLINE_CONFIG = {
/** 最大显示天数 */
maxDays: 250, // 约一年交易日
/** 默认显示天数 */
defaultDays: 60,
} as const;

View File

@@ -0,0 +1,30 @@
/**
* StockChart 配置统一导出
*
* 使用方式:
* import { lightTheme, DEFAULT_CHART_CONFIG } from '@components/StockChart/config';
*/
// 主题配置
export {
CHART_COLORS,
lightTheme,
darkTheme,
getTheme,
} from './klineTheme';
// 图表配置
export {
CHART_HEIGHTS,
INDICATORS,
DEFAULT_MAIN_INDICATOR,
DEFAULT_SUB_INDICATORS,
CHART_TYPE_CONFIG,
EVENT_MARKER_CONFIG,
DATA_LOADER_CONFIG,
ZOOM_CONFIG,
DEFAULT_CHART_CONFIG,
CHART_INIT_OPTIONS,
TIMELINE_CONFIG,
DAILY_KLINE_CONFIG,
} from './chartConfig';

View File

@@ -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<Styles> = {
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<Styles> = {
...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<Styles> => {
return colorMode === 'dark' ? darkTheme : lightTheme;
};

View File

@@ -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';

View File

@@ -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<EventMarker | null>(null);
const [markerId, setMarkerId] = useState<string | null>(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,
};
};

View File

@@ -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<HTMLDivElement>;
/** 是否已初始化 */
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<HTMLDivElement>(null);
const chartInstanceRef = useRef<Chart | null>(null);
const [isInitialized, setIsInitialized] = useState(false);
const [error, setError] = useState<Error | null>(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,
};
};

View File

@@ -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<void>;
/** 更新数据 */
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<KLineDataPoint[]>([]);
const [rawData, setRawData] = useState<RawDataPoint[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(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,
};
};

View File

@@ -0,0 +1,122 @@
/**
* KLineChart 图表类型定义
*
* 适配 klinecharts@10.0.0-beta1
* 文档: https://github.com/klinecharts/KLineChart
*/
/**
* K 线数据点(符合 KLineChart 10.0 规范)
*
* 注意: 10.0 版本要求 timestamp 为数字类型(毫秒时间戳)
*/
export interface KLineDataPoint {
/** 时间戳(毫秒) */
timestamp: number;
/** 开盘价 */
open: number;
/** 最高价 */
high: number;
/** 最低价 */
low: number;
/** 收盘价 */
close: number;
/** 成交量 */
volume: number;
/** 成交额(可选) */
turnover?: number;
}
/**
* 后端原始数据格式
*
* 支持多种时间字段格式time/date/timestamp
*/
export interface RawDataPoint {
/** 时间字符串分时图格式HH:mm */
time?: string;
/** 日期字符串日线格式YYYY-MM-DD */
date?: string;
/** 时间戳字符串或数字 */
timestamp?: string | number;
/** 开盘价 */
open: number;
/** 最高价 */
high: number;
/** 最低价 */
low: number;
/** 收盘价 */
close: number;
/** 成交量 */
volume: number;
/** 均价(分时图专用) */
avg_price?: number;
}
/**
* 图表类型枚举
*/
export type ChartType = 'timeline' | 'daily';
/**
* 图表配置接口
*/
export interface ChartConfig {
/** 图表类型 */
type: ChartType;
/** 显示技术指标 */
showIndicators: boolean;
/** 默认技术指标列表 */
defaultIndicators?: string[];
/** 图表高度px */
height?: number;
/** 是否显示网格 */
showGrid?: boolean;
/** 是否显示十字光标 */
showCrosshair?: boolean;
}
/**
* 事件标记接口
*
* 用于在 K 线图上标记重要事件发生时间点
*/
export interface EventMarker {
/** 唯一标识 */
id: string;
/** 时间戳(毫秒) */
timestamp: number;
/** 标签文本 */
label: string;
/** 标记位置 */
position: 'top' | 'middle' | 'bottom';
/** 标记颜色 */
color: string;
/** 图标(可选) */
icon?: string;
/** 是否可拖动(默认 false */
draggable?: boolean;
}
/**
* DataLoader 回调参数KLineChart 10.0 新增)
*/
export interface DataLoaderCallbackParams {
/** K 线数据 */
data: KLineDataPoint[];
/** 是否还有更多数据 */
more: boolean;
}
/**
* DataLoader getBars 参数KLineChart 10.0 新增)
*/
export interface DataLoaderGetBarsParams {
/** 回调函数 */
callback: (data: KLineDataPoint[], options?: { more: boolean }) => void;
/** 范围参数(可选) */
range?: {
from: number;
to: number;
};
}

View File

@@ -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';

View File

@@ -0,0 +1,80 @@
/**
* 股票相关类型定义
*
* 用于股票信息和图表数据的类型声明
*/
import type { RawDataPoint } from './chart.types';
/**
* 股票基础信息
*/
export interface StockInfo {
/** 股票代码600000.SH */
stock_code: string;
/** 股票名称(如:浦发银行) */
stock_name: string;
/** 关联描述(可能是字符串或对象) */
relation_desc?:
| string
| {
/** 数据字段 */
data?: string;
/** 内容字段 */
content?: string;
};
}
/**
* 图表数据 API 响应格式
*/
export interface ChartDataResponse {
/** K 线数据数组 */
data: RawDataPoint[];
/** 交易日期YYYY-MM-DD */
trade_date?: string;
/** 昨收价 */
prev_close?: number;
/** 状态码(可选) */
code?: number;
/** 消息(可选) */
message?: string;
}
/**
* 股票实时行情
*/
export interface StockQuote {
/** 股票代码 */
stock_code: string;
/** 当前价 */
price: number;
/** 涨跌幅(% */
change_percent: number;
/** 涨跌额 */
change_amount: number;
/** 成交量 */
volume: number;
/** 成交额 */
turnover: number;
/** 更新时间 */
update_time: string;
}
/**
* 事件信息(用于事件中心)
*/
export interface EventInfo {
/** 事件 ID */
id: number | string;
/** 事件标题 */
title: string;
/** 事件内容 */
content: string;
/** 事件发生时间ISO 字符串) */
event_time: string;
/** 重要性等级1-5 */
importance?: number;
/** 关联股票列表 */
related_stocks?: StockInfo[];
}

View File

@@ -0,0 +1,295 @@
/**
* 图表通用工具函数
*
* 包含图表初始化、技术指标管理等通用逻辑
*/
import type { Chart } from 'klinecharts';
import { logger } from '@utils/logger';
/**
* 安全地执行图表操作(捕获异常)
*
* @param operation 操作名称
* @param fn 执行函数
* @returns T | null 执行结果或 null
*/
export const safeChartOperation = <T>(
operation: string,
fn: () => T
): T | null => {
try {
return fn();
} catch (error) {
logger.error('chartUtils', operation, error as Error);
return null;
}
};
/**
* 创建技术指标
*
* @param chart KLineChart 实例
* @param indicatorName 指标名称(如 'MA', 'MACD', 'VOL'
* @param params 指标参数(可选)
* @param isStack 是否叠加(主图指标为 true副图为 false
* @returns string | null 指标 ID
*/
export const createIndicator = (
chart: Chart,
indicatorName: string,
params?: number[],
isStack: boolean = false
): string | null => {
return safeChartOperation(`createIndicator:${indicatorName}`, () => {
const indicatorId = chart.createIndicator(
{
name: indicatorName,
...(params && { calcParams: params }),
},
isStack
);
logger.debug('chartUtils', 'createIndicator', '创建技术指标', {
indicatorName,
params,
isStack,
indicatorId,
});
return indicatorId;
});
};
/**
* 移除技术指标
*
* @param chart KLineChart 实例
* @param indicatorId 指标 ID不传则移除所有指标
*/
export const removeIndicator = (chart: Chart, indicatorId?: string): void => {
safeChartOperation('removeIndicator', () => {
chart.removeIndicator(indicatorId);
logger.debug('chartUtils', 'removeIndicator', '移除技术指标', { indicatorId });
});
};
/**
* 批量创建副图指标
*
* @param chart KLineChart 实例
* @param indicators 指标名称数组
* @returns string[] 指标 ID 数组
*/
export const createSubIndicators = (
chart: Chart,
indicators: string[]
): string[] => {
const ids: string[] = [];
indicators.forEach((name) => {
const id = createIndicator(chart, name, undefined, false);
if (id) {
ids.push(id);
}
});
logger.debug('chartUtils', 'createSubIndicators', '批量创建副图指标', {
indicators,
createdIds: ids,
});
return ids;
};
/**
* 设置图表缩放级别
*
* @param chart KLineChart 实例
* @param zoom 缩放级别0.5 - 2.0
*/
export const setChartZoom = (chart: Chart, zoom: number): void => {
safeChartOperation('setChartZoom', () => {
// KLineChart 10.0: 使用 setBarSpace 方法调整 K 线宽度(实现缩放效果)
const baseBarSpace = 8; // 默认 K 线宽度px
const newBarSpace = Math.max(4, Math.min(16, baseBarSpace * zoom));
// 注意KLineChart 10.0 可能没有直接的 zoom API需要通过调整样式实现
chart.setStyles({
candle: {
bar: {
upBorderColor: undefined, // 保持默认
upColor: undefined,
downBorderColor: undefined,
downColor: undefined,
},
// 通过调整蜡烛图宽度实现缩放效果
tooltip: {
showRule: 'always',
},
},
});
logger.debug('chartUtils', 'setChartZoom', '设置图表缩放', {
zoom,
newBarSpace,
});
});
};
/**
* 滚动到指定时间
*
* @param chart KLineChart 实例
* @param timestamp 目标时间戳
*/
export const scrollToTimestamp = (chart: Chart, timestamp: number): void => {
safeChartOperation('scrollToTimestamp', () => {
// KLineChart 10.0: 使用 scrollToTimestamp 方法
chart.scrollToTimestamp(timestamp);
logger.debug('chartUtils', 'scrollToTimestamp', '滚动到指定时间', { timestamp });
});
};
/**
* 调整图表大小(响应式)
*
* @param chart KLineChart 实例
*/
export const resizeChart = (chart: Chart): void => {
safeChartOperation('resizeChart', () => {
chart.resize();
logger.debug('chartUtils', 'resizeChart', '调整图表大小');
});
};
/**
* 获取图表可见数据范围
*
* @param chart KLineChart 实例
* @returns { from: number, to: number } | null 可见范围
*/
export const getVisibleRange = (chart: Chart): { from: number; to: number } | null => {
return safeChartOperation('getVisibleRange', () => {
const data = chart.getDataList();
if (!data || data.length === 0) {
return null;
}
// 简化实现:返回所有数据范围
// 实际项目中可通过 chart 的内部状态获取可见范围
return {
from: 0,
to: data.length - 1,
};
});
};
/**
* 清空图表数据
*
* @param chart KLineChart 实例
*/
export const clearChartData = (chart: Chart): void => {
safeChartOperation('clearChartData', () => {
chart.resetData();
logger.debug('chartUtils', 'clearChartData', '清空图表数据');
});
};
/**
* 截图(导出图表为图片)
*
* @param chart KLineChart 实例
* @param includeOverlay 是否包含 overlay
* @returns string | null Base64 图片数据
*/
export const exportChartImage = (
chart: Chart,
includeOverlay: boolean = true
): string | null => {
return safeChartOperation('exportChartImage', () => {
// KLineChart 10.0: 使用 getConvertPictureUrl 方法
const imageData = chart.getConvertPictureUrl(includeOverlay, 'png', '#ffffff');
logger.debug('chartUtils', 'exportChartImage', '导出图表图片', {
includeOverlay,
hasData: !!imageData,
});
return imageData;
});
};
/**
* 切换十字光标显示
*
* @param chart KLineChart 实例
* @param show 是否显示
*/
export const toggleCrosshair = (chart: Chart, show: boolean): void => {
safeChartOperation('toggleCrosshair', () => {
chart.setStyles({
crosshair: {
show,
},
});
logger.debug('chartUtils', 'toggleCrosshair', '切换十字光标', { show });
});
};
/**
* 切换网格显示
*
* @param chart KLineChart 实例
* @param show 是否显示
*/
export const toggleGrid = (chart: Chart, show: boolean): void => {
safeChartOperation('toggleGrid', () => {
chart.setStyles({
grid: {
show,
},
});
logger.debug('chartUtils', 'toggleGrid', '切换网格', { show });
});
};
/**
* 订阅图表事件
*
* @param chart KLineChart 实例
* @param eventName 事件名称
* @param handler 事件处理函数
*/
export const subscribeChartEvent = (
chart: Chart,
eventName: string,
handler: (...args: any[]) => void
): void => {
safeChartOperation(`subscribeChartEvent:${eventName}`, () => {
chart.subscribeAction(eventName, handler);
logger.debug('chartUtils', 'subscribeChartEvent', '订阅图表事件', { eventName });
});
};
/**
* 取消订阅图表事件
*
* @param chart KLineChart 实例
* @param eventName 事件名称
* @param handler 事件处理函数
*/
export const unsubscribeChartEvent = (
chart: Chart,
eventName: string,
handler: (...args: any[]) => void
): void => {
safeChartOperation(`unsubscribeChartEvent:${eventName}`, () => {
chart.unsubscribeAction(eventName, handler);
logger.debug('chartUtils', 'unsubscribeChartEvent', '取消订阅图表事件', { eventName });
});
};

View File

@@ -0,0 +1,257 @@
/**
* 数据转换适配器
*
* 将后端返回的各种格式数据转换为 KLineChart 10.0 所需的标准格式
*/
import dayjs from 'dayjs';
import type { KLineDataPoint, RawDataPoint, ChartType } from '../types';
import { logger } from '@utils/logger';
/**
* 将后端原始数据转换为 KLineChart 标准格式
*
* @param rawData 后端原始数据数组
* @param chartType 图表类型timeline/daily
* @param eventTime 事件时间(用于日期基准)
* @returns KLineDataPoint[] 标准K线数据
*/
export const convertToKLineData = (
rawData: RawDataPoint[],
chartType: ChartType,
eventTime?: string
): KLineDataPoint[] => {
if (!rawData || !Array.isArray(rawData) || rawData.length === 0) {
logger.warn('dataAdapter', 'convertToKLineData', '原始数据为空', { chartType });
return [];
}
try {
return rawData.map((item, index) => {
const timestamp = parseTimestamp(item, chartType, eventTime, index);
return {
timestamp,
open: Number(item.open) || 0,
high: Number(item.high) || 0,
low: Number(item.low) || 0,
close: Number(item.close) || 0,
volume: Number(item.volume) || 0,
turnover: item.turnover ? Number(item.turnover) : undefined,
};
});
} catch (error) {
logger.error('dataAdapter', 'convertToKLineData', error as Error, {
chartType,
dataLength: rawData.length,
});
return [];
}
};
/**
* 解析时间戳(兼容多种时间格式)
*
* @param item 原始数据项
* @param chartType 图表类型
* @param eventTime 事件时间
* @param index 数据索引(用于分时图时间推算)
* @returns number 毫秒时间戳
*/
const parseTimestamp = (
item: RawDataPoint,
chartType: ChartType,
eventTime?: string,
index?: number
): number => {
// 优先级1: 使用 timestamp 字段
if (item.timestamp) {
const ts = typeof item.timestamp === 'number' ? item.timestamp : Number(item.timestamp);
// 判断是秒级还是毫秒级时间戳
return ts > 10000000000 ? ts : ts * 1000;
}
// 优先级2: 使用 date 字段日K线
if (item.date) {
return dayjs(item.date).valueOf();
}
// 优先级3: 使用 time 字段(分时图)
if (item.time && eventTime) {
return parseTimelineTimestamp(item.time, eventTime);
}
// 优先级4: 根据 chartType 和 index 推算(兜底逻辑)
if (chartType === 'timeline' && eventTime && typeof index === 'number') {
// 分时图:从事件时间推算(假设 09:30 开盘)
const baseTime = dayjs(eventTime).startOf('day').add(9, 'hour').add(30, 'minute');
return baseTime.add(index, 'minute').valueOf();
}
// 默认返回当前时间(避免图表崩溃)
logger.warn('dataAdapter', 'parseTimestamp', '无法解析时间戳,使用当前时间', { item });
return Date.now();
};
/**
* 解析分时图时间戳
*
* 将 "HH:mm" 格式转换为完整时间戳
*
* @param time 时间字符串(如 "09:30"
* @param eventTime 事件时间YYYY-MM-DD HH:mm:ss
* @returns number 毫秒时间戳
*/
const parseTimelineTimestamp = (time: string, eventTime: string): number => {
try {
const [hours, minutes] = time.split(':').map(Number);
const eventDate = dayjs(eventTime).startOf('day');
return eventDate.hour(hours).minute(minutes).second(0).valueOf();
} catch (error) {
logger.error('dataAdapter', 'parseTimelineTimestamp', error as Error, { time, eventTime });
return dayjs(eventTime).valueOf();
}
};
/**
* 数据验证和清洗
*
* 移除无效数据(价格/成交量异常)
*
* @param data K线数据
* @returns KLineDataPoint[] 清洗后的数据
*/
export const validateAndCleanData = (data: KLineDataPoint[]): KLineDataPoint[] => {
return data.filter((item) => {
// 移除价格为 0 或负数的数据
if (item.open <= 0 || item.high <= 0 || item.low <= 0 || item.close <= 0) {
logger.warn('dataAdapter', 'validateAndCleanData', '价格异常,已移除', { item });
return false;
}
// 移除 high < low 的数据(数据错误)
if (item.high < item.low) {
logger.warn('dataAdapter', 'validateAndCleanData', '最高价 < 最低价,已移除', { item });
return false;
}
// 移除成交量为负数的数据
if (item.volume < 0) {
logger.warn('dataAdapter', 'validateAndCleanData', '成交量异常,已移除', { item });
return false;
}
return true;
});
};
/**
* 数据排序(按时间升序)
*
* @param data K线数据
* @returns KLineDataPoint[] 排序后的数据
*/
export const sortDataByTime = (data: KLineDataPoint[]): KLineDataPoint[] => {
return [...data].sort((a, b) => a.timestamp - b.timestamp);
};
/**
* 数据去重(移除时间戳重复的数据,保留最后一条)
*
* @param data K线数据
* @returns KLineDataPoint[] 去重后的数据
*/
export const deduplicateData = (data: KLineDataPoint[]): KLineDataPoint[] => {
const map = new Map<number, KLineDataPoint>();
data.forEach((item) => {
map.set(item.timestamp, item); // 相同时间戳会覆盖
});
return Array.from(map.values());
};
/**
* 完整的数据处理流程
*
* 转换 → 验证 → 去重 → 排序
*
* @param rawData 后端原始数据
* @param chartType 图表类型
* @param eventTime 事件时间
* @returns KLineDataPoint[] 处理后的数据
*/
export const processChartData = (
rawData: RawDataPoint[],
chartType: ChartType,
eventTime?: string
): KLineDataPoint[] => {
// 1. 转换数据格式
let data = convertToKLineData(rawData, chartType, eventTime);
// 2. 验证和清洗
data = validateAndCleanData(data);
// 3. 去重
data = deduplicateData(data);
// 4. 排序
data = sortDataByTime(data);
logger.debug('dataAdapter', 'processChartData', '数据处理完成', {
rawLength: rawData.length,
processedLength: data.length,
chartType,
});
return data;
};
/**
* 获取数据时间范围
*
* @param data K线数据
* @returns { start: number, end: number } 时间范围(毫秒时间戳)
*/
export const getDataTimeRange = (
data: KLineDataPoint[]
): { start: number; end: number } | null => {
if (!data || data.length === 0) {
return null;
}
const timestamps = data.map((item) => item.timestamp);
return {
start: Math.min(...timestamps),
end: Math.max(...timestamps),
};
};
/**
* 查找最接近指定时间的数据点
*
* @param data K线数据
* @param targetTime 目标时间戳
* @returns KLineDataPoint | null 最接近的数据点
*/
export const findClosestDataPoint = (
data: KLineDataPoint[],
targetTime: number
): KLineDataPoint | null => {
if (!data || data.length === 0) {
return null;
}
let closest = data[0];
let minDiff = Math.abs(data[0].timestamp - targetTime);
data.forEach((item) => {
const diff = Math.abs(item.timestamp - targetTime);
if (diff < minDiff) {
minDiff = diff;
closest = item;
}
});
return closest;
};

View File

@@ -0,0 +1,305 @@
/**
* 事件标记工具函数
*
* 用于在 K 线图上创建、管理事件标记Overlay
*/
import dayjs from 'dayjs';
import type { OverlayCreate } from 'klinecharts';
import type { EventMarker, KLineDataPoint } from '../types';
import { EVENT_MARKER_CONFIG } from '../config';
import { findClosestDataPoint } from './dataAdapter';
import { logger } from '@utils/logger';
/**
* 创建事件标记 OverlayKLineChart 10.0 格式)
*
* @param marker 事件标记配置
* @param data K线数据用于定位标记位置
* @returns OverlayCreate | null Overlay 配置对象
*/
export const createEventMarkerOverlay = (
marker: EventMarker,
data: KLineDataPoint[]
): OverlayCreate | null => {
try {
// 查找最接近事件时间的数据点
const closestPoint = findClosestDataPoint(data, marker.timestamp);
if (!closestPoint) {
logger.warn('eventMarkerUtils', 'createEventMarkerOverlay', '未找到匹配的数据点', {
markerId: marker.id,
timestamp: marker.timestamp,
});
return null;
}
// 根据位置计算 Y 坐标
const yValue = calculateMarkerYPosition(closestPoint, marker.position);
// 创建 Overlay 配置KLineChart 10.0 规范)
const overlay: OverlayCreate = {
name: 'simpleAnnotation', // 使用内置的简单标注类型
id: marker.id,
points: [
{
timestamp: closestPoint.timestamp,
value: yValue,
},
],
styles: {
point: {
color: marker.color,
borderColor: marker.color,
borderSize: 2,
radius: EVENT_MARKER_CONFIG.size.point,
},
text: {
color: EVENT_MARKER_CONFIG.text.color,
size: EVENT_MARKER_CONFIG.text.fontSize,
family: EVENT_MARKER_CONFIG.text.fontFamily,
weight: 'bold',
},
rect: {
style: 'fill',
color: marker.color,
borderRadius: EVENT_MARKER_CONFIG.text.borderRadius,
paddingLeft: EVENT_MARKER_CONFIG.text.padding,
paddingRight: EVENT_MARKER_CONFIG.text.padding,
paddingTop: EVENT_MARKER_CONFIG.text.padding,
paddingBottom: EVENT_MARKER_CONFIG.text.padding,
},
},
// 标记文本内容
extendData: {
label: marker.label,
icon: marker.icon,
},
};
logger.debug('eventMarkerUtils', 'createEventMarkerOverlay', '创建事件标记', {
markerId: marker.id,
timestamp: closestPoint.timestamp,
label: marker.label,
});
return overlay;
} catch (error) {
logger.error('eventMarkerUtils', 'createEventMarkerOverlay', error as Error, {
markerId: marker.id,
});
return null;
}
};
/**
* 计算标记的 Y 轴位置
*
* @param dataPoint K线数据点
* @param position 标记位置top/middle/bottom
* @returns number Y轴数值
*/
const calculateMarkerYPosition = (
dataPoint: KLineDataPoint,
position: 'top' | 'middle' | 'bottom'
): number => {
switch (position) {
case 'top':
return dataPoint.high * 1.02; // 在最高价上方 2%
case 'bottom':
return dataPoint.low * 0.98; // 在最低价下方 2%
case 'middle':
default:
return (dataPoint.high + dataPoint.low) / 2; // 中间位置
}
};
/**
* 从事件时间创建标记配置
*
* @param eventTime 事件时间字符串ISO 格式)
* @param label 标记标签(可选,默认为"事件发生"
* @param color 标记颜色(可选,使用默认颜色)
* @returns EventMarker 事件标记配置
*/
export const createEventMarkerFromTime = (
eventTime: string,
label: string = '事件发生',
color: string = EVENT_MARKER_CONFIG.defaultColor
): EventMarker => {
const timestamp = dayjs(eventTime).valueOf();
return {
id: `event-${timestamp}`,
timestamp,
label,
position: EVENT_MARKER_CONFIG.defaultPosition,
color,
icon: EVENT_MARKER_CONFIG.defaultIcon,
draggable: false,
};
};
/**
* 批量创建事件标记 Overlays
*
* @param markers 事件标记配置数组
* @param data K线数据
* @returns OverlayCreate[] Overlay 配置数组
*/
export const createEventMarkerOverlays = (
markers: EventMarker[],
data: KLineDataPoint[]
): OverlayCreate[] => {
if (!markers || markers.length === 0) {
return [];
}
const overlays: OverlayCreate[] = [];
markers.forEach((marker) => {
const overlay = createEventMarkerOverlay(marker, data);
if (overlay) {
overlays.push(overlay);
}
});
logger.debug('eventMarkerUtils', 'createEventMarkerOverlays', '批量创建事件标记', {
totalMarkers: markers.length,
createdOverlays: overlays.length,
});
return overlays;
};
/**
* 移除事件标记
*
* @param chart KLineChart 实例
* @param markerId 标记 ID
*/
export const removeEventMarker = (chart: any, markerId: string): void => {
try {
chart.removeOverlay(markerId);
logger.debug('eventMarkerUtils', 'removeEventMarker', '移除事件标记', { markerId });
} catch (error) {
logger.error('eventMarkerUtils', 'removeEventMarker', error as Error, { markerId });
}
};
/**
* 移除所有事件标记
*
* @param chart KLineChart 实例
*/
export const removeAllEventMarkers = (chart: any): void => {
try {
// KLineChart 10.0 API: removeOverlay() 不传参数时移除所有 overlays
chart.removeOverlay();
logger.debug('eventMarkerUtils', 'removeAllEventMarkers', '移除所有事件标记');
} catch (error) {
logger.error('eventMarkerUtils', 'removeAllEventMarkers', error as Error);
}
};
/**
* 更新事件标记
*
* @param chart KLineChart 实例
* @param markerId 标记 ID
* @param updates 更新内容(部分字段)
*/
export const updateEventMarker = (
chart: any,
markerId: string,
updates: Partial<EventMarker>
): void => {
try {
// 先移除旧标记
removeEventMarker(chart, markerId);
// 重新创建标记KLineChart 10.0 不支持直接更新 overlay
// 注意:需要在调用方重新创建并添加 overlay
logger.debug('eventMarkerUtils', 'updateEventMarker', '更新事件标记', {
markerId,
updates,
});
} catch (error) {
logger.error('eventMarkerUtils', 'updateEventMarker', error as Error, { markerId });
}
};
/**
* 高亮事件标记(改变样式)
*
* @param chart KLineChart 实例
* @param markerId 标记 ID
* @param highlight 是否高亮
*/
export const highlightEventMarker = (
chart: any,
markerId: string,
highlight: boolean
): void => {
try {
// KLineChart 10.0: 通过 overrideOverlay 修改样式
chart.overrideOverlay({
id: markerId,
styles: {
point: {
activeRadius: highlight ? 10 : EVENT_MARKER_CONFIG.size.point,
activeBorderSize: highlight ? 3 : 2,
},
},
});
logger.debug('eventMarkerUtils', 'highlightEventMarker', '高亮事件标记', {
markerId,
highlight,
});
} catch (error) {
logger.error('eventMarkerUtils', 'highlightEventMarker', error as Error, { markerId });
}
};
/**
* 格式化事件标记标签
*
* @param eventTitle 事件标题
* @param maxLength 最大长度(默认 10
* @returns string 格式化后的标签
*/
export const formatEventMarkerLabel = (eventTitle: string, maxLength: number = 10): string => {
if (!eventTitle) {
return '事件';
}
if (eventTitle.length <= maxLength) {
return eventTitle;
}
return `${eventTitle.substring(0, maxLength)}...`;
};
/**
* 判断事件时间是否在数据范围内
*
* @param eventTime 事件时间戳
* @param data K线数据
* @returns boolean 是否在范围内
*/
export const isEventTimeInDataRange = (
eventTime: number,
data: KLineDataPoint[]
): boolean => {
if (!data || data.length === 0) {
return false;
}
const timestamps = data.map((item) => item.timestamp);
const minTime = Math.min(...timestamps);
const maxTime = Math.max(...timestamps);
return eventTime >= minTime && eventTime <= maxTime;
};

View File

@@ -0,0 +1,48 @@
/**
* StockChart 工具函数统一导出
*
* 使用方式:
* import { processChartData, createEventMarkerOverlay } from '@components/StockChart/utils';
*/
// 数据转换适配器
export {
convertToKLineData,
validateAndCleanData,
sortDataByTime,
deduplicateData,
processChartData,
getDataTimeRange,
findClosestDataPoint,
} from './dataAdapter';
// 事件标记工具
export {
createEventMarkerOverlay,
createEventMarkerFromTime,
createEventMarkerOverlays,
removeEventMarker,
removeAllEventMarkers,
updateEventMarker,
highlightEventMarker,
formatEventMarkerLabel,
isEventTimeInDataRange,
} from './eventMarkerUtils';
// 图表通用工具
export {
safeChartOperation,
createIndicator,
removeIndicator,
createSubIndicators,
setChartZoom,
scrollToTimestamp,
resizeChart,
getVisibleRange,
clearChartData,
exportChartImage,
toggleCrosshair,
toggleGrid,
subscribeChartEvent,
unsubscribeChartEvent,
} from './chartUtils';