608 lines
26 KiB
JavaScript
608 lines
26 KiB
JavaScript
// 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 { Tag } from 'antd';
|
||
import { RobotOutlined } from '@ant-design/icons';
|
||
import { stockService } from '@services/eventService';
|
||
import { logger } from '@utils/logger';
|
||
import CitedContent from '@components/Citation/CitedContent';
|
||
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
|
||
|
||
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 = PROFESSIONAL_COLORS.background.card;
|
||
const borderColor = PROFESSIONAL_COLORS.border.default;
|
||
const textSecondary = PROFESSIONAL_COLORS.text.secondary;
|
||
const nameColor = PROFESSIONAL_COLORS.text.primary;
|
||
|
||
// 字段兼容函数
|
||
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="gray.500"
|
||
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={3} p={4}>
|
||
{/* 顶部区域:左侧(标题+时间) + 右侧(按钮) */}
|
||
<HStack align="flex-start" spacing={3}>
|
||
{/* 左侧:标题 + 时间信息(允许折行) */}
|
||
<VStack flex="1" align="flex-start" spacing={2}>
|
||
{/* 标题 */}
|
||
<Text
|
||
fontSize="lg"
|
||
fontWeight="bold"
|
||
color={useColorModeValue('blue.500', 'blue.300')}
|
||
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="gray.300" fontWeight="medium">
|
||
{formatDate(getEventDate(event))}
|
||
</Text>
|
||
<Text fontSize="sm" color="gray.400">
|
||
({getRelativeTime(getEventDate(event))})
|
||
</Text>
|
||
{event.importance && (
|
||
<Badge colorScheme={importanceColor} fontSize="xs" px={2}>
|
||
重要性: {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'}
|
||
fontSize="xs"
|
||
px={2}
|
||
>
|
||
涨幅: {event.avg_change_pct > 0 ? '+' : ''}{event.avg_change_pct.toFixed(2)}%
|
||
</Badge>
|
||
)}
|
||
{event.similarity !== undefined && event.similarity !== null && (
|
||
<Badge colorScheme={getSimilarityColor(event.similarity)} fontSize="xs" px={2}>
|
||
相关度: {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}
|
||
textColor="#E2E8F0"
|
||
containerStyle={{
|
||
backgroundColor: 'transparent',
|
||
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;
|