feat: StockChartModal.tsx
This commit is contained in:
689
src/components/StockChart/StockChartModal.tsx
Normal file
689
src/components/StockChart/StockChartModal.tsx
Normal file
@@ -0,0 +1,689 @@
|
|||||||
|
// src/components/StockChart/StockChartModal.tsx - 统一的股票图表组件
|
||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
ModalOverlay,
|
||||||
|
ModalContent,
|
||||||
|
ModalHeader,
|
||||||
|
ModalCloseButton,
|
||||||
|
ModalBody,
|
||||||
|
Button,
|
||||||
|
ButtonGroup,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图表类型
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 股票信息
|
||||||
|
*/
|
||||||
|
interface StockInfo {
|
||||||
|
stock_code: string;
|
||||||
|
stock_name?: string;
|
||||||
|
relation_desc?: RelationDescType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StockChartModal 组件 Props
|
||||||
|
*/
|
||||||
|
export interface StockChartModalProps {
|
||||||
|
/** 模态框是否打开 */
|
||||||
|
isOpen: boolean;
|
||||||
|
|
||||||
|
/** 关闭回调 */
|
||||||
|
onClose: () => void;
|
||||||
|
|
||||||
|
/** 股票信息 */
|
||||||
|
stock: StockInfo | null;
|
||||||
|
|
||||||
|
/** 事件时间 */
|
||||||
|
eventTime?: string | null;
|
||||||
|
|
||||||
|
/** 是否使用 Chakra UI(保留字段,当前未使用) */
|
||||||
|
isChakraUI?: boolean;
|
||||||
|
|
||||||
|
/** 模态框大小 */
|
||||||
|
size?: string;
|
||||||
|
|
||||||
|
/** 初始图表类型 */
|
||||||
|
initialChartType?: ChartType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ECharts 实例(带自定义 resizeHandler)
|
||||||
|
*/
|
||||||
|
interface EChartsInstance extends ECharts {
|
||||||
|
resizeHandler?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StockChartModal: React.FC<StockChartModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
stock,
|
||||||
|
eventTime,
|
||||||
|
isChakraUI = true,
|
||||||
|
size = '6xl',
|
||||||
|
initialChartType = 'timeline',
|
||||||
|
}) => {
|
||||||
|
const chartRef = useRef<HTMLDivElement>(null);
|
||||||
|
const chartInstanceRef = useRef<EChartsInstance | null>(null);
|
||||||
|
const [chartType, setChartType] = useState<ChartType>(initialChartType);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [chartData, setChartData] = useState<ChartData | null>(null);
|
||||||
|
const [preloadedData, setPreloadedData] = useState<Record<ChartType, ChartData | undefined>>({
|
||||||
|
timeline: undefined,
|
||||||
|
daily: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 预加载数据
|
||||||
|
const preloadData = async (type: ChartType): Promise<void> => {
|
||||||
|
if (!stock || preloadedData[type]) return;
|
||||||
|
|
||||||
|
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<void> => {
|
||||||
|
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]}<br/>现价:<span style="color: ${priceColor}">¥${prices[
|
||||||
|
idx
|
||||||
|
]?.toFixed(2)} (${priceChangePercent >= 0 ? '+' : ''}${priceChangePercent.toFixed(
|
||||||
|
2
|
||||||
|
)}%)</span><br/>均价:<span style="color: ${avgColor}">¥${avgPrices[idx]?.toFixed(
|
||||||
|
2
|
||||||
|
)} (${avgChangePercent >= 0 ? '+' : ''}${avgChangePercent.toFixed(
|
||||||
|
2
|
||||||
|
)}%)</span><br/>昨收:¥${prevClose?.toFixed(2)}<br/>成交量:${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}<br/>开盘: ¥${kline.data[0]}<br/>收盘: ¥${kline.data[1]}<br/>最低: ¥${kline.data[2]}<br/>最高: ¥${kline.data[3]}`;
|
||||||
|
|
||||||
|
if (volume && volume.data) {
|
||||||
|
tooltipHtml += `<br/>成交量: ${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 {};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!stock) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} size={size}>
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent maxW="90vw" maxH="90vh" overflow="hidden">
|
||||||
|
<ModalHeader pb={4}>
|
||||||
|
<VStack align="flex-start" spacing={2}>
|
||||||
|
<HStack>
|
||||||
|
<Text fontSize="lg" fontWeight="bold">
|
||||||
|
{stock.stock_name || stock.stock_code} ({stock.stock_code}) - 股票详情
|
||||||
|
</Text>
|
||||||
|
{chartData && <Badge colorScheme="blue">{chartData.trade_date}</Badge>}
|
||||||
|
</HStack>
|
||||||
|
<ButtonGroup size="sm">
|
||||||
|
<Button
|
||||||
|
variant={chartType === 'timeline' ? 'solid' : 'outline'}
|
||||||
|
onClick={() => setChartType('timeline')}
|
||||||
|
colorScheme="blue"
|
||||||
|
>
|
||||||
|
分时线
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={chartType === 'daily' ? 'solid' : 'outline'}
|
||||||
|
onClick={() => setChartType('daily')}
|
||||||
|
colorScheme="blue"
|
||||||
|
>
|
||||||
|
日K线
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</VStack>
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalCloseButton />
|
||||||
|
<ModalBody p={0} overflowY="auto" maxH="calc(90vh - 120px)">
|
||||||
|
{/* 图表区域 */}
|
||||||
|
<Box h="500px" w="100%" position="relative">
|
||||||
|
{loading && (
|
||||||
|
<Flex
|
||||||
|
position="absolute"
|
||||||
|
top="0"
|
||||||
|
left="0"
|
||||||
|
right="0"
|
||||||
|
bottom="0"
|
||||||
|
bg="rgba(255, 255, 255, 0.7)"
|
||||||
|
zIndex="10"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
>
|
||||||
|
<VStack spacing={4}>
|
||||||
|
<CircularProgress isIndeterminate color="blue.300" />
|
||||||
|
<Text>加载图表数据...</Text>
|
||||||
|
</VStack>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
<div ref={chartRef} style={{ height: '100%', width: '100%', minHeight: '500px' }} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 关联描述 */}
|
||||||
|
<RelationDescription relationDesc={stock?.relation_desc} />
|
||||||
|
|
||||||
|
{/* 风险提示 */}
|
||||||
|
<Box px={4} pb={4}>
|
||||||
|
<RiskDisclaimer text="" variant="default" sx={{}} />
|
||||||
|
</Box>
|
||||||
|
</ModalBody>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StockChartModal;
|
||||||
Reference in New Issue
Block a user