Files
vf_react/src/views/EventDetail/components/HistoricalEvents.js
2025-11-02 16:41:21 +08:00

585 lines
26 KiB
JavaScript
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.

// src/views/EventDetail/components/HistoricalEvents.js
import React, { useState, useEffect } from 'react';
import {
Box,
VStack,
HStack,
Text,
Badge,
Button,
Collapse,
Skeleton,
Alert,
AlertIcon,
Card,
CardBody,
CardHeader,
Divider,
Icon,
useColorModeValue,
Tooltip,
Spinner,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
Link
} from '@chakra-ui/react';
import {
FaExclamationTriangle,
FaClock,
FaCalendarAlt,
FaChartLine,
FaEye,
FaTimes,
FaInfoCircle,
FaChevronDown,
FaChevronUp
} from 'react-icons/fa';
import { stockService } from '../../../services/eventService';
import { logger } from '../../../utils/logger';
const HistoricalEvents = ({
events = [],
expectationScore = null,
loading = false,
error = null
}) => {
// 所有 useState/useEffect/useContext/useRef/useCallback/useMemo 必须在组件顶层、顺序一致
// 不要在 if/循环/回调中调用 Hook
const [expandedEvents, setExpandedEvents] = useState(new Set());
const [expandedStocks, setExpandedStocks] = useState(new Set()); // 追踪哪些事件的股票列表被展开
const [eventStocks, setEventStocks] = useState({});
const [loadingStocks, setLoadingStocks] = useState({});
// 颜色主题
const timelineBg = useColorModeValue('#D4AF37', '#B8860B');
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const textSecondary = useColorModeValue('gray.600', 'gray.400');
// 切换事件展开状态
const toggleEventExpansion = (eventId) => {
const newExpanded = new Set(expandedEvents);
if (newExpanded.has(eventId)) {
newExpanded.delete(eventId);
} else {
newExpanded.add(eventId);
}
setExpandedEvents(newExpanded);
};
// 切换股票列表展开状态
const toggleStocksExpansion = async (event) => {
const eventId = event.id;
const newExpanded = new Set(expandedStocks);
// 如果正在收起,直接更新状态
if (newExpanded.has(eventId)) {
newExpanded.delete(eventId);
setExpandedStocks(newExpanded);
return;
}
// 如果正在展开,先展开再加载数据
newExpanded.add(eventId);
setExpandedStocks(newExpanded);
// 如果已经加载过该事件的股票数据,不再重复加载
if (eventStocks[eventId]) {
return;
}
// 标记为加载中
setLoadingStocks(prev => ({ ...prev, [eventId]: true }));
try {
// 调用API获取历史事件相关股票
const response = await stockService.getHistoricalEventStocks(eventId);
setEventStocks(prev => ({
...prev,
[eventId]: response.data || []
}));
} catch (err) {
logger.error('HistoricalEvents', 'toggleStocksExpansion', err, {
eventId: eventId,
eventTitle: event.title
});
setEventStocks(prev => ({
...prev,
[eventId]: []
}));
} finally {
setLoadingStocks(prev => ({ ...prev, [eventId]: false }));
}
};
// 获取重要性图标
const getImportanceIcon = (importance) => {
if (importance >= 4) return FaExclamationTriangle;
if (importance >= 2) return FaCalendarAlt;
return FaClock;
};
// 获取重要性颜色
const getImportanceColor = (importance) => {
if (importance >= 4) return 'red';
if (importance >= 2) return 'orange';
return 'green';
};
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return '日期未知';
return new Date(dateString).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
};
// 计算相对时间
const getRelativeTime = (dateString) => {
if (!dateString) return '';
const date = new Date(dateString);
const now = new Date();
const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
if (diffDays < 30) return `${diffDays}天前`;
if (diffDays < 365) return `${Math.floor(diffDays / 30)}个月前`;
return `${Math.floor(diffDays / 365)}年前`;
};
// 处理关联描述字段的辅助函数
const getRelationDesc = (relationDesc) => {
// 处理空值
if (!relationDesc) return '';
// 如果是字符串,直接返回
if (typeof relationDesc === 'string') {
return relationDesc;
}
// 如果是对象且包含data数组
if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
const firstItem = relationDesc.data[0];
if (firstItem) {
// 优先使用 query_part,其次使用 sentences
return firstItem.query_part || firstItem.sentences || '';
}
}
// 其他情况返回空字符串
return '';
};
// 可展开的文本组件
const ExpandableText = ({ text, maxLength = 20 }) => {
const { isOpen, onToggle } = useDisclosure();
const [shouldTruncate, setShouldTruncate] = useState(false);
useEffect(() => {
if (text && text.length > maxLength) {
setShouldTruncate(true);
} else {
setShouldTruncate(false);
}
}, [text, maxLength]);
if (!text) return <Text fontSize="xs">--</Text>;
const displayText = shouldTruncate && !isOpen
? text.substring(0, maxLength) + '...'
: text;
return (
<VStack align="flex-start" spacing={1}>
<Text fontSize="xs" noOfLines={isOpen ? undefined : 2} maxW="300px">
{displayText}{text.includes('AI合成') ? '' : 'AI合成'}
</Text>
{shouldTruncate && (
<Button
size="xs"
variant="link"
color="blue.500"
onClick={onToggle}
height="auto"
py={0}
minH={0}
>
{isOpen ? '收起' : '展开'}
</Button>
)}
</VStack>
);
};
// 加载状态
if (loading) {
return (
<VStack spacing={4} align="stretch">
{[1, 2, 3].map((i) => (
<Card key={i} borderLeft="4px solid" borderLeftColor="gray.200">
<CardBody>
<HStack spacing={4} align="flex-start">
<Skeleton boxSize="40px" borderRadius="full" />
<VStack align="flex-start" spacing={2} flex="1">
<Skeleton height="20px" width="70%" />
<Skeleton height="16px" width="40%" />
<Skeleton height="14px" width="90%" />
</VStack>
</HStack>
</CardBody>
</Card>
))}
</VStack>
);
}
// 错误状态
if (error) {
return (
<Alert status="error" borderRadius="lg">
<AlertIcon />
加载历史事件失败: {error}
</Alert>
);
}
// 无数据状态
if (!events || events.length === 0) {
return (
<Box
textAlign="center"
py={12}
color="gray.500"
bg={useColorModeValue('gray.50', 'gray.700')}
borderRadius="lg"
>
<Text fontSize="lg" mb={2}>暂无历史事件</Text>
<Text fontSize="sm">历史事件数据将在这里显示</Text>
</Box>
);
}
return (
<>
<VStack spacing={4} align="stretch">
{/* 超预期得分显示 */}
{expectationScore && (
<Card bg={useColorModeValue('yellow.50', 'yellow.900')} borderColor="yellow.200">
<CardBody>
<HStack spacing={3}>
<Icon as={FaChartLine} color="yellow.600" boxSize="20px" />
<VStack align="flex-start" spacing={1}>
<Text fontSize="sm" fontWeight="bold" color="yellow.800">
超预期得分: {expectationScore}
</Text>
<Text fontSize="xs" color="yellow.700">
基于历史事件判断当前事件的超预期情况满分100分AI合成
</Text>
</VStack>
</HStack>
</CardBody>
</Card>
)}
{/* 历史事件时间轴 */}
<Box position="relative">
{/* 时间轴线 */}
<Box
position="absolute"
left="20px"
top="20px"
bottom="20px"
width="2px"
background={`linear-gradient(to bottom, ${timelineBg}, #996515)`}
zIndex={0}
/>
{/* 事件列表 */}
<VStack spacing={6} align="stretch">
{events.map((event, index) => {
const ImportanceIcon = getImportanceIcon(event.importance);
const importanceColor = getImportanceColor(event.importance);
const isExpanded = expandedEvents.has(event.id);
return (
<Box key={event.id} position="relative">
{/* 时间轴节点 */}
<Box
position="absolute"
left="0"
top="20px"
width="40px"
height="40px"
borderRadius="full"
bg={cardBg}
border="2px solid"
borderColor={timelineBg}
display="flex"
alignItems="center"
justifyContent="center"
zIndex={1}
>
<Icon
as={ImportanceIcon}
color={`${importanceColor}.500`}
boxSize="16px"
/>
</Box>
{/* 事件内容卡片 */}
<Box ml="60px">
<Card
borderLeft="3px solid"
borderLeftColor={timelineBg}
bg={cardBg}
_hover={{ shadow: 'md' }}
transition="all 0.2s"
>
<CardBody>
<VStack align="flex-start" spacing={3}>
{/* 事件标题和操作 */}
<HStack justify="space-between" align="flex-start" w="100%">
<VStack align="flex-start" spacing={1} flex="1">
<Button
variant="link"
color={useColorModeValue('blue.600', 'blue.400')}
fontWeight="bold"
fontSize="md"
p={0}
h="auto"
onClick={() => toggleEventExpansion(event.id)}
_hover={{ textDecoration: 'underline' }}
>
{event.title || '未命名事件'}
</Button>
<HStack spacing={3} fontSize="sm" color={textSecondary}>
<Text>{formatDate(event.event_date)}</Text>
<Text>({getRelativeTime(event.event_date)})</Text>
{event.relevance && (
<Badge colorScheme="blue" size="sm">
相关度: {event.relevance}
</Badge>
)}
</HStack>
</VStack>
<HStack spacing={2}>
{event.importance && (
<Tooltip label={`重要性等级: ${event.importance}/5`}>
<Badge colorScheme={importanceColor} size="sm">
重要性: {event.importance}
</Badge>
</Tooltip>
)}
<Button
size="sm"
leftIcon={<Icon as={FaChartLine} />}
rightIcon={<Icon as={expandedStocks.has(event.id) ? FaChevronUp : FaChevronDown} />}
onClick={() => toggleStocksExpansion(event)}
colorScheme="blue"
variant="outline"
>
相关股票
</Button>
</HStack>
</HStack>
{/* 事件简介 */}
<Text fontSize="sm" color={textSecondary} lineHeight="1.5">
{event.content ? `${event.content}AI合成` : '暂无内容'}
</Text>
{/* 展开的详细信息 */}
<Collapse in={isExpanded} animateOpacity>
<Box pt={3} borderTop="1px solid" borderTopColor={borderColor}>
<VStack align="flex-start" spacing={2}>
<Text fontSize="xs" color={textSecondary}>
事件ID: {event.id}
</Text>
{event.source && (
<Text fontSize="xs" color={textSecondary}>
来源: {event.source}
</Text>
)}
{event.tags && event.tags.length > 0 && (
<HStack spacing={1} flexWrap="wrap">
<Text fontSize="xs" color={textSecondary}>标签:</Text>
{event.tags.map((tag, idx) => (
<Badge key={idx} size="sm" variant="outline">
{tag}
</Badge>
))}
</HStack>
)}
</VStack>
</Box>
</Collapse>
{/* 相关股票列表 Collapse */}
<Collapse in={expandedStocks.has(event.id)} animateOpacity>
<Box
mt={3}
pt={3}
borderTop="1px solid"
borderTopColor={borderColor}
bg={useColorModeValue('gray.50', 'gray.750')}
p={3}
borderRadius="md"
>
{loadingStocks[event.id] ? (
<VStack spacing={4} py={8}>
<Spinner size="lg" color="blue.500" />
<Text color={textSecondary}>加载相关股票数据...</Text>
</VStack>
) : (
<StocksList
stocks={eventStocks[event.id] || []}
eventTradingDate={event.event_date}
/>
)}
</Box>
</Collapse>
</VStack>
</CardBody>
</Card>
</Box>
</Box>
);
})}
</VStack>
</Box>
</VStack>
</>
);
};
// 股票列表子组件
const StocksList = ({ stocks, eventTradingDate }) => {
const textSecondary = useColorModeValue('gray.600', 'gray.400');
// 处理股票代码,移除.SZ/.SH后缀
const formatStockCode = (stockCode) => {
if (!stockCode) return '';
return stockCode.replace(/\.(SZ|SH)$/i, '');
};
// 处理关联描述字段的辅助函数
const getRelationDesc = (relationDesc) => {
// 处理空值
if (!relationDesc) return '';
// 如果是字符串,直接返回
if (typeof relationDesc === 'string') {
return relationDesc;
}
// 如果是对象且包含data数组
if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
const firstItem = relationDesc.data[0];
if (firstItem) {
// 优先使用 query_part,其次使用 sentences
return firstItem.query_part || firstItem.sentences || '';
}
}
// 其他情况返回空字符串
return '';
};
if (!stocks || stocks.length === 0) {
return (
<Box textAlign="center" py={8} color={textSecondary}>
<Icon as={FaInfoCircle} boxSize="48px" mb={4} />
<Text fontSize="lg" mb={2}>暂无相关股票数据</Text>
<Text fontSize="sm">该历史事件暂未关联股票信息</Text>
</Box>
);
}
return (
<>
{eventTradingDate && (
<Box mb={4} p={3} bg={useColorModeValue('blue.50', 'blue.900')} borderRadius="md">
<Text fontSize="sm" color={useColorModeValue('blue.700', 'blue.300')}>
📅 事件对应交易日{new Date(eventTradingDate).toLocaleDateString('zh-CN')}
</Text>
</Box>
)}
<TableContainer>
<Table size="md">
<Thead>
<Tr>
<Th>股票代码</Th>
<Th>股票名称</Th>
<Th>板块</Th>
<Th isNumeric>相关度</Th>
<Th isNumeric>事件日涨幅</Th>
<Th>关联原因</Th>
</Tr>
</Thead>
<Tbody>
{stocks.map((stock, index) => (
<Tr key={stock.id || index}>
<Td fontFamily="mono" fontWeight="medium">
<Link
href={`https://valuefrontier.cn/company?scode=${stock.stock_code ? stock.stock_code.replace(/\.(SZ|SH)$/i, '') : ''}`}
isExternal
color="blue.500"
_hover={{ textDecoration: 'underline' }}
>
{stock.stock_code ? stock.stock_code.replace(/\.(SZ|SH)$/i, '') : ''}
</Link>
</Td>
<Td>{stock.stock_name || '--'}</Td>
<Td>
<Badge size="sm" variant="outline">
{stock.sector || '未知'}
</Badge>
</Td>
<Td isNumeric>
<Badge
colorScheme={
stock.correlation >= 0.8 ? 'red' :
stock.correlation >= 0.6 ? 'orange' : 'green'
}
size="sm"
>
{Math.round((stock.correlation || 0) * 100)}%
</Badge>
</Td>
<Td isNumeric>
{stock.event_day_change_pct !== null && stock.event_day_change_pct !== undefined ? (
<Text
fontWeight="medium"
color={stock.event_day_change_pct >= 0 ? 'red.500' : 'green.500'}
>
{stock.event_day_change_pct >= 0 ? '+' : ''}{stock.event_day_change_pct.toFixed(2)}%
</Text>
) : (
<Text color={textSecondary} fontSize="sm">--</Text>
)}
</Td>
<Td>
<VStack align="flex-start" spacing={1}>
<Text fontSize="xs" noOfLines={2} maxW="300px">
{getRelationDesc(stock.relation_desc) ? `${getRelationDesc(stock.relation_desc)}AI合成` : '--'}
</Text>
</VStack>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</>
);
};
export default HistoricalEvents;