281 lines
7.3 KiB
TypeScript
281 lines
7.3 KiB
TypeScript
/**
|
||
* 迷你分时图组件
|
||
* 用于灵活屏中显示证券的日内走势
|
||
*/
|
||
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;
|