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