Files
vf_react/src/views/StockOverview/components/FlexScreen/components/MiniTimelineChart.tsx
2025-12-10 11:19:02 +08:00

281 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 迷你分时图组件
* 用于灵活屏中显示证券的日内走势
*/
import React, { useEffect, useRef, useState, useMemo } from 'react';
import { Box, Spinner, Center, Text } from '@chakra-ui/react';
import * as echarts from 'echarts';
import type { ECharts, EChartsOption } from 'echarts';
import type { MiniTimelineChartProps, TimelineDataPoint } from '../types';
/**
* 生成交易时间刻度(用于 X 轴)
* A股交易时间9:30-11:30, 13:00-15:00
*/
const generateTimeTicks = (): string[] => {
const ticks: string[] = [];
// 上午
for (let h = 9; h <= 11; h++) {
for (let m = h === 9 ? 30 : 0; m < 60; m++) {
if (h === 11 && m > 30) break;
ticks.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`);
}
}
// 下午
for (let h = 13; h <= 15; h++) {
for (let m = 0; m < 60; m++) {
if (h === 15 && m > 0) break;
ticks.push(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`);
}
}
return ticks;
};
const TIME_TICKS = generateTimeTicks();
/** API 返回的分钟数据结构 */
interface MinuteKLineItem {
time?: string;
timestamp?: string;
close?: number;
price?: number;
}
/** API 响应结构 */
interface KLineApiResponse {
success?: boolean;
data?: MinuteKLineItem[];
error?: string;
}
/**
* MiniTimelineChart 组件
*/
const MiniTimelineChart: React.FC<MiniTimelineChartProps> = ({
code,
isIndex = false,
prevClose,
currentPrice,
height = 120,
}) => {
const chartRef = useRef<HTMLDivElement>(null);
const chartInstance = useRef<ECharts | null>(null);
const [timelineData, setTimelineData] = useState<TimelineDataPoint[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 获取分钟数据
useEffect(() => {
if (!code) return;
const fetchData = async (): Promise<void> => {
setLoading(true);
setError(null);
try {
const apiPath = isIndex
? `/api/index/${code}/kline?type=minute`
: `/api/stock/${code}/kline?type=minute`;
const response = await fetch(apiPath);
const result: KLineApiResponse = await response.json();
if (result.success !== false && result.data) {
// 格式化数据
const formatted: TimelineDataPoint[] = result.data.map(item => ({
time: item.time || item.timestamp || '',
price: item.close || item.price || 0,
}));
setTimelineData(formatted);
} else {
setError(result.error || '暂无数据');
}
} catch (e) {
setError('加载失败');
} finally {
setLoading(false);
}
};
fetchData();
// 交易时间内每分钟刷新
const now = new Date();
const hours = now.getHours();
const minutes = now.getMinutes();
const currentMinutes = hours * 60 + minutes;
const isTrading =
(currentMinutes >= 570 && currentMinutes <= 690) ||
(currentMinutes >= 780 && currentMinutes <= 900);
let intervalId: NodeJS.Timeout | undefined;
if (isTrading) {
intervalId = setInterval(fetchData, 60000); // 1分钟刷新
}
return () => {
if (intervalId) clearInterval(intervalId);
};
}, [code, isIndex]);
// 合并实时价格到数据中
const chartData = useMemo((): TimelineDataPoint[] => {
if (!timelineData.length) return [];
const data = [...timelineData];
// 如果有实时价格,添加到最新点
if (currentPrice && data.length > 0) {
const now = new Date();
const timeStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
const lastItem = data[data.length - 1];
// 如果实时价格的时间比最后一条数据新,添加新点
if (lastItem.time !== timeStr) {
data.push({ time: timeStr, price: currentPrice });
} else {
// 更新最后一条
data[data.length - 1] = { ...lastItem, price: currentPrice };
}
}
return data;
}, [timelineData, currentPrice]);
// 渲染图表
useEffect(() => {
if (!chartRef.current || loading || !chartData.length) return;
if (!chartInstance.current) {
chartInstance.current = echarts.init(chartRef.current);
}
const baseLine = prevClose || chartData[0]?.price || 0;
// 计算价格范围
const prices = chartData.map(d => d.price).filter(p => p > 0);
const minPrice = Math.min(...prices, baseLine);
const maxPrice = Math.max(...prices, baseLine);
const range = Math.max(maxPrice - baseLine, baseLine - minPrice) * 1.1;
// 准备数据
const times = chartData.map(d => d.time);
const values = chartData.map(d => d.price);
// 判断涨跌
const lastPrice = values[values.length - 1] || baseLine;
const isUp = lastPrice >= baseLine;
const option: EChartsOption = {
grid: {
top: 5,
right: 5,
bottom: 5,
left: 5,
containLabel: false,
},
xAxis: {
type: 'category',
data: times,
show: false,
boundaryGap: false,
},
yAxis: {
type: 'value',
min: baseLine - range,
max: baseLine + range,
show: false,
},
series: [
{
type: 'line',
data: values,
smooth: false,
symbol: 'none',
lineStyle: {
width: 1.5,
color: isUp ? '#ef4444' : '#22c55e',
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: isUp ? 'rgba(239, 68, 68, 0.3)' : 'rgba(34, 197, 94, 0.3)' },
{ offset: 1, color: isUp ? 'rgba(239, 68, 68, 0.05)' : 'rgba(34, 197, 94, 0.05)' },
],
},
},
markLine: {
silent: true,
symbol: 'none',
data: [
{
yAxis: baseLine,
lineStyle: {
color: '#666',
type: 'dashed',
width: 1,
},
label: { show: false },
},
],
},
},
],
animation: false,
};
chartInstance.current.setOption(option);
return () => {
// 不在这里销毁,只在组件卸载时销毁
};
}, [chartData, prevClose, loading]);
// 组件卸载时销毁图表
useEffect(() => {
return () => {
if (chartInstance.current) {
chartInstance.current.dispose();
chartInstance.current = null;
}
};
}, []);
// 窗口 resize 处理
useEffect(() => {
const handleResize = (): void => {
chartInstance.current?.resize();
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
if (loading) {
return (
<Center h={height}>
<Spinner size="sm" color="gray.400" />
</Center>
);
}
if (error || !chartData.length) {
return (
<Center h={height}>
<Text fontSize="xs" color="gray.400">
{error || '暂无数据'}
</Text>
</Center>
);
}
return <Box ref={chartRef} h={`${height}px`} w="100%" />;
};
export default MiniTimelineChart;