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-23 14:42:05 +08:00
4 changed files with 818 additions and 36 deletions

View File

@@ -7,6 +7,7 @@ import dayjs from 'dayjs';
import { stockService } from '../../services/eventService';
import { logger } from '../../utils/logger';
import RiskDisclaimer from '../RiskDisclaimer';
import { RelationDescription } from '../StockRelation';
const StockChartModal = ({
isOpen,
@@ -24,27 +25,6 @@ const StockChartModal = ({
const [chartData, setChartData] = useState(null);
const [preloadedData, setPreloadedData] = useState({});
// 处理关联描述(兼容对象和字符串格式)- 使用 useMemo 缓存计算结果
const relationDesc = useMemo(() => {
const desc = stock?.relation_desc;
if (!desc) return null;
if (typeof desc === 'string') {
return desc;
}
if (typeof desc === 'object' && desc.data && Array.isArray(desc.data)) {
// 新格式:{data: [{query_part: "...", sentences: "..."}]}
return desc.data
.map(item => item.query_part || item.sentences || '')
.filter(s => s)
.join('') || null;
}
return null;
}, [stock?.relation_desc]);
// 预加载数据
const preloadData = async (type) => {
if (!stock || preloadedData[type]) return;
@@ -563,21 +543,7 @@ const StockChartModal = ({
</Box>
{/* 关联描述 */}
{relationDesc && (
<Box p={4} borderTop="1px solid" borderTopColor="gray.200">
<Text fontSize="sm" fontWeight="bold" mb={2} color="gray.700">
关联描述:
</Text>
<Text
fontSize="sm"
color="gray.600"
lineHeight="1.7"
whiteSpace="pre-wrap"
>
{relationDesc}
</Text>
</Box>
)}
<RelationDescription relationDesc={stock?.relation_desc} />
{/* 风险提示 */}
<Box px={4} pb={4}>

View 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;

View File

@@ -0,0 +1,121 @@
/**
* 关联描述组件
*
* 用于显示股票与事件的关联描述信息
* 固定标题为"关联描述:"
* 自动处理多种数据格式(字符串、对象数组)
*
* @example
* ```tsx
* // 基础使用 - 传入原始 relation_desc 数据
* <RelationDescription relationDesc={stock.relation_desc} />
*
* // 自定义样式
* <RelationDescription
* relationDesc={stock.relation_desc}
* fontSize="md"
* titleColor="blue.700"
* />
* ```
*/
import React, { useMemo } from 'react';
import { Box, Text, BoxProps } from '@chakra-ui/react';
/**
* 关联描述数据类型
* - 字符串格式:直接的描述文本
* - 对象格式:包含多个句子的数组
*/
export type RelationDescType =
| string
| {
data: Array<{
query_part?: string;
sentences?: string;
}>;
}
| null
| undefined;
export interface RelationDescriptionProps {
/** 原始关联描述数据(支持字符串或对象格式) */
relationDesc: RelationDescType;
/** 字体大小,默认 'sm' */
fontSize?: string;
/** 标题颜色,默认 'gray.700' */
titleColor?: string;
/** 文本颜色,默认 'gray.600' */
textColor?: string;
/** 行高,默认 '1.7' */
lineHeight?: string;
/** 容器额外属性 */
containerProps?: BoxProps;
}
export const RelationDescription: React.FC<RelationDescriptionProps> = ({
relationDesc,
fontSize = 'sm',
titleColor = 'gray.700',
textColor = 'gray.600',
lineHeight = '1.7',
containerProps = {}
}) => {
// 处理关联描述(兼容对象和字符串格式)
const processedDesc = useMemo(() => {
if (!relationDesc) return null;
// 字符串格式:直接返回
if (typeof relationDesc === 'string') {
return relationDesc;
}
// 对象格式:提取并拼接文本
if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
return (
relationDesc.data
.map((item) => item.query_part || item.sentences || '')
.filter((s) => s)
.join('') || null
);
}
return null;
}, [relationDesc]);
// 如果没有有效的描述内容,不渲染组件
if (!processedDesc) {
return null;
}
return (
<Box
p={4}
borderTop="1px solid"
borderTopColor="gray.200"
{...containerProps}
>
<Text
fontSize={fontSize}
fontWeight="bold"
mb={2}
color={titleColor}
>
:
</Text>
<Text
fontSize={fontSize}
color={textColor}
lineHeight={lineHeight}
whiteSpace="pre-wrap"
>
{processedDesc}
</Text>
</Box>
);
};

View File

@@ -0,0 +1,6 @@
/**
* StockRelation 组件导出入口
*/
export { RelationDescription } from './RelationDescription';
export type { RelationDescriptionProps, RelationDescType } from './RelationDescription';