Files
vf_react/src/views/EventDetail/components/HistoricalEvents.js
2025-11-07 20:05:14 +08:00

604 lines
25 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 { useNavigate } from 'react-router-dom';
import {
Box,
VStack,
HStack,
Text,
Badge,
Button,
Skeleton,
Alert,
AlertIcon,
SimpleGrid,
Icon,
useColorModeValue,
Spinner,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
Link,
Flex,
Collapse
} from '@chakra-ui/react';
import {
FaChartLine,
FaInfoCircle
} from 'react-icons/fa';
import { stockService } from '../../../services/eventService';
import { logger } from '../../../utils/logger';
import CitedContent from '../../../components/Citation/CitedContent';
const HistoricalEvents = ({
events = [],
expectationScore = null,
loading = false,
error = null
}) => {
const navigate = useNavigate();
// 状态管理
const [selectedEventForStocks, setSelectedEventForStocks] = useState(null);
const [stocksModalOpen, setStocksModalOpen] = useState(false);
const [eventStocks, setEventStocks] = useState({});
const [loadingStocks, setLoadingStocks] = useState({});
// 颜色主题
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const textSecondary = useColorModeValue('gray.600', 'gray.400');
const nameColor = useColorModeValue('gray.700', 'gray.300');
// 字段兼容函数
const getEventDate = (event) => {
return event?.event_date || event?.created_at || event?.date || event?.publish_time;
};
const getEventContent = (event) => {
return event?.content || event?.description || event?.summary;
};
// Debug: 打印实际数据结构
useEffect(() => {
if (events && events.length > 0) {
console.log('===== Historical Events Debug =====');
console.log('First Event Data:', events[0]);
console.log('Available Fields:', Object.keys(events[0]));
console.log('Date Field:', getEventDate(events[0]));
console.log('Content Field:', getEventContent(events[0]));
console.log('==================================');
}
}, [events]);
// 点击相关股票按钮
const handleViewStocks = async (event) => {
setSelectedEventForStocks(event);
setStocksModalOpen(true);
// 如果已经加载过该事件的股票数据,不再重复加载
if (eventStocks[event.id]) {
return;
}
// 标记为加载中
setLoadingStocks(prev => ({ ...prev, [event.id]: true }));
try {
// 调用API获取历史事件相关股票
const response = await stockService.getHistoricalEventStocks(event.id);
setEventStocks(prev => ({
...prev,
[event.id]: response.data || []
}));
} catch (err) {
logger.error('HistoricalEvents', 'handleViewStocks', err, {
eventId: event.id,
eventTitle: event.title
});
setEventStocks(prev => ({
...prev,
[event.id]: []
}));
} finally {
setLoadingStocks(prev => ({ ...prev, [event.id]: false }));
}
};
// 关闭弹窗
const handleCloseModal = () => {
setStocksModalOpen(false);
setSelectedEventForStocks(null);
};
// 处理卡片点击跳转到事件详情页
const handleCardClick = (event) => {
navigate(`/event-detail/${event.id}`);
};
// 获取重要性颜色
const getImportanceColor = (importance) => {
if (importance >= 4) return 'red';
if (importance >= 2) return 'orange';
return 'green';
};
// 获取相关度颜色1-10
const getSimilarityColor = (similarity) => {
if (similarity >= 8) return 'green';
if (similarity >= 6) return 'blue';
if (similarity >= 4) return 'orange';
return 'gray';
};
// 格式化日期
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)}年前`;
};
// 加载状态
if (loading) {
return (
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
{[1, 2, 3].map((i) => (
<Box
key={i}
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
p={4}
>
<VStack align="flex-start" spacing={3}>
<Skeleton height="20px" width="70%" />
<Skeleton height="16px" width="50%" />
<Skeleton height="60px" width="100%" />
<Skeleton height="32px" width="100px" />
</VStack>
</Box>
))}
</SimpleGrid>
);
}
// 错误状态
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 (
<>
{/* 超预期得分显示 */}
{expectationScore && (
<Box
mb={4}
p={3}
bg={useColorModeValue('yellow.50', 'yellow.900')}
borderColor="yellow.200"
borderWidth="1px"
borderRadius="md"
>
<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>
</Box>
)}
{/* 历史事件卡片列表 - 混合布局 */}
<VStack spacing={3} align="stretch">
{events.map((event) => {
const importanceColor = getImportanceColor(event.importance);
return (
<Box
key={event.id}
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="lg"
position="relative"
overflow="visible"
cursor="pointer"
onClick={() => handleCardClick(event)}
_before={{
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
bgGradient: 'linear(to-r, blue.400, purple.500, pink.500)',
borderTopLeftRadius: 'lg',
borderTopRightRadius: 'lg',
}}
_hover={{
boxShadow: 'lg',
borderColor: 'blue.400',
}}
transition="all 0.2s"
>
<VStack align="stretch" spacing={2} p={3}>
{/* 顶部区域:左侧(标题+时间) + 右侧(按钮) */}
<HStack align="flex-start" spacing={3}>
{/* 左侧:标题 + 时间信息(允许折行) */}
<VStack flex="1" align="flex-start" spacing={1}>
{/* 标题 */}
<Text
fontSize="md"
fontWeight="bold"
color={useColorModeValue('blue.600', 'blue.400')}
lineHeight="1.4"
cursor="pointer"
onClick={(e) => {
e.stopPropagation();
handleCardClick(event);
}}
_hover={{ textDecoration: 'underline' }}
>
{event.title || '未命名事件'}
</Text>
{/* 时间 + Badges允许折行 */}
<HStack spacing={2} flexWrap="wrap">
<Text fontSize="sm" color={textSecondary}>
{formatDate(getEventDate(event))}
</Text>
<Text fontSize="sm" color={textSecondary}>
({getRelativeTime(getEventDate(event))})
</Text>
{event.importance && (
<Badge colorScheme={importanceColor} size="sm">
重要性: {event.importance}
</Badge>
)}
{event.avg_change_pct !== undefined && event.avg_change_pct !== null && (
<Badge
colorScheme={event.avg_change_pct > 0 ? 'red' : event.avg_change_pct < 0 ? 'green' : 'gray'}
size="sm"
>
涨幅: {event.avg_change_pct > 0 ? '+' : ''}{event.avg_change_pct.toFixed(2)}%
</Badge>
)}
{event.similarity !== undefined && event.similarity !== null && (
<Badge colorScheme={getSimilarityColor(event.similarity)} size="sm">
相关度: {event.similarity}
</Badge>
)}
</HStack>
</VStack>
{/* 右侧:相关股票按钮 */}
<Button
size="sm"
leftIcon={<Icon as={FaChartLine} />}
onClick={(e) => {
e.stopPropagation();
handleViewStocks(event);
}}
colorScheme="blue"
variant="outline"
flexShrink={0}
>
相关股票
</Button>
</HStack>
{/* 底部:描述(独占整行)- 升级和降级处理 */}
<Box>
{(() => {
const content = getEventContent(event);
// 检查是否有 data 结构(升级版本)
if (content && typeof content === 'object' && content.data) {
return (
<CitedContent
data={content}
title=""
showAIBadge={true}
containerStyle={{
backgroundColor: useColorModeValue('#f7fafc', 'rgba(45, 55, 72, 0.6)'),
borderRadius: '8px',
padding: '0',
}}
/>
);
}
// 降级版本:纯文本
return (
<Text
fontSize="sm"
color={nameColor}
lineHeight="1.6"
noOfLines={2}
>
{content ? `${content}AI合成` : '暂无内容'}
</Text>
);
})()}
</Box>
</VStack>
</Box>
);
})}
</VStack>
{/* 相关股票 Modal - 条件渲染 */}
{stocksModalOpen && (
<Modal
isOpen={stocksModalOpen}
onClose={handleCloseModal}
size="6xl"
scrollBehavior="inside"
>
<ModalOverlay />
<ModalContent>
<ModalHeader>
{selectedEventForStocks?.title || '历史事件相关股票'}
</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
{loadingStocks[selectedEventForStocks?.id] ? (
<VStack spacing={4} py={12}>
<Spinner size="xl" color="blue.500" />
<Text color={textSecondary}>加载相关股票数据...</Text>
</VStack>
) : (
<StocksList
stocks={eventStocks[selectedEventForStocks?.id] || []}
eventTradingDate={getEventDate(selectedEventForStocks)}
/>
)}
</ModalBody>
</ModalContent>
</Modal>
)}
</>
);
};
// 股票列表子组件(卡片式布局)
const StocksList = ({ stocks, eventTradingDate }) => {
const [expandedStocks, setExpandedStocks] = useState(new Set());
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const dividerColor = useColorModeValue('gray.200', 'gray.600');
const textSecondary = useColorModeValue('gray.600', 'gray.400');
const nameColor = useColorModeValue('gray.700', 'gray.300');
// 处理关联描述字段的辅助函数
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 toggleExpand = (stockId) => {
const newExpanded = new Set(expandedStocks);
if (newExpanded.has(stockId)) {
newExpanded.delete(stockId);
} else {
newExpanded.add(stockId);
}
setExpandedStocks(newExpanded);
};
// 格式化涨跌幅
const formatChange = (value) => {
if (value === null || value === undefined || isNaN(value)) return '--';
const prefix = value > 0 ? '+' : '';
return `${prefix}${parseFloat(value).toFixed(2)}%`;
};
// 获取涨跌幅颜色
const getChangeColor = (value) => {
const num = parseFloat(value);
if (isNaN(num) || num === 0) return 'gray.500';
return num > 0 ? 'red.500' : 'green.500';
};
// 获取相关度颜色
const getCorrelationColor = (correlation) => {
if (correlation >= 0.8) return 'red';
if (correlation >= 0.6) return 'orange';
return 'green';
};
if (!stocks || stocks.length === 0) {
return (
<Box textAlign="center" py={12} 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>
)}
{/* 股票卡片网格 */}
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
{stocks.map((stock, index) => {
const stockId = stock.id || index;
const isExpanded = expandedStocks.has(stockId);
const cleanCode = stock.stock_code ? stock.stock_code.replace(/\.(SZ|SH)$/i, '') : '';
const relationDesc = getRelationDesc(stock.relation_desc);
const needTruncate = relationDesc && relationDesc.length > 50;
return (
<Box
key={stockId}
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
p={4}
_hover={{
boxShadow: 'md',
borderColor: 'blue.300',
}}
transition="all 0.2s"
>
<VStack align="stretch" spacing={3}>
{/* 顶部:股票代码 + 名称 + 涨跌幅 */}
<Flex justify="space-between" align="center">
<VStack align="flex-start" spacing={1}>
<Link
href={`https://valuefrontier.cn/company?scode=${cleanCode}`}
isExternal
fontSize="md"
fontWeight="bold"
color="blue.500"
_hover={{ textDecoration: 'underline' }}
>
{cleanCode}
</Link>
<Text fontSize="sm" color={nameColor}>
{stock.stock_name || '--'}
</Text>
</VStack>
<Text
fontSize="lg"
fontWeight="bold"
color={getChangeColor(stock.event_day_change_pct)}
>
{formatChange(stock.event_day_change_pct)}
</Text>
</Flex>
{/* 分隔线 */}
<Box borderTop="1px solid" borderColor={dividerColor} />
{/* 板块和相关度 */}
<Flex justify="space-between" align="center" flexWrap="wrap" gap={2}>
<HStack spacing={2}>
<Text fontSize="xs" color={textSecondary}>板块</Text>
<Badge size="sm" variant="outline">
{stock.sector || '未知'}
</Badge>
</HStack>
<HStack spacing={2}>
<Text fontSize="xs" color={textSecondary}>相关度</Text>
<Badge
colorScheme={getCorrelationColor(stock.correlation || 0)}
size="sm"
>
{Math.round((stock.correlation || 0) * 100)}%
</Badge>
</HStack>
</Flex>
{/* 分隔线 */}
<Box borderTop="1px solid" borderColor={dividerColor} />
{/* 关联原因 */}
{relationDesc && (
<Box>
<Text fontSize="xs" color={textSecondary} mb={1}>
关联原因
</Text>
<Collapse in={isExpanded} startingHeight={40}>
<Text fontSize="sm" color={nameColor} lineHeight="1.6">
{relationDesc}AI合成
</Text>
</Collapse>
{needTruncate && (
<Button
size="xs"
variant="link"
colorScheme="blue"
onClick={() => toggleExpand(stockId)}
mt={1}
>
{isExpanded ? '收起' : '展开'}
</Button>
)}
</Box>
)}
</VStack>
</Box>
);
})}
</SimpleGrid>
</>
);
};
export default HistoricalEvents;