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