Initial commit

This commit is contained in:
2025-10-11 11:55:25 +08:00
parent 467dad8449
commit 8107dee8d3
2879 changed files with 610575 additions and 0 deletions

View File

@@ -0,0 +1,539 @@
// src/views/EventDetail/components/HistoricalEvents.js
import React, { useState, useEffect } from 'react';
import {
Box,
VStack,
HStack,
Text,
Badge,
Button,
Collapse,
useDisclosure,
Skeleton,
Alert,
AlertIcon,
Card,
CardBody,
CardHeader,
Divider,
Icon,
useColorModeValue,
Tooltip,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
Spinner,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
Link
} from '@chakra-ui/react';
import {
FaExclamationTriangle,
FaClock,
FaCalendarAlt,
FaChartLine,
FaEye,
FaTimes,
FaInfoCircle
} from 'react-icons/fa';
import { stockService } from '../../../services/eventService';
const HistoricalEvents = ({
events = [],
expectationScore = null,
loading = false,
error = null
}) => {
// 所有 useState/useEffect/useContext/useRef/useCallback/useMemo 必须在组件顶层、顺序一致
// 不要在 if/循环/回调中调用 Hook
const [expandedEvents, setExpandedEvents] = useState(new Set());
const [selectedEvent, setSelectedEvent] = useState(null);
const [eventStocks, setEventStocks] = useState({});
const [loadingStocks, setLoadingStocks] = useState(false);
const { isOpen, onOpen, onClose } = useDisclosure();
// 颜色主题
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 showEventStocks = async (event) => {
setSelectedEvent(event);
setLoadingStocks(true);
onOpen();
try {
// 如果已经加载过该事件的股票数据,直接使用缓存
if (eventStocks[event.id]) {
setLoadingStocks(false);
return;
}
// 调用API获取历史事件相关股票
const response = await stockService.getHistoricalEventStocks(event.id);
setEventStocks(prev => ({
...prev,
[event.id]: response.data || []
}));
} catch (err) {
console.error('加载事件股票失败:', err);
setEventStocks(prev => ({
...prev,
[event.id]: []
}));
} finally {
setLoadingStocks(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 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} />}
onClick={() => showEventStocks(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>
</VStack>
</CardBody>
</Card>
</Box>
</Box>
);
})}
</VStack>
</Box>
</VStack>
{/* 事件相关股票模态框 */}
<Modal isOpen={isOpen} onClose={onClose} size="4xl">
<ModalOverlay />
<ModalContent maxW="80vw" maxH="85vh">
<ModalHeader>
<VStack align="flex-start" spacing={1}>
<Text>{selectedEvent?.title || '历史事件'}</Text>
<Text fontSize="sm" color={textSecondary} fontWeight="normal">
相关股票信息
</Text>
</VStack>
</ModalHeader>
<ModalCloseButton />
<ModalBody overflowY="auto" maxH="calc(85vh - 180px)">
{loadingStocks ? (
<VStack spacing={4} py={8}>
<Spinner size="lg" color="blue.500" />
<Text color={textSecondary}>加载相关股票数据...</Text>
</VStack>
) : (
<StocksList
stocks={selectedEvent ? eventStocks[selectedEvent.id] || [] : []}
eventTradingDate={selectedEvent ? selectedEvent.event_date : null}
/>
)}
</ModalBody>
<ModalFooter>
<Button onClick={onClose}>关闭</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
};
// 股票列表子组件
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, '');
};
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">
{stock.relation_desc ? `${stock.relation_desc}AI合成` : '--'}
</Text>
</VStack>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</>
);
};
export default HistoricalEvents;