Initial commit
This commit is contained in:
368
src/views/EventDetail/components/EventHeader.js
Normal file
368
src/views/EventDetail/components/EventHeader.js
Normal file
@@ -0,0 +1,368 @@
|
||||
// src/views/EventDetail/components/EventHeader.js - 改进的事件头部信息组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
HStack,
|
||||
VStack,
|
||||
Text,
|
||||
Button,
|
||||
Badge,
|
||||
Avatar,
|
||||
Icon,
|
||||
Tag,
|
||||
TagLabel,
|
||||
Wrap,
|
||||
WrapItem,
|
||||
useColorModeValue,
|
||||
Divider,
|
||||
SimpleGrid,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
StatArrow
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FaHeart,
|
||||
FaRegHeart,
|
||||
FaEye,
|
||||
FaComment,
|
||||
FaUsers,
|
||||
FaClock,
|
||||
FaCalendarAlt,
|
||||
FaChartLine
|
||||
} from 'react-icons/fa';
|
||||
|
||||
const EventHeader = ({ event, onFollowToggle }) => {
|
||||
// 颜色主题
|
||||
const bgGradient = useColorModeValue(
|
||||
'linear(to-r, blue.50, purple.50)',
|
||||
'linear(to-r, blue.900, purple.900)'
|
||||
);
|
||||
const textPrimary = useColorModeValue('gray.800', 'white');
|
||||
const textSecondary = useColorModeValue('gray.600', 'gray.300');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const statBg = useColorModeValue('white', 'gray.700');
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '--';
|
||||
return new Date(dateString).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num) => {
|
||||
if (!num && num !== 0) return '0';
|
||||
if (num >= 10000) {
|
||||
return (num / 10000).toFixed(1) + 'w';
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'k';
|
||||
}
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
// 获取超预期分数颜色
|
||||
const getScoreColor = (score) => {
|
||||
if (score >= 80) return 'red';
|
||||
if (score >= 60) return 'orange';
|
||||
if (score >= 40) return 'yellow';
|
||||
return 'green';
|
||||
};
|
||||
|
||||
if (!event) {
|
||||
return <Box>加载中...</Box>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 主要信息区域 */}
|
||||
<Box
|
||||
bgGradient={bgGradient}
|
||||
borderRadius="xl"
|
||||
p={6}
|
||||
mb={6}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* 装饰性背景图案 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="-50px"
|
||||
right="-50px"
|
||||
width="200px"
|
||||
height="200px"
|
||||
bg="whiteAlpha.100"
|
||||
borderRadius="full"
|
||||
opacity={0.3}
|
||||
/>
|
||||
|
||||
<Flex direction={{ base: 'column', lg: 'row' }} gap={6} position="relative">
|
||||
{/* 左侧内容 */}
|
||||
<VStack align="flex-start" spacing={4} flex="1">
|
||||
{/* 事件类型和状态 */}
|
||||
<HStack spacing={3} flexWrap="wrap">
|
||||
<Badge
|
||||
colorScheme="blue"
|
||||
size="lg"
|
||||
px={4}
|
||||
py={2}
|
||||
borderRadius="full"
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{event.event_type || '未分类'}
|
||||
</Badge>
|
||||
<Badge
|
||||
colorScheme={event.status === 'active' ? 'green' : 'gray'}
|
||||
size="lg"
|
||||
px={4}
|
||||
py={2}
|
||||
borderRadius="full"
|
||||
fontSize="sm"
|
||||
>
|
||||
{event.status === 'active' ? '进行中' : '已结束'}
|
||||
</Badge>
|
||||
{event.expectation_surprise_score && (
|
||||
<Badge
|
||||
colorScheme={getScoreColor(event.expectation_surprise_score)}
|
||||
size="lg"
|
||||
px={4}
|
||||
py={2}
|
||||
borderRadius="full"
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
>
|
||||
超预期: {event.expectation_surprise_score}分
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* 事件标题 */}
|
||||
<Text
|
||||
fontSize={{ base: '2xl', md: '3xl' }}
|
||||
fontWeight="bold"
|
||||
lineHeight="1.2"
|
||||
color={textPrimary}
|
||||
>
|
||||
{event.title}
|
||||
</Text>
|
||||
|
||||
{/* 创建者和时间信息 */}
|
||||
<HStack spacing={6} flexWrap="wrap">
|
||||
<HStack spacing={3}>
|
||||
<Avatar
|
||||
size="sm"
|
||||
src={event.creator?.avatar_url}
|
||||
name={event.creator?.username || '未知用户'}
|
||||
/>
|
||||
<VStack align="flex-start" spacing={0}>
|
||||
<Text fontWeight="medium" fontSize="sm" color={textPrimary}>
|
||||
{event.creator?.username || '系统'}
|
||||
</Text>
|
||||
<Text fontSize="xs" color={textSecondary}>
|
||||
创建者
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<HStack spacing={3}>
|
||||
<Icon as={FaCalendarAlt} color={textSecondary} />
|
||||
<VStack align="flex-start" spacing={0}>
|
||||
<Text fontSize="sm" color={textPrimary}>
|
||||
{formatDate(event.created_at)}
|
||||
</Text>
|
||||
<Text fontSize="xs" color={textSecondary}>
|
||||
发布时间
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* 右侧关注按钮 */}
|
||||
<Flex align="flex-start" justify="center">
|
||||
<Button
|
||||
leftIcon={<Icon as={event.is_following ? FaHeart : FaRegHeart} />}
|
||||
colorScheme={event.is_following ? "red" : "blue"}
|
||||
variant={event.is_following ? "solid" : "outline"}
|
||||
size="lg"
|
||||
onClick={onFollowToggle}
|
||||
borderRadius="full"
|
||||
px={8}
|
||||
py={6}
|
||||
fontSize="md"
|
||||
fontWeight="bold"
|
||||
bg={event.is_following ? undefined : 'white'}
|
||||
_hover={{
|
||||
transform: 'translateY(-2px)',
|
||||
shadow: 'lg'
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
{event.is_following ? '已关注' : '关注事件'}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
{/* 统计数据卡片 */}
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4} mb={6}>
|
||||
<Stat
|
||||
px={4}
|
||||
py={3}
|
||||
bg={statBg}
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
textAlign="center"
|
||||
>
|
||||
<StatLabel fontSize="xs" color={textSecondary}>
|
||||
<HStack justify="center" spacing={1}>
|
||||
<Icon as={FaEye} />
|
||||
<Text>浏览量</Text>
|
||||
</HStack>
|
||||
</StatLabel>
|
||||
<StatNumber fontSize="lg" color={textPrimary}>
|
||||
{formatNumber(event.view_count)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
|
||||
<Stat
|
||||
px={4}
|
||||
py={3}
|
||||
bg={statBg}
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
textAlign="center"
|
||||
>
|
||||
<StatLabel fontSize="xs" color={textSecondary}>
|
||||
<HStack justify="center" spacing={1}>
|
||||
<Icon as={FaComment} />
|
||||
<Text>回复数</Text>
|
||||
</HStack>
|
||||
</StatLabel>
|
||||
<StatNumber fontSize="lg" color={textPrimary}>
|
||||
{formatNumber(event.post_count)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
|
||||
<Stat
|
||||
px={4}
|
||||
py={3}
|
||||
bg={statBg}
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
textAlign="center"
|
||||
>
|
||||
<StatLabel fontSize="xs" color={textSecondary}>
|
||||
<HStack justify="center" spacing={1}>
|
||||
<Icon as={FaUsers} />
|
||||
<Text>关注数</Text>
|
||||
</HStack>
|
||||
</StatLabel>
|
||||
<StatNumber fontSize="lg" color={textPrimary}>
|
||||
{formatNumber(event.follower_count)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
|
||||
<Stat
|
||||
px={4}
|
||||
py={3}
|
||||
bg={statBg}
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
textAlign="center"
|
||||
>
|
||||
<StatLabel fontSize="xs" color={textSecondary}>
|
||||
<HStack justify="center" spacing={1}>
|
||||
<Icon as={FaChartLine} />
|
||||
<Text>热度分</Text>
|
||||
</HStack>
|
||||
</StatLabel>
|
||||
<StatNumber fontSize="lg" color={textPrimary}>
|
||||
{event.hot_score ? event.hot_score.toFixed(1) : '0.0'}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* 事件描述 */}
|
||||
{event.description && (
|
||||
<Box mb={6}>
|
||||
<Text fontSize="md" fontWeight="bold" mb={3} color={textPrimary}>
|
||||
事件描述
|
||||
</Text>
|
||||
<Box
|
||||
p={4}
|
||||
bg={useColorModeValue('gray.50', 'gray.700')}
|
||||
borderRadius="lg"
|
||||
borderLeft="4px solid"
|
||||
borderLeftColor="blue.500"
|
||||
>
|
||||
<Box
|
||||
dangerouslySetInnerHTML={{ __html: event.description }}
|
||||
sx={{
|
||||
'& p': { mb: 2, lineHeight: '1.6' },
|
||||
'& p:last-child': { mb: 0 },
|
||||
'& h1, & h2, & h3, & h4, & h5, & h6': {
|
||||
fontWeight: 'bold',
|
||||
mb: 2,
|
||||
color: textPrimary
|
||||
},
|
||||
'& ul, & ol': {
|
||||
pl: 4,
|
||||
mb: 2
|
||||
},
|
||||
'& li': {
|
||||
mb: 1
|
||||
},
|
||||
color: textSecondary
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 相关标签 */}
|
||||
{event.keywords_list && event.keywords_list.length > 0 && (
|
||||
<Box>
|
||||
<Text fontSize="md" fontWeight="bold" mb={3} color={textPrimary}>
|
||||
相关标签
|
||||
</Text>
|
||||
<Wrap spacing={2}>
|
||||
{event.keywords_list.map((tag, index) => (
|
||||
<WrapItem key={index}>
|
||||
<Tag
|
||||
size="md"
|
||||
variant="subtle"
|
||||
colorScheme="blue"
|
||||
cursor="pointer"
|
||||
borderRadius="full"
|
||||
_hover={{
|
||||
bg: useColorModeValue('blue.100', 'blue.700'),
|
||||
transform: 'translateY(-1px)'
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<TagLabel px={2}>{tag}</TagLabel>
|
||||
</Tag>
|
||||
</WrapItem>
|
||||
))}
|
||||
</Wrap>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventHeader;
|
||||
539
src/views/EventDetail/components/HistoricalEvents.js
Normal file
539
src/views/EventDetail/components/HistoricalEvents.js
Normal 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;
|
||||
832
src/views/EventDetail/components/LimitAnalyse.js
Normal file
832
src/views/EventDetail/components/LimitAnalyse.js
Normal file
@@ -0,0 +1,832 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Select,
|
||||
Grid,
|
||||
GridItem,
|
||||
Spinner,
|
||||
Center,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
useToast,
|
||||
useColorModeValue,
|
||||
Badge,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
StatArrow,
|
||||
Divider,
|
||||
Flex,
|
||||
Icon,
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionButton,
|
||||
AccordionPanel,
|
||||
AccordionIcon,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
useDisclosure,
|
||||
Tag,
|
||||
Container,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiTrendingUp,
|
||||
FiBarChart2,
|
||||
FiPieChart,
|
||||
FiActivity,
|
||||
FiDownload,
|
||||
FiCalendar,
|
||||
FiTarget,
|
||||
FiZap,
|
||||
} from 'react-icons/fi';
|
||||
import 'echarts-wordcloud';
|
||||
|
||||
// 导入现有的卡片组件
|
||||
import Card from '../../../components/Card/Card';
|
||||
import CardBody from '../../../components/Card/CardBody';
|
||||
import CardHeader from '../../../components/Card/CardHeader';
|
||||
|
||||
// 导入图表组件
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
|
||||
// 导入导航栏组件
|
||||
import HomeNavbar from '../../../components/Navbars/HomeNavbar';
|
||||
|
||||
// 板块关联TOP10数据计算
|
||||
function getSectorRelationTop10(sectorData) {
|
||||
// 股票代码 -> 所属板块集合
|
||||
const stockSectorMap = new Map();
|
||||
Object.entries(sectorData).forEach(([sector, sectorInfo]) => {
|
||||
(sectorInfo.stocks || []).forEach(stock => {
|
||||
const stockKey = stock.scode;
|
||||
if (!stockSectorMap.has(stockKey)) {
|
||||
stockSectorMap.set(stockKey, new Set());
|
||||
}
|
||||
(stock.core_sectors || []).forEach(s => stockSectorMap.get(stockKey).add(s));
|
||||
});
|
||||
});
|
||||
// 统计板块对
|
||||
const relations = new Map();
|
||||
stockSectorMap.forEach(sectors => {
|
||||
const arr = Array.from(sectors);
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
for (let j = i + 1; j < arr.length; j++) {
|
||||
const pair = [arr[i], arr[j]].sort().join(' - ');
|
||||
relations.set(pair, (relations.get(pair) || 0) + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Top10
|
||||
const sorted = Array.from(relations.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
||||
return {
|
||||
labels: sorted.map(([pair]) => pair),
|
||||
counts: sorted.map(([_, count]) => count)
|
||||
};
|
||||
}
|
||||
|
||||
// 板块关联关系图数据计算
|
||||
function getSectorRelationGraph(sectorData) {
|
||||
// 节点
|
||||
const nodes = [];
|
||||
const nodeMap = new Map();
|
||||
const links = [];
|
||||
const relationMap = new Map();
|
||||
const stockInSectorMap = new Map();
|
||||
let idx = 0;
|
||||
Object.entries(sectorData).forEach(([sector, data]) => {
|
||||
if (sector !== '其他' && sector !== '公告') {
|
||||
nodes.push({
|
||||
name: sector,
|
||||
value: data.count,
|
||||
symbolSize: Math.sqrt(data.count) * 8,
|
||||
category: idx % 10,
|
||||
label: {
|
||||
show: true,
|
||||
formatter: `{b}\n(${data.count}只)`
|
||||
}
|
||||
});
|
||||
nodeMap.set(sector, nodes.length - 1);
|
||||
idx++;
|
||||
(data.stocks || []).forEach(stock => {
|
||||
const stockKey = stock.scode;
|
||||
if (!stockInSectorMap.has(stockKey)) {
|
||||
stockInSectorMap.set(stockKey, new Set());
|
||||
}
|
||||
stockInSectorMap.get(stockKey).add(sector);
|
||||
});
|
||||
}
|
||||
});
|
||||
// 统计边
|
||||
stockInSectorMap.forEach(sectors => {
|
||||
const arr = Array.from(sectors);
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
for (let j = i + 1; j < arr.length; j++) {
|
||||
const key = [arr[i], arr[j]].sort().join('-');
|
||||
relationMap.set(key, (relationMap.get(key) || 0) + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
relationMap.forEach((value, key) => {
|
||||
const [source, target] = key.split('-');
|
||||
if (nodeMap.has(source) && nodeMap.has(target) && value >= 2) {
|
||||
links.push({
|
||||
source: nodeMap.get(source),
|
||||
target: nodeMap.get(target),
|
||||
value,
|
||||
lineStyle: {
|
||||
width: Math.log(value) * 2,
|
||||
opacity: 0.6,
|
||||
curveness: 0.3
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return { nodes, links };
|
||||
}
|
||||
|
||||
// 只取前10大板块,其余合并为“其他”
|
||||
function getTop10Sectors(chartData) {
|
||||
if (!chartData || !chartData.labels || !chartData.counts) return {labels: [], counts: []};
|
||||
const zipped = chartData.labels.map((label, i) => ({label, count: chartData.counts[i]}));
|
||||
const sorted = zipped.sort((a, b) => b.count - a.count);
|
||||
const top10 = sorted.slice(0, 10);
|
||||
const rest = sorted.slice(10);
|
||||
let otherCount = 0;
|
||||
rest.forEach(item => { otherCount += item.count; });
|
||||
const labels = top10.map(item => item.label);
|
||||
const counts = top10.map(item => item.count);
|
||||
if (otherCount > 0) {
|
||||
labels.push('其他');
|
||||
counts.push(otherCount);
|
||||
}
|
||||
return {labels, counts};
|
||||
}
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
const API_BASE = isProduction ? "" : process.env.REACT_APP_API_URL;
|
||||
// const API_BASE = 'http://49.232.185.254:5001'; // 改回5001端口,确保和后端一致
|
||||
|
||||
// 涨停分析服务
|
||||
const limitAnalyseService = {
|
||||
async getAvailableDates() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/limit-analyse/available-dates`);
|
||||
const text = await response.text();
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
return data.data || [];
|
||||
} catch (e) {
|
||||
throw new Error('接口返回内容不是有效的 JSON,实际返回:' + text.slice(0, 100));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching available dates:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async getAnalysisData(date) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/limit-analyse/data?date=${date}`);
|
||||
const data = await response.json();
|
||||
return data; // 修正:直接返回整个对象
|
||||
} catch (error) {
|
||||
console.error('Error fetching analysis data:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async getSectorData(date) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/limit-analyse/sector-data?date=${date}`);
|
||||
const data = await response.json();
|
||||
return data.data || [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching sector data:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async getWordCloudData(date) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/limit-analyse/word-cloud?date=${date}`);
|
||||
const data = await response.json();
|
||||
return data.data || [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching word cloud data:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async exportData(date, exportType = 'excel') {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/limit-analyse/export-data`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ date, type: exportType }),
|
||||
});
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
} catch (error) {
|
||||
console.error('Error exporting data:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const LimitAnalyse = () => {
|
||||
const [selectedDate, setSelectedDate] = useState('');
|
||||
const [availableDates, setAvailableDates] = useState([]);
|
||||
const [analysisData, setAnalysisData] = useState(null);
|
||||
const [sectorData, setSectorData] = useState({}); // 改为对象
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const toast = useToast();
|
||||
|
||||
const bgColor = useColorModeValue('gray.50', 'gray.900');
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
// 加载可用日期
|
||||
useEffect(() => {
|
||||
const loadAvailableDates = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const dates = await limitAnalyseService.getAvailableDates();
|
||||
setAvailableDates(dates);
|
||||
if (dates.length > 0 && !selectedDate) {
|
||||
setSelectedDate(dates[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
setError('加载日期列表失败');
|
||||
toast({
|
||||
title: '错误',
|
||||
description: '加载日期列表失败',
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadAvailableDates();
|
||||
}, []);
|
||||
|
||||
// 加载分析数据
|
||||
useEffect(() => {
|
||||
const loadAnalysisData = async () => {
|
||||
if (!selectedDate) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const analysis = await limitAnalyseService.getAnalysisData(selectedDate);
|
||||
setAnalysisData(analysis);
|
||||
setSectorData(analysis.sector_data || {}); // sector_data为对象
|
||||
// 不再用 wordCloudData
|
||||
} catch (error) {
|
||||
setError('加载分析数据失败');
|
||||
toast({
|
||||
title: '错误',
|
||||
description: '加载分析数据失败',
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadAnalysisData();
|
||||
}, [selectedDate]);
|
||||
|
||||
// 由 sectorData 生成词云数据
|
||||
const wordCloudData = analysisData?.word_freq_data || [];
|
||||
|
||||
// 统计卡片数据
|
||||
const totalStocks = Object.values(sectorData || {}).reduce((sum, data) => sum + data.count, 0);
|
||||
const sectorCount = Object.keys(sectorData || {}).length;
|
||||
// 平均涨幅、最大涨幅
|
||||
const avgChange = analysisData?.avg_change || 0;
|
||||
const maxChange = analysisData?.max_change || 0;
|
||||
|
||||
// 饼图数据
|
||||
const chartOption = {
|
||||
title: {
|
||||
text: '涨停股票分布',
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)',
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
left: 'left',
|
||||
top: 'middle',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '板块分布',
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
center: ['60%', '50%'],
|
||||
data: Object.entries(sectorData || {}).map(([name, data]) => ({
|
||||
name,
|
||||
value: data.count,
|
||||
})),
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const wordCloudOption = {
|
||||
title: {
|
||||
text: '热点词汇',
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'wordCloud',
|
||||
shape: 'circle',
|
||||
left: 'center',
|
||||
top: 'center',
|
||||
width: '70%',
|
||||
height: '80%',
|
||||
right: null,
|
||||
bottom: null,
|
||||
sizeRange: [12, 60],
|
||||
rotationRange: [-90, 90],
|
||||
rotationStep: 45,
|
||||
gridSize: 8,
|
||||
drawOutOfBound: false,
|
||||
textStyle: {
|
||||
fontFamily: 'sans-serif',
|
||||
fontWeight: 'bold',
|
||||
color: function () {
|
||||
return 'rgb(' + [
|
||||
Math.round(Math.random() * 160),
|
||||
Math.round(Math.random() * 160),
|
||||
Math.round(Math.random() * 160),
|
||||
].join(',') + ')';
|
||||
},
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'self',
|
||||
textStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowColor: '#333',
|
||||
},
|
||||
},
|
||||
data: wordCloudData.map((word) => ({
|
||||
name: word.name,
|
||||
value: Number(word.value) || 0,
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 统计卡片组件
|
||||
const StatCard = ({ icon, label, value, color, change }) => {
|
||||
const iconColor = useColorModeValue(`${color}.500`, `${color}.300`);
|
||||
const bgColor = useColorModeValue(`${color}.50`, `${color}.900`);
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
bg={bgColor}
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
_hover={{
|
||||
transform: 'translateY(-2px)',
|
||||
shadow: 'md',
|
||||
}}
|
||||
transition="all 0.3s ease"
|
||||
>
|
||||
<VStack align="start" spacing={2}>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={icon} color={iconColor} boxSize={5} />
|
||||
<Text fontSize="sm" fontWeight="medium" color="gray.600">
|
||||
{label}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text fontSize="2xl" fontWeight="bold" color={iconColor}>
|
||||
{value}
|
||||
</Text>
|
||||
{change && (
|
||||
<HStack spacing={1}>
|
||||
<StatArrow type={change > 0 ? 'increase' : 'decrease'} />
|
||||
<Text fontSize="sm" color={change > 0 ? 'green.500' : 'red.500'}>
|
||||
{Math.abs(change)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert status="error" borderRadius="lg">
|
||||
<AlertIcon />
|
||||
<AlertTitle>错误</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 添加导航栏 */}
|
||||
<HomeNavbar />
|
||||
|
||||
{/* 添加容器和边距 */}
|
||||
<Container maxW="container.xl" px={6} py={8}>
|
||||
<VStack spacing={6} align="stretch">
|
||||
{/* 标题和日期选择 */}
|
||||
<Card bg={cardBg} shadow="xl" borderRadius="2xl">
|
||||
<CardHeader>
|
||||
<HStack justify="space-between" align="center">
|
||||
<VStack align="start" spacing={2}>
|
||||
<HStack spacing={3}>
|
||||
<Icon as={FiTrendingUp} color="blue.500" boxSize={6} />
|
||||
<Text fontSize="xl" fontWeight="bold">
|
||||
涨停分析
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text color="gray.600">
|
||||
分析每日涨停股票的数据分布和热点词汇
|
||||
</Text>
|
||||
</VStack>
|
||||
<HStack spacing={3}>
|
||||
<Select
|
||||
value={selectedDate}
|
||||
onChange={(e) => setSelectedDate(e.target.value)}
|
||||
placeholder="选择日期"
|
||||
maxW="200px"
|
||||
size="md"
|
||||
>
|
||||
{availableDates.map((date) => (
|
||||
<option key={date} value={date}>
|
||||
{date}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Button
|
||||
leftIcon={<FiDownload />}
|
||||
colorScheme="blue"
|
||||
variant="outline"
|
||||
onClick={() => handleExport('excel')}
|
||||
size="md"
|
||||
>
|
||||
导出
|
||||
</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{isLoading ? (
|
||||
<Center py={10}>
|
||||
<VStack spacing={4}>
|
||||
<Spinner size="xl" color="blue.500" />
|
||||
<Text color="gray.600">加载数据中...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<>
|
||||
<Grid templateColumns="repeat(3, 1fr)" gap={6}>
|
||||
{/* 板块分布图表 */}
|
||||
<GridItem>
|
||||
<Card bg={cardBg} shadow="xl" borderRadius="2xl" overflow="hidden">
|
||||
<CardHeader>
|
||||
<HStack spacing={3}>
|
||||
<Icon as={FiPieChart} color="blue.500" boxSize={5} />
|
||||
<Text fontSize="lg" fontWeight="bold">
|
||||
板块分布
|
||||
</Text>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<ReactECharts option={{
|
||||
title: { text: '涨停股票分布', left: 'center', textStyle: { fontSize: 16, fontWeight: 'bold' } },
|
||||
tooltip: { trigger: 'item', formatter: '{a} <br/>{b}: {c} ({d}%)' },
|
||||
legend: { orient: 'vertical', left: 'left', top: 'middle' },
|
||||
series: [{
|
||||
name: '板块分布',
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
center: ['60%', '50%'],
|
||||
data: getTop10Sectors(analysisData?.chart_data).labels.map((name, i) => ({ name, value: getTop10Sectors(analysisData?.chart_data).counts[i] })),
|
||||
emphasis: { itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0, 0, 0, 0.5)' } },
|
||||
}],
|
||||
}} style={{ height: '300px' }} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</GridItem>
|
||||
{/* 板块股票数量柱状图 */}
|
||||
<GridItem>
|
||||
<Card bg={cardBg} shadow="xl" borderRadius="2xl" overflow="hidden">
|
||||
<CardHeader>
|
||||
<HStack spacing={3}>
|
||||
<Icon as={FiBarChart2} color="green.500" boxSize={5} />
|
||||
<Text fontSize="lg" fontWeight="bold">
|
||||
板块股票数量
|
||||
</Text>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<ReactECharts
|
||||
option={{
|
||||
title: { text: '板块股票数量分布', left: 'center', textStyle: { fontSize: 14 } },
|
||||
xAxis: { type: 'category', data: getTop10Sectors(analysisData?.chart_data).labels, axisLabel: { rotate: 45 } },
|
||||
yAxis: { type: 'value', name: '股票数量' },
|
||||
series: [{
|
||||
data: getTop10Sectors(analysisData?.chart_data).counts,
|
||||
type: 'bar',
|
||||
itemStyle: { color: '#5e72e4' }
|
||||
}]
|
||||
}}
|
||||
style={{ height: '300px' }}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</GridItem>
|
||||
{/* 板块关联TOP10横向条形图 */}
|
||||
<GridItem>
|
||||
<Card bg={cardBg} shadow="xl" borderRadius="2xl" overflow="hidden">
|
||||
<CardHeader>
|
||||
<HStack spacing={3}>
|
||||
<Icon as={FiPieChart} color="purple.500" boxSize={5} />
|
||||
<Text fontSize="lg" fontWeight="bold">
|
||||
板块关联TOP10
|
||||
</Text>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<ReactECharts
|
||||
option={{
|
||||
title: { text: '板块关联度 Top 10', left: 'center', textStyle: { fontSize: 14 } },
|
||||
xAxis: { type: 'value', name: '共同持有股票数量' },
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: getSectorRelationTop10(sectorData).labels,
|
||||
axisLabel: {
|
||||
fontSize: 12,
|
||||
width: 120,
|
||||
overflow: 'break',
|
||||
formatter: function(value) {
|
||||
// 自动换行
|
||||
if (value.length > 10) return value.replace(/(.{10})/g, '$1\n');
|
||||
return value;
|
||||
}
|
||||
}
|
||||
},
|
||||
series: [{
|
||||
data: getSectorRelationTop10(sectorData).counts,
|
||||
type: 'bar',
|
||||
itemStyle: { color: '#45b7d1' },
|
||||
label: { show: true, position: 'right' }
|
||||
}],
|
||||
grid: { left: 120, right: 20, top: 40, bottom: 40 }
|
||||
}}
|
||||
style={{ height: '300px' }}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
{/* 板块关联关系图+词云 */}
|
||||
<Grid templateColumns="repeat(2, 1fr)" gap={6} mt={8}>
|
||||
<GridItem>
|
||||
<Card bg={cardBg} shadow="xl" borderRadius="2xl" overflow="hidden">
|
||||
<CardHeader>
|
||||
<HStack spacing={3}>
|
||||
<Icon as={FiPieChart} color="purple.500" boxSize={5} />
|
||||
<Text fontSize="lg" fontWeight="bold">
|
||||
板块关联关系图
|
||||
</Text>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<ReactECharts
|
||||
option={{
|
||||
title: { text: '板块关联关系图', left: 'center', textStyle: { fontSize: 14 } },
|
||||
tooltip: {},
|
||||
series: [{
|
||||
type: 'graph',
|
||||
layout: 'force',
|
||||
data: getSectorRelationGraph(sectorData).nodes,
|
||||
links: getSectorRelationGraph(sectorData).links,
|
||||
roam: true,
|
||||
label: { show: true },
|
||||
force: { repulsion: 300, gravity: 0.1, edgeLength: 120 },
|
||||
lineStyle: { width: 2, color: '#aaa', curveness: 0.2 },
|
||||
edgeSymbol: ['none', 'arrow'],
|
||||
edgeSymbolSize: 8
|
||||
}]
|
||||
}}
|
||||
style={{ height: '400px' }}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</GridItem>
|
||||
<GridItem>
|
||||
<Card bg={cardBg} shadow="xl" borderRadius="2xl" overflow="hidden">
|
||||
<CardHeader>
|
||||
<HStack spacing={3}>
|
||||
<Icon as={FiActivity} color="orange.500" boxSize={5} />
|
||||
<Text fontSize="lg" fontWeight="bold">
|
||||
热点词云
|
||||
</Text>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<ReactECharts
|
||||
option={wordCloudOption}
|
||||
style={{ height: '400px' }}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
{/* 数据统计 */}
|
||||
<Grid templateColumns="repeat(auto-fit, minmax(200px, 1fr))" gap={4} mt={8}>
|
||||
<GridItem colSpan={2}>
|
||||
<Card bg={cardBg} shadow="xl" borderRadius="2xl">
|
||||
<CardHeader>
|
||||
<HStack spacing={3}>
|
||||
<Icon as={FiActivity} color="purple.500" boxSize={5} />
|
||||
<Text fontSize="lg" fontWeight="bold">
|
||||
数据统计
|
||||
</Text>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Grid templateColumns="repeat(auto-fit, minmax(200px, 1fr))" gap={4}>
|
||||
<StatCard
|
||||
icon={FiTarget}
|
||||
label="涨停股票总数"
|
||||
value={totalStocks}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
icon={FiBarChart2}
|
||||
label="涉及板块数"
|
||||
value={sectorCount}
|
||||
color="green"
|
||||
/>
|
||||
<StatCard
|
||||
icon={FiTrendingUp}
|
||||
label="平均涨幅"
|
||||
value={`${avgChange}%`}
|
||||
color="orange"
|
||||
/>
|
||||
<StatCard
|
||||
icon={FiZap}
|
||||
label="最大涨幅"
|
||||
value={`${maxChange}%`}
|
||||
color="red"
|
||||
/>
|
||||
</Grid>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
{/* 板块手风琴 */}
|
||||
<Grid templateColumns="1fr" gap={6} mt={8}>
|
||||
{Object.keys(sectorData).length > 0 && (
|
||||
<GridItem>
|
||||
<Card bg={cardBg} shadow="xl" borderRadius="2xl">
|
||||
<CardHeader>
|
||||
<HStack spacing={3}>
|
||||
<Icon as={FiBarChart2} color="teal.500" boxSize={5} />
|
||||
<Text fontSize="lg" fontWeight="bold">
|
||||
板块详细数据
|
||||
</Text>
|
||||
</HStack>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Accordion allowMultiple>
|
||||
{Object.entries(sectorData).map(([sector, data], idx) => (
|
||||
<AccordionItem key={sector} border="none">
|
||||
<h2>
|
||||
<AccordionButton _expanded={{ bg: 'gray.100' }}>
|
||||
<Box flex="1" textAlign="left" fontWeight="bold">
|
||||
{sector} <Tag ml={2} colorScheme="blue">{data.count}</Tag>
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
</h2>
|
||||
<AccordionPanel pb={4}>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{(data.stocks || []).map((stock, sidx) => (
|
||||
<StockCard key={stock.scode} stock={stock} idx={sidx} />
|
||||
))}
|
||||
</VStack>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</GridItem>
|
||||
)}
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// 新增股票卡片组件和弹窗
|
||||
const StockCard = ({ stock, idx }) => {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
return (
|
||||
<Box p={4} borderRadius="lg" bg={useColorModeValue('gray.50', 'gray.700')} border="1px solid" borderColor={useColorModeValue('gray.200', 'gray.600')}>
|
||||
<HStack justify="space-between" align="start">
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<HStack>
|
||||
<Text fontWeight="bold" fontSize="md">{stock.sname} ({stock.scode})</Text>
|
||||
{stock.continuous_days && (
|
||||
<Tag colorScheme={stock.continuous_days.includes('首板') ? 'yellow' : 'red'} size="sm">
|
||||
{stock.continuous_days}
|
||||
</Tag>
|
||||
)}
|
||||
</HStack>
|
||||
<Text color="gray.500" fontSize="sm">涨停时间:{stock.formatted_time}</Text>
|
||||
<Box mt={2} mb={2}>
|
||||
<Text fontSize="sm" color="gray.700">{stock.brief}</Text>
|
||||
{stock.summary && (
|
||||
<Button onClick={onOpen} size="xs" colorScheme="yellow" variant="outline" ml={2} mt={2}>
|
||||
查看详情
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
<HStack spacing={2} mt={2} flexWrap="wrap">
|
||||
{(stock.core_sectors || []).map((sector) => (
|
||||
<Tag key={sector} colorScheme="blackAlpha" bg="#1b1b1b" color="#eacd76" fontWeight="500" borderRadius="md">
|
||||
{sector}
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
{/* 详细摘要弹窗美化 */}
|
||||
{stock.summary && (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="lg" isCentered>
|
||||
<ModalOverlay />
|
||||
<ModalContent borderRadius="lg" p={0}>
|
||||
<ModalHeader p={4} bg="#1b1b1b" color="#eacd76" fontWeight="bold" fontSize="lg" borderTopRadius="lg">
|
||||
{stock.sname} ({stock.scode}) - 详细分析
|
||||
</ModalHeader>
|
||||
<ModalCloseButton color="#eacd76" top={3} right={3} />
|
||||
<ModalBody p={6} bg={useColorModeValue('white', 'gray.800')}>
|
||||
<Box fontSize="md" color="#222" lineHeight={1.9} whiteSpace="pre-line" dangerouslySetInnerHTML={{ __html: (stock.summary || '').replace(/<br\s*\/?>(\s*)/g, '\n').replace(/\n{2,}/g, '\n').replace(/\n/g, '<br/>') }} />
|
||||
</ModalBody>
|
||||
<ModalFooter bg={useColorModeValue('white', 'gray.800')} justifyContent="center">
|
||||
<Button onClick={onClose} colorScheme="yellow" borderRadius="md" px={8} fontWeight="bold">关闭</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LimitAnalyse;
|
||||
743
src/views/EventDetail/components/RelatedConcepts.js
Normal file
743
src/views/EventDetail/components/RelatedConcepts.js
Normal file
@@ -0,0 +1,743 @@
|
||||
// src/views/EventDetail/components/RelatedConcepts.js - 支持概念API调用
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Icon, // 明确导入 Icon 组件
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
SimpleGrid,
|
||||
Image,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
useDisclosure,
|
||||
Skeleton,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Card,
|
||||
CardBody,
|
||||
useColorModeValue,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Button,
|
||||
Center,
|
||||
Divider
|
||||
} from '@chakra-ui/react';
|
||||
import { FaEye, FaExternalLinkAlt, FaChartLine, FaCalendarAlt } from 'react-icons/fa';
|
||||
import moment from 'moment';
|
||||
import tradingDayUtils from '../../../utils/tradingDayUtils'; // 引入交易日工具
|
||||
|
||||
// API配置
|
||||
const API_BASE_URL = process.env.NODE_ENV === 'production' ? '/concept-api' : 'https://valuefrontier.cn/concept-api';
|
||||
|
||||
// 增强版 ConceptCard 组件 - 展示更多数据细节
|
||||
const ConceptCard = ({ concept, tradingDate, onViewDetails }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const textColor = useColorModeValue('gray.600', 'gray.400');
|
||||
const highlightBg = useColorModeValue('yellow.50', 'yellow.900');
|
||||
|
||||
// 计算涨跌幅颜色和符号
|
||||
const changeColor = concept.price_info?.avg_change_pct > 0 ? 'red' : 'green';
|
||||
const changeSymbol = concept.price_info?.avg_change_pct > 0 ? '+' : '';
|
||||
const hasValidPriceInfo = concept.price_info && concept.price_info.avg_change_pct !== null;
|
||||
|
||||
// 获取匹配类型的中文名称
|
||||
const getMatchTypeName = (type) => {
|
||||
const typeMap = {
|
||||
'hybrid_knn': '混合匹配',
|
||||
'keyword': '关键词匹配',
|
||||
'semantic': '语义匹配'
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
|
||||
// 处理概念点击
|
||||
const handleConceptClick = () => {
|
||||
window.open(`https://valuefrontier.cn/htmls/${encodeURIComponent(concept.concept)}.html`, '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
borderWidth={2}
|
||||
_hover={{
|
||||
transform: 'translateY(-2px)',
|
||||
shadow: 'xl',
|
||||
borderColor: 'blue.400'
|
||||
}}
|
||||
transition="all 0.3s"
|
||||
>
|
||||
<CardBody p={5}>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{/* 头部信息 */}
|
||||
<Box>
|
||||
<HStack justify="space-between" align="flex-start" mb={2}>
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<Text fontSize="lg" fontWeight="bold" color="blue.600">
|
||||
{concept.concept}
|
||||
</Text>
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Badge colorScheme="purple" fontSize="xs">
|
||||
相关度: {concept.score.toFixed(2)}
|
||||
</Badge>
|
||||
<Badge colorScheme="teal" fontSize="xs">
|
||||
{getMatchTypeName(concept.match_type)}
|
||||
</Badge>
|
||||
<Badge colorScheme="orange" fontSize="xs">
|
||||
{concept.stock_count} 只股票
|
||||
</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{hasValidPriceInfo && (
|
||||
<Box textAlign="right">
|
||||
<Text fontSize="xs" color={textColor} mb={1}>
|
||||
{tradingDate || concept.price_info.trade_date}
|
||||
</Text>
|
||||
<Badge
|
||||
size="lg"
|
||||
colorScheme={changeColor}
|
||||
fontSize="md"
|
||||
px={3}
|
||||
py={1}
|
||||
>
|
||||
{changeSymbol}{concept.price_info.avg_change_pct?.toFixed(2)}%
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 概念描述 */}
|
||||
<Box>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={textColor}
|
||||
noOfLines={isExpanded ? undefined : 4}
|
||||
lineHeight="1.6"
|
||||
>
|
||||
{concept.description}
|
||||
</Text>
|
||||
{concept.description && concept.description.length > 200 && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="link"
|
||||
colorScheme="blue"
|
||||
mt={1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(!isExpanded);
|
||||
}}
|
||||
>
|
||||
{isExpanded ? '收起' : '展开更多'}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 历史发生时间 */}
|
||||
{concept.happened_times && concept.happened_times.length > 0 && (
|
||||
<Box>
|
||||
<Text fontSize="xs" fontWeight="semibold" mb={2} color={textColor}>
|
||||
历史触发时间:
|
||||
</Text>
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{concept.happened_times.map((time, idx) => (
|
||||
<Badge key={idx} variant="subtle" colorScheme="gray" fontSize="xs">
|
||||
{time}
|
||||
</Badge>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 相关股票展示 - 增强版 */}
|
||||
{concept.stocks && concept.stocks.length > 0 && (
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={2}>
|
||||
<Text fontSize="sm" fontWeight="semibold" color={textColor}>
|
||||
核心相关股票
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
共 {concept.stock_count} 只
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={2}>
|
||||
{concept.stocks.slice(0, isExpanded ? 8 : 4).map((stock, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
bg={useColorModeValue('gray.50', 'gray.700')}
|
||||
fontSize="xs"
|
||||
>
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="semibold">
|
||||
{stock.stock_name}
|
||||
</Text>
|
||||
<Badge size="sm" variant="outline">
|
||||
{stock.stock_code}
|
||||
</Badge>
|
||||
</HStack>
|
||||
{stock.reason && (
|
||||
<Text fontSize="xs" color={textColor} mt={1} noOfLines={2}>
|
||||
{stock.reason}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{concept.stocks.length > 4 && !isExpanded && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
mt={2}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(true);
|
||||
}}
|
||||
>
|
||||
查看更多股票
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<HStack spacing={2} pt={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<FaChartLine />}
|
||||
flex={1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleConceptClick();
|
||||
}}
|
||||
>
|
||||
查看概念详情
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
colorScheme="blue"
|
||||
leftIcon={<FaEye />}
|
||||
flex={1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewDetails(concept);
|
||||
}}
|
||||
>
|
||||
快速预览
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// 主组件 - 修改为接收事件信息并调用API
|
||||
const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoading, error: externalError }) => {
|
||||
// 调试:检查 Icon 组件是否可用
|
||||
if (typeof Icon === 'undefined') {
|
||||
console.error('Icon component is not defined! Make sure @chakra-ui/react is properly imported.');
|
||||
return <div>组件加载错误:Icon 组件未定义</div>;
|
||||
}
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [selectedConcept, setSelectedConcept] = useState(null);
|
||||
const [concepts, setConcepts] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [effectiveTradingDate, setEffectiveTradingDate] = useState(null);
|
||||
const bgColor = useColorModeValue('blue.50', 'blue.900');
|
||||
const textColor = useColorModeValue('gray.600', 'gray.400');
|
||||
|
||||
// 数据验证函数
|
||||
const validateConceptData = (data) => {
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error('Invalid response data format');
|
||||
}
|
||||
|
||||
// 验证新的API格式
|
||||
if (data.results && Array.isArray(data.results)) {
|
||||
return data.results.every(item =>
|
||||
item &&
|
||||
typeof item === 'object' &&
|
||||
(item.concept || item.concept_id) &&
|
||||
typeof item.score === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
// 验证旧的API格式
|
||||
if (data.data && data.data.concepts && Array.isArray(data.data.concepts)) {
|
||||
return data.data.concepts.every(item =>
|
||||
item &&
|
||||
typeof item === 'object' &&
|
||||
(item.concept || item.concept_id) &&
|
||||
typeof item.score === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// 搜索相关概念
|
||||
const searchConcepts = async (title, tradeDate) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 确保tradeDate是字符串格式
|
||||
let formattedTradeDate;
|
||||
if (typeof tradeDate === 'string') {
|
||||
formattedTradeDate = tradeDate;
|
||||
} else if (tradeDate instanceof Date) {
|
||||
formattedTradeDate = moment(tradeDate).format('YYYY-MM-DD');
|
||||
} else if (moment.isMoment(tradeDate)) {
|
||||
formattedTradeDate = tradeDate.format('YYYY-MM-DD');
|
||||
} else {
|
||||
console.warn('Invalid tradeDate format:', tradeDate, typeof tradeDate);
|
||||
formattedTradeDate = moment().format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
query: title,
|
||||
size: 4,
|
||||
page: 1,
|
||||
sort_by: "_score",
|
||||
trade_date: formattedTradeDate
|
||||
};
|
||||
|
||||
console.log('Searching concepts with:', requestBody);
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/search`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Concept search response:', data);
|
||||
|
||||
// 数据验证
|
||||
if (!validateConceptData(data)) {
|
||||
console.warn('Invalid concept data format:', data);
|
||||
setConcepts([]);
|
||||
setError('返回的数据格式无效');
|
||||
return;
|
||||
}
|
||||
|
||||
// 修复:适配实际的API响应格式
|
||||
if (data.results && Array.isArray(data.results)) {
|
||||
setConcepts(data.results);
|
||||
// 使用传入的交易日期作为生效日期
|
||||
setEffectiveTradingDate(formattedTradeDate);
|
||||
} else if (data.data && data.data.concepts) {
|
||||
// 保持向后兼容
|
||||
setConcepts(data.data.concepts);
|
||||
setEffectiveTradingDate(data.data.trade_date || formattedTradeDate);
|
||||
} else {
|
||||
setConcepts([]);
|
||||
console.warn('No concepts found in response');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to search concepts:', err);
|
||||
setError(err.message);
|
||||
setConcepts([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 当事件信息变化时,调用API搜索概念
|
||||
useEffect(() => {
|
||||
if (eventTitle && eventTime) {
|
||||
// 格式化日期为 YYYY-MM-DD
|
||||
let formattedDate;
|
||||
try {
|
||||
// eventTime 可能是Date对象或字符串,使用 moment 处理
|
||||
let eventMoment;
|
||||
|
||||
// 检查是否是Date对象
|
||||
if (eventTime instanceof Date) {
|
||||
eventMoment = moment(eventTime);
|
||||
} else if (typeof eventTime === 'string') {
|
||||
eventMoment = moment(eventTime);
|
||||
} else if (typeof eventTime === 'number') {
|
||||
eventMoment = moment(eventTime);
|
||||
} else {
|
||||
console.warn('Unknown eventTime format:', eventTime, typeof eventTime);
|
||||
eventMoment = moment();
|
||||
}
|
||||
|
||||
// 确保moment对象有效
|
||||
if (!eventMoment.isValid()) {
|
||||
console.warn('Invalid eventTime:', eventTime);
|
||||
eventMoment = moment();
|
||||
}
|
||||
|
||||
formattedDate = eventMoment.format('YYYY-MM-DD');
|
||||
|
||||
// 如果时间是15:00之后,获取下一个交易日
|
||||
if (eventMoment.hour() >= 15) {
|
||||
// 使用 tradingDayUtils 获取下一个交易日
|
||||
if (tradingDayUtils && tradingDayUtils.getNextTradingDay) {
|
||||
const nextTradingDay = tradingDayUtils.getNextTradingDay(formattedDate);
|
||||
// 确保返回的是字符串格式
|
||||
if (typeof nextTradingDay === 'string') {
|
||||
formattedDate = nextTradingDay;
|
||||
} else if (nextTradingDay instanceof Date) {
|
||||
formattedDate = moment(nextTradingDay).format('YYYY-MM-DD');
|
||||
} else {
|
||||
console.warn('tradingDayUtils.getNextTradingDay returned invalid format:', nextTradingDay);
|
||||
formattedDate = eventMoment.add(1, 'day').format('YYYY-MM-DD');
|
||||
}
|
||||
} else {
|
||||
// 降级处理:简单地加一天(不考虑周末和节假日)
|
||||
console.warn('tradingDayUtils.getNextTradingDay not available, using simple date addition');
|
||||
formattedDate = eventMoment.add(1, 'day').format('YYYY-MM-DD');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to format event time:', e);
|
||||
// 使用当前交易日作为fallback
|
||||
if (tradingDayUtils && tradingDayUtils.getCurrentTradingDay) {
|
||||
const currentTradingDay = tradingDayUtils.getCurrentTradingDay();
|
||||
// 确保返回的是字符串格式
|
||||
if (typeof currentTradingDay === 'string') {
|
||||
formattedDate = currentTradingDay;
|
||||
} else if (currentTradingDay instanceof Date) {
|
||||
formattedDate = moment(currentTradingDay).format('YYYY-MM-DD');
|
||||
} else {
|
||||
console.warn('tradingDayUtils.getCurrentTradingDay returned invalid format:', currentTradingDay);
|
||||
formattedDate = moment().format('YYYY-MM-DD');
|
||||
}
|
||||
} else {
|
||||
formattedDate = moment().format('YYYY-MM-DD');
|
||||
}
|
||||
}
|
||||
|
||||
searchConcepts(eventTitle, formattedDate);
|
||||
} else if (!eventTitle) {
|
||||
console.warn('No event title provided for concept search');
|
||||
setConcepts([]);
|
||||
}
|
||||
}, [eventTitle, eventTime]);
|
||||
|
||||
const handleViewDetails = (concept) => {
|
||||
setSelectedConcept(concept);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 合并加载状态
|
||||
const isLoading = externalLoading || loading;
|
||||
const displayError = externalError || error;
|
||||
|
||||
// 加载状态
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Box key={i}>
|
||||
<Skeleton height="200px" borderRadius="lg" />
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
if (displayError) {
|
||||
return (
|
||||
<Alert status="error" borderRadius="lg">
|
||||
<AlertIcon />
|
||||
加载相关概念失败: {displayError}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// 无数据状态
|
||||
if (!concepts || concepts.length === 0) {
|
||||
return (
|
||||
<Box textAlign="center" py={8}>
|
||||
<Text color="gray.500" mb={4}>
|
||||
{eventTitle ? '未找到相关概念' : '暂无相关概念数据'}
|
||||
</Text>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
size="lg"
|
||||
leftIcon={<FaChartLine />}
|
||||
onClick={() => window.open('https://valuefrontier.cn/concepts', '_blank')}
|
||||
>
|
||||
进入概念中心
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 如果有交易日期,显示日期信息 */}
|
||||
{effectiveTradingDate && (
|
||||
<Box mb={4} p={3} bg={bgColor} borderRadius="md">
|
||||
<HStack spacing={2}>
|
||||
<FaCalendarAlt color={textColor} />
|
||||
<Text fontSize="sm" color={textColor}>
|
||||
涨跌幅数据日期:{effectiveTradingDate}
|
||||
{eventTime && effectiveTradingDate !== moment(eventTime).format('YYYY-MM-DD') && (
|
||||
<Text as="span" ml={2} fontSize="xs">
|
||||
(事件发生于 {typeof eventTime === 'object' ? moment(eventTime).format('YYYY-MM-DD HH:mm') : eventTime},显示下一交易日数据)
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 概念卡片网格 */}
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
{concepts.map((concept, index) => (
|
||||
<ConceptCard
|
||||
key={concept.concept_id || index}
|
||||
concept={concept}
|
||||
tradingDate={effectiveTradingDate}
|
||||
onViewDetails={handleViewDetails}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* 进入概念中心按钮 */}
|
||||
<Center mt={8}>
|
||||
<VStack spacing={3}>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
size="lg"
|
||||
leftIcon={<FaChartLine />}
|
||||
onClick={() => window.open('https://valuefrontier.cn/concepts', '_blank')}
|
||||
px={8}
|
||||
py={6}
|
||||
fontSize="md"
|
||||
fontWeight="bold"
|
||||
bgGradient="linear(to-r, blue.400, cyan.400)"
|
||||
_hover={{
|
||||
bgGradient: "linear(to-r, blue.500, cyan.500)",
|
||||
transform: "translateY(-2px)",
|
||||
shadow: "lg"
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
进入概念中心
|
||||
</Button>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
探索更多概念板块,发现投资机会
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
|
||||
{/* 增强版概念详情模态框 */}
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="3xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent maxH="90vh">
|
||||
<ModalHeader borderBottomWidth={1}>
|
||||
<HStack justify="space-between">
|
||||
<VStack align="start" spacing={1}>
|
||||
<Text fontSize="xl">{selectedConcept?.concept}</Text>
|
||||
<HStack spacing={2}>
|
||||
<Badge colorScheme="purple">
|
||||
相关度: {selectedConcept?.score?.toFixed(2)}
|
||||
</Badge>
|
||||
<Badge colorScheme="teal">
|
||||
{selectedConcept?.stock_count} 只股票
|
||||
</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
{selectedConcept?.price_info && (
|
||||
<VStack align="end" spacing={1}>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{selectedConcept.price_info.trade_date || '暂无数据'}
|
||||
</Text>
|
||||
<Badge
|
||||
size="lg"
|
||||
colorScheme={selectedConcept.price_info.avg_change_pct > 0 ? 'red' : 'green'}
|
||||
fontSize="lg"
|
||||
px={4}
|
||||
py={2}
|
||||
>
|
||||
{selectedConcept.price_info.avg_change_pct > 0 ? '+' : ''}
|
||||
{selectedConcept.price_info.avg_change_pct?.toFixed(2) || '0.00'}%
|
||||
</Badge>
|
||||
</VStack>
|
||||
)}
|
||||
</HStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
|
||||
<ModalBody pb={6} overflowY="auto">
|
||||
<VStack spacing={6} align="stretch">
|
||||
{/* 概念描述 - 完整版 */}
|
||||
{selectedConcept?.description && (
|
||||
<Box>
|
||||
<HStack mb={3}>
|
||||
<Icon as={FaChartLine} color="blue.500" />
|
||||
<Text fontSize="md" fontWeight="bold">
|
||||
概念解析
|
||||
</Text>
|
||||
</HStack>
|
||||
<Box
|
||||
p={4}
|
||||
bg={useColorModeValue('blue.50', 'blue.900')}
|
||||
borderRadius="md"
|
||||
>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={useColorModeValue('gray.700', 'gray.300')}
|
||||
lineHeight="1.8"
|
||||
>
|
||||
{selectedConcept.description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 历史触发时间线 */}
|
||||
{selectedConcept?.happened_times && selectedConcept.happened_times.length > 0 && (
|
||||
<Box>
|
||||
<HStack mb={3}>
|
||||
<Icon as={FaCalendarAlt} color="purple.500" />
|
||||
<Text fontSize="md" fontWeight="bold">
|
||||
历史触发时间
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={3} flexWrap="wrap">
|
||||
{selectedConcept.happened_times.map((time, idx) => (
|
||||
<Badge
|
||||
key={idx}
|
||||
colorScheme="purple"
|
||||
variant="subtle"
|
||||
px={3}
|
||||
py={1}
|
||||
>
|
||||
{time}
|
||||
</Badge>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 相关股票详细列表 */}
|
||||
{selectedConcept?.stocks && selectedConcept.stocks.length > 0 && (
|
||||
<Box>
|
||||
<HStack mb={3}>
|
||||
<Icon as={FaEye} color="green.500" />
|
||||
<Text fontSize="md" fontWeight="bold">
|
||||
核心相关股票 ({selectedConcept.stock_count}只)
|
||||
</Text>
|
||||
</HStack>
|
||||
<Box
|
||||
maxH="300px"
|
||||
overflowY="auto"
|
||||
borderWidth={1}
|
||||
borderRadius="md"
|
||||
p={3}
|
||||
>
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={3}>
|
||||
{selectedConcept.stocks.map((stock, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
p={3}
|
||||
borderWidth={1}
|
||||
borderRadius="md"
|
||||
bg={useColorModeValue('white', 'gray.700')}
|
||||
_hover={{
|
||||
bg: useColorModeValue('gray.50', 'gray.600'),
|
||||
borderColor: 'blue.300'
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<HStack justify="space-between" mb={2}>
|
||||
<Text fontWeight="bold" fontSize="sm">
|
||||
{stock.stock_name}
|
||||
</Text>
|
||||
<Badge colorScheme="blue" fontSize="xs">
|
||||
{stock.stock_code}
|
||||
</Badge>
|
||||
</HStack>
|
||||
{stock.reason && (
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
{stock.reason}
|
||||
</Text>
|
||||
)}
|
||||
{(stock.行业 || stock.项目) && (
|
||||
<HStack spacing={2} mt={2}>
|
||||
{stock.行业 && (
|
||||
<Badge size="sm" variant="subtle">
|
||||
{stock.行业}
|
||||
</Badge>
|
||||
)}
|
||||
{stock.项目 && (
|
||||
<Badge size="sm" variant="subtle" colorScheme="green">
|
||||
{stock.项目}
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<HStack spacing={3} pt={4}>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
size="lg"
|
||||
flex={1}
|
||||
onClick={() => {
|
||||
window.open(`https://valuefrontier.cn/htmls/${encodeURIComponent(selectedConcept.concept)}.html`, '_blank');
|
||||
}}
|
||||
leftIcon={<FaExternalLinkAlt />}
|
||||
>
|
||||
查看概念详情页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
colorScheme="blue"
|
||||
size="lg"
|
||||
flex={1}
|
||||
onClick={onClose}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RelatedConcepts;
|
||||
503
src/views/EventDetail/components/RelatedStocks.js
Normal file
503
src/views/EventDetail/components/RelatedStocks.js
Normal file
@@ -0,0 +1,503 @@
|
||||
// src/views/EventDetail/components/RelatedStocks.js - 完整修改版本
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
VStack,
|
||||
Text,
|
||||
Button,
|
||||
Badge,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
useDisclosure,
|
||||
Skeleton,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableContainer,
|
||||
Input,
|
||||
Textarea,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
ButtonGroup,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
CircularProgress,
|
||||
CircularProgressLabel,
|
||||
Flex,
|
||||
Spacer,
|
||||
useToast
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FaPlus,
|
||||
FaTrash,
|
||||
FaChartLine,
|
||||
FaRedo,
|
||||
FaSearch
|
||||
} from 'react-icons/fa';
|
||||
import * as echarts from 'echarts';
|
||||
import StockChartModal from '../../../components/StockChart/StockChartModal';
|
||||
|
||||
import { eventService, stockService } from '../../../services/eventService';
|
||||
|
||||
const RelatedStocks = ({
|
||||
eventId,
|
||||
eventTime, // 新增:从父组件传递事件时间
|
||||
stocks = [],
|
||||
loading = false,
|
||||
error = null,
|
||||
onStockAdded = () => {},
|
||||
onStockDeleted = () => {}
|
||||
}) => {
|
||||
// ==================== 状态管理 ====================
|
||||
const [stocksData, setStocksData] = useState(stocks);
|
||||
const [quotes, setQuotes] = useState({});
|
||||
const [quotesLoading, setQuotesLoading] = useState(false);
|
||||
const [selectedStock, setSelectedStock] = useState(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortField, setSortField] = useState('correlation');
|
||||
const [sortOrder, setSortOrder] = useState('desc');
|
||||
|
||||
// 模态框状态
|
||||
const {
|
||||
isOpen: isChartModalOpen,
|
||||
onOpen: onChartModalOpen,
|
||||
onClose: onChartModalClose
|
||||
} = useDisclosure();
|
||||
|
||||
|
||||
// 主题和工具
|
||||
const toast = useToast();
|
||||
const tableBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
|
||||
// ==================== 副作用处理 ====================
|
||||
useEffect(() => {
|
||||
setStocksData(stocks);
|
||||
}, [stocks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (stocksData.length > 0) {
|
||||
fetchQuotes();
|
||||
}
|
||||
}, [stocksData, eventTime]); // 添加 eventTime 依赖
|
||||
|
||||
// ==================== API 调用函数 ====================
|
||||
const fetchQuotes = useCallback(async () => {
|
||||
if (stocksData.length === 0) return;
|
||||
|
||||
try {
|
||||
setQuotesLoading(true);
|
||||
const codes = stocksData.map(stock => stock.stock_code);
|
||||
|
||||
console.log('获取股票报价,代码:', codes, '事件时间:', eventTime);
|
||||
|
||||
const response = await stockService.getQuotes(codes, eventTime);
|
||||
console.log('股票报价响应:', response);
|
||||
|
||||
setQuotes(response || {});
|
||||
} catch (err) {
|
||||
console.error('获取股票报价失败:', err);
|
||||
toast({
|
||||
title: '获取股票报价失败',
|
||||
description: err.message,
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setQuotesLoading(false);
|
||||
}
|
||||
}, [stocksData, eventTime, toast]);
|
||||
|
||||
const handleRefreshQuotes = () => {
|
||||
fetchQuotes();
|
||||
toast({
|
||||
title: '正在刷新报价...',
|
||||
status: 'info',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const handleDeleteStock = async (stockId, stockCode) => {
|
||||
if (!window.confirm(`确定要删除股票 ${stockCode} 吗?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await eventService.deleteRelatedStock(stockId);
|
||||
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
onStockDeleted();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: '删除失败',
|
||||
description: err.message,
|
||||
status: 'error',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowChart = (stock) => {
|
||||
setSelectedStock(stock);
|
||||
onChartModalOpen();
|
||||
};
|
||||
|
||||
// ==================== 辅助函数 ====================
|
||||
const getCorrelationColor = (correlation) => {
|
||||
const value = (correlation || 0) * 100;
|
||||
if (value >= 80) return 'red';
|
||||
if (value >= 60) return 'orange';
|
||||
return 'green';
|
||||
};
|
||||
|
||||
const formatPriceChange = (change) => {
|
||||
if (!change && change !== 0) return '--';
|
||||
const prefix = change > 0 ? '+' : '';
|
||||
return `${prefix}${change.toFixed(2)}%`;
|
||||
};
|
||||
|
||||
const getStockName = (stock) => {
|
||||
// 优先使用API返回的名称
|
||||
const quote = quotes[stock.stock_code];
|
||||
if (quote && quote.name) {
|
||||
return quote.name;
|
||||
}
|
||||
|
||||
// 其次使用数据库中的名称
|
||||
if (stock.stock_name) {
|
||||
return stock.stock_name;
|
||||
}
|
||||
|
||||
// 最后使用默认格式
|
||||
return `股票${stock.stock_code.split('.')[0]}`;
|
||||
};
|
||||
|
||||
// ==================== 数据处理 ====================
|
||||
const filteredAndSortedStocks = stocksData
|
||||
.filter(stock => {
|
||||
const stockName = getStockName(stock);
|
||||
return stock.stock_code.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
stockName.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
})
|
||||
.sort((a, b) => {
|
||||
let aValue, bValue;
|
||||
|
||||
switch (sortField) {
|
||||
case 'correlation':
|
||||
aValue = a.correlation || 0;
|
||||
bValue = b.correlation || 0;
|
||||
break;
|
||||
case 'change':
|
||||
aValue = quotes[a.stock_code]?.change || 0;
|
||||
bValue = quotes[b.stock_code]?.change || 0;
|
||||
break;
|
||||
case 'price':
|
||||
aValue = quotes[a.stock_code]?.price || 0;
|
||||
bValue = quotes[b.stock_code]?.price || 0;
|
||||
break;
|
||||
default:
|
||||
aValue = a[sortField] || '';
|
||||
bValue = b[sortField] || '';
|
||||
}
|
||||
|
||||
if (sortOrder === 'asc') {
|
||||
return aValue > bValue ? 1 : -1;
|
||||
} else {
|
||||
return aValue < bValue ? 1 : -1;
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== 渲染状态 ====================
|
||||
if (loading) {
|
||||
return (
|
||||
<VStack spacing={4}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<HStack key={i} w="100%" spacing={4}>
|
||||
<Skeleton height="20px" width="100px" />
|
||||
<Skeleton height="20px" width="150px" />
|
||||
<Skeleton height="20px" width="80px" />
|
||||
<Skeleton height="20px" width="100px" />
|
||||
<Skeleton height="20px" width="60px" />
|
||||
<Skeleton height="20px" width="120px" />
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert status="error" borderRadius="lg">
|
||||
<AlertIcon />
|
||||
加载相关股票失败: {error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 主要渲染 ====================
|
||||
return (
|
||||
<>
|
||||
{/* 工具栏 */}
|
||||
<Flex mb={4} align="center" wrap="wrap" gap={4}>
|
||||
{/* 搜索框 */}
|
||||
<HStack>
|
||||
<FaSearch />
|
||||
<Input
|
||||
placeholder="搜索股票代码或名称..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
size="sm"
|
||||
maxW="200px"
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<Spacer />
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<HStack>
|
||||
<Tooltip label="刷新报价">
|
||||
<IconButton
|
||||
icon={<FaRedo />}
|
||||
size="sm"
|
||||
onClick={handleRefreshQuotes}
|
||||
isLoading={quotesLoading}
|
||||
aria-label="刷新报价"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 股票表格 */}
|
||||
<Box bg={tableBg} borderRadius="lg" overflow="hidden" border="1px solid" borderColor={borderColor}>
|
||||
<TableContainer>
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th cursor="pointer" onClick={() => {
|
||||
if (sortField === 'stock_code') {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField('stock_code');
|
||||
setSortOrder('asc');
|
||||
}
|
||||
}}>
|
||||
股票代码 {sortField === 'stock_code' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||
</Th>
|
||||
<Th>股票名称</Th>
|
||||
<Th>产业链</Th>
|
||||
<Th isNumeric cursor="pointer" onClick={() => {
|
||||
if (sortField === 'price') {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField('price');
|
||||
setSortOrder('desc');
|
||||
}
|
||||
}}>
|
||||
最新价 {sortField === 'price' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||
</Th>
|
||||
<Th isNumeric cursor="pointer" onClick={() => {
|
||||
if (sortField === 'change') {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField('change');
|
||||
setSortOrder('desc');
|
||||
}
|
||||
}}>
|
||||
涨跌幅 {sortField === 'change' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||
</Th>
|
||||
<Th textAlign="center" cursor="pointer" onClick={() => {
|
||||
if (sortField === 'correlation') {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField('correlation');
|
||||
setSortOrder('desc');
|
||||
}
|
||||
}}>
|
||||
相关度 {sortField === 'correlation' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||
</Th>
|
||||
<Th>关联描述</Th>
|
||||
<Th>操作</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{filteredAndSortedStocks.length === 0 ? (
|
||||
<Tr>
|
||||
<Td colSpan={9} textAlign="center" py={8} color="gray.500">
|
||||
{searchTerm ? '未找到匹配的股票' : '暂无相关股票数据'}
|
||||
</Td>
|
||||
</Tr>
|
||||
) : (
|
||||
filteredAndSortedStocks.map((stock) => {
|
||||
const quote = quotes[stock.stock_code] || {};
|
||||
const correlation = (stock.correlation || 0) * 100;
|
||||
const stockName = getStockName(stock);
|
||||
|
||||
return (
|
||||
<Tr key={stock.id} _hover={{ bg: useColorModeValue('gray.50', 'gray.700') }}>
|
||||
{/* 股票代码 */}
|
||||
<Td>
|
||||
<Button
|
||||
variant="link"
|
||||
color="blue.500"
|
||||
fontFamily="mono"
|
||||
fontWeight="bold"
|
||||
p={0}
|
||||
onClick={() => handleShowChart(stock)}
|
||||
>
|
||||
{stock.stock_code}
|
||||
</Button>
|
||||
</Td>
|
||||
|
||||
{/* 股票名称 */}
|
||||
<Td>
|
||||
<VStack align="flex-start" spacing={0}>
|
||||
<Text fontSize="sm" fontWeight="medium">
|
||||
{stockName}
|
||||
</Text>
|
||||
{quotesLoading && (
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
加载中...
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Td>
|
||||
|
||||
{/* 产业链 */}
|
||||
<Td>
|
||||
<Badge size="sm" variant="outline">
|
||||
{stock.sector || '未知'}
|
||||
</Badge>
|
||||
</Td>
|
||||
|
||||
{/* 最新价 */}
|
||||
<Td isNumeric>
|
||||
<Text fontSize="sm" fontWeight="medium">
|
||||
{quote.price ? `¥${quote.price.toFixed(2)}` : '--'}
|
||||
</Text>
|
||||
</Td>
|
||||
|
||||
{/* 涨跌幅 */}
|
||||
<Td isNumeric>
|
||||
<Badge
|
||||
colorScheme={
|
||||
!quote.change ? 'gray' :
|
||||
quote.change > 0 ? 'red' :
|
||||
quote.change < 0 ? 'green' : 'gray'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{formatPriceChange(quote.change)}
|
||||
</Badge>
|
||||
</Td>
|
||||
|
||||
|
||||
{/* 相关度 */}
|
||||
<Td textAlign="center">
|
||||
<CircularProgress
|
||||
value={correlation}
|
||||
color={getCorrelationColor(stock.correlation)}
|
||||
size="50px"
|
||||
>
|
||||
<CircularProgressLabel fontSize="xs">
|
||||
{Math.round(correlation)}%
|
||||
</CircularProgressLabel>
|
||||
</CircularProgress>
|
||||
</Td>
|
||||
|
||||
{/* 关联描述 */}
|
||||
<Td maxW="200px">
|
||||
<Text fontSize="xs" noOfLines={2}>
|
||||
{stock.relation_desc || '--'}
|
||||
</Text>
|
||||
</Td>
|
||||
|
||||
{/* 操作 */}
|
||||
<Td>
|
||||
<HStack spacing={1}>
|
||||
<Tooltip label="股票详情">
|
||||
<Button
|
||||
size="xs"
|
||||
colorScheme="blue"
|
||||
variant="solid"
|
||||
onClick={() => {
|
||||
const stockCode = stock.stock_code.split('.')[0];
|
||||
window.open(`https://valuefrontier.cn/company?scode=${stockCode}`, '_blank');
|
||||
}}
|
||||
>
|
||||
股票详情
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="查看K线图">
|
||||
<IconButton
|
||||
icon={<FaChartLine />}
|
||||
size="xs"
|
||||
onClick={() => handleShowChart(stock)}
|
||||
aria-label="查看K线图"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="删除">
|
||||
<IconButton
|
||||
icon={<FaTrash />}
|
||||
size="xs"
|
||||
colorScheme="red"
|
||||
variant="ghost"
|
||||
onClick={() => handleDeleteStock(stock.id, stock.stock_code)}
|
||||
aria-label="删除股票"
|
||||
/>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
|
||||
|
||||
{/* 股票图表模态框 */}
|
||||
<StockChartModal
|
||||
isOpen={isChartModalOpen}
|
||||
onClose={onChartModalClose}
|
||||
stock={selectedStock}
|
||||
eventTime={eventTime}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 子组件 ====================
|
||||
|
||||
|
||||
|
||||
// 现在使用统一的StockChartModal组件,无需重复代码
|
||||
|
||||
export default RelatedStocks;
|
||||
979
src/views/EventDetail/components/TransmissionChainAnalysis.js
Normal file
979
src/views/EventDetail/components/TransmissionChainAnalysis.js
Normal file
@@ -0,0 +1,979 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
Spinner,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Text,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
HStack,
|
||||
VStack,
|
||||
Tag,
|
||||
Badge,
|
||||
List,
|
||||
ListItem,
|
||||
Divider,
|
||||
CloseButton,
|
||||
Grid,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
Tooltip
|
||||
} from '@chakra-ui/react';
|
||||
import { InfoIcon, ViewIcon } from '@chakra-ui/icons';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { eventService } from '../../../services/eventService';
|
||||
|
||||
// 节点样式配置 - 完全复刻Flask版本
|
||||
const NODE_STYLES = {
|
||||
'event': { color: '#ff4757', symbol: 'diamond', size: 'large' },
|
||||
'industry': { color: '#00d2d3', symbol: 'rect', size: 'medium' },
|
||||
'company': { color: '#54a0ff', symbol: 'circle', size: 'medium' },
|
||||
'policy': { color: '#10ac84', symbol: 'triangle', size: 'medium' },
|
||||
'technology': { color: '#ee5a6f', symbol: 'roundRect', size: 'medium' },
|
||||
'market': { color: '#ffd93d', symbol: 'diamond', size: 'medium' },
|
||||
'other': { color: '#a4b0be', symbol: 'circle', size: 'small' }
|
||||
};
|
||||
|
||||
// 影响方向颜色映射 - 完全复刻Flask版本
|
||||
const IMPACT_COLORS = {
|
||||
'positive': '#52c41a',
|
||||
'negative': '#ff4d4f',
|
||||
'neutral': '#999',
|
||||
'mixed': '#faad14'
|
||||
};
|
||||
|
||||
// 节点类型标签映射
|
||||
const NODE_TYPE_LABELS = {
|
||||
'event': '事件',
|
||||
'industry': '行业',
|
||||
'company': '公司',
|
||||
'policy': '政策',
|
||||
'technology': '技术',
|
||||
'market': '市场',
|
||||
'other': '其他'
|
||||
};
|
||||
|
||||
// 过滤孤立节点 - 完全复刻Flask版本
|
||||
function filterIsolatedNodes(nodes, edges) {
|
||||
console.log('开始过滤孤立节点');
|
||||
console.log('输入节点:', nodes);
|
||||
console.log('输入边:', edges);
|
||||
|
||||
if (!nodes || !edges) {
|
||||
console.log('节点或边数据为空');
|
||||
return [];
|
||||
}
|
||||
|
||||
const connectedNodeIds = new Set();
|
||||
edges.forEach(edge => {
|
||||
console.log('处理边:', edge, '从', edge.source, '到', edge.target);
|
||||
connectedNodeIds.add(String(edge.source));
|
||||
connectedNodeIds.add(String(edge.target));
|
||||
});
|
||||
|
||||
console.log('连接的节点ID集合:', connectedNodeIds);
|
||||
|
||||
// 如果图中只有一个节点且是主事件,也显示它
|
||||
const mainEventNode = nodes.find(n => n.extra?.is_main_event);
|
||||
console.log('主事件节点:', mainEventNode);
|
||||
|
||||
if (mainEventNode) {
|
||||
connectedNodeIds.add(String(mainEventNode.id));
|
||||
console.log('添加主事件节点ID:', String(mainEventNode.id));
|
||||
}
|
||||
|
||||
const filteredNodes = nodes.filter(node => {
|
||||
const shouldKeep = connectedNodeIds.has(String(node.id));
|
||||
console.log(`节点 ${node.name}(${node.id}[${String(node.id)}]): ${shouldKeep ? '保留' : '过滤'}`);
|
||||
return shouldKeep;
|
||||
});
|
||||
|
||||
console.log('过滤后的节点:', filteredNodes);
|
||||
return filteredNodes;
|
||||
}
|
||||
|
||||
// 计算节点连接数
|
||||
function calculateNodeConnections(nodeId, edges) {
|
||||
return edges.filter(edge =>
|
||||
String(edge.source) === String(nodeId) || String(edge.target) === String(nodeId)
|
||||
).length;
|
||||
}
|
||||
|
||||
// 计算节点大小 - 基于连接数,增大整体尺寸方便点击
|
||||
function calculateNodeSize(connectionCount, isMainEvent = false) {
|
||||
const baseSize = 35; // 增加基础大小
|
||||
const maxSize = 70; // 增加最大大小
|
||||
const minSize = 25; // 增加最小大小
|
||||
|
||||
// 主事件节点稍大一些
|
||||
if (isMainEvent) {
|
||||
return Math.min(maxSize, baseSize + 20);
|
||||
}
|
||||
|
||||
// 基于连接数计算大小,使用对数函数避免差距过大
|
||||
const sizeMultiplier = Math.log(connectionCount + 1) * 6;
|
||||
return Math.min(maxSize, Math.max(minSize, baseSize + sizeMultiplier));
|
||||
}
|
||||
|
||||
// 计算边的宽度 - 基于强度,但差距不要过大
|
||||
function calculateEdgeWidth(strength) {
|
||||
const minWidth = 1;
|
||||
const maxWidth = 4;
|
||||
if (typeof strength === 'number') {
|
||||
const normalizedStrength = strength / 100; // 假设强度是0-100
|
||||
return minWidth + (maxWidth - minWidth) * normalizedStrength;
|
||||
}
|
||||
// 如果是字符串类型的强度
|
||||
switch(strength) {
|
||||
case 'strong': return 3;
|
||||
case 'medium': return 2;
|
||||
case 'weak': return 1;
|
||||
default: return 2;
|
||||
}
|
||||
}
|
||||
|
||||
// 力导向图配置 - 完全复刻Flask版本
|
||||
function getGraphOption(data) {
|
||||
console.log('getGraphOption 被调用,输入数据:', data);
|
||||
|
||||
if (!data || !data.nodes || !data.edges || data.nodes.length === 0) {
|
||||
console.log('数据为空或无效');
|
||||
return {
|
||||
title: { text: '暂无传导链数据', left: 'center', top: 'center' },
|
||||
graphic: { type: 'text', left: 'center', top: '60%', style: { text: '当前事件暂无传导链分析数据', fontSize: 14 } }
|
||||
};
|
||||
}
|
||||
|
||||
console.log('原始节点数:', data.nodes.length);
|
||||
console.log('原始边数:', data.edges.length);
|
||||
|
||||
const filteredNodes = filterIsolatedNodes(data.nodes, data.edges);
|
||||
console.log('过滤后节点数:', filteredNodes.length);
|
||||
console.log('过滤后的节点:', filteredNodes);
|
||||
|
||||
// 进一步过滤:不显示事件类型的节点
|
||||
const nonEventNodes = filteredNodes.filter(node => node.extra?.node_type !== 'event');
|
||||
console.log('排除事件节点后:', nonEventNodes.length);
|
||||
|
||||
if (nonEventNodes.length === 0) {
|
||||
console.log('过滤后没有有效节点');
|
||||
return {
|
||||
title: { text: '暂无有效节点数据', left: 'center', top: 'center' },
|
||||
graphic: { type: 'text', left: 'center', top: '60%', style: { text: '当前事件的传导链节点均为孤立节点', fontSize: 14 } }
|
||||
};
|
||||
}
|
||||
|
||||
// 生成节点类别(排除事件类型)
|
||||
const categories = [...new Set(nonEventNodes.map(n => n.extra?.node_type || 'other'))].map(type => ({
|
||||
name: NODE_TYPE_LABELS[type] || type,
|
||||
itemStyle: { color: NODE_STYLES[type]?.color || NODE_STYLES.other.color }
|
||||
}));
|
||||
|
||||
console.log('节点类别:', categories);
|
||||
|
||||
// 构建图表节点数据 - 完全复刻Flask版本样式(排除事件节点)
|
||||
const chartNodes = nonEventNodes.map(node => {
|
||||
const nodeType = node.extra?.node_type || 'other';
|
||||
const nodeStyle = NODE_STYLES[nodeType] || NODE_STYLES['other'];
|
||||
const connectionCount = calculateNodeConnections(node.id, data.edges);
|
||||
|
||||
console.log(`节点 ${node.name} (${node.id}): 类型=${nodeType}, 连接数=${connectionCount}`);
|
||||
|
||||
return {
|
||||
id: String(node.id),
|
||||
name: node.name,
|
||||
value: node.value,
|
||||
symbol: nodeStyle.symbol,
|
||||
symbolSize: calculateNodeSize(connectionCount, node.extra?.is_main_event),
|
||||
category: NODE_TYPE_LABELS[nodeType] || nodeType,
|
||||
itemStyle: {
|
||||
color: nodeStyle.color,
|
||||
borderColor: node.extra?.is_main_event ? '#ffd700' : '#fff',
|
||||
borderWidth: node.extra?.is_main_event ? 3 : 1,
|
||||
shadowBlur: 5,
|
||||
shadowColor: 'rgba(0,0,0,0.2)'
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
formatter: '{b}',
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
padding: [5, 8], // 增加标签内边距,扩大点击区域
|
||||
backgroundColor: 'rgba(255,255,255,0.8)',
|
||||
borderRadius: 4,
|
||||
borderColor: '#ddd',
|
||||
borderWidth: 1
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'none', // 减少高亮敏感性,但保持点击功能
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: 13,
|
||||
fontWeight: 'bold'
|
||||
},
|
||||
itemStyle: {
|
||||
borderWidth: 3,
|
||||
shadowBlur: 10
|
||||
}
|
||||
},
|
||||
// 扩大节点的实际点击区域
|
||||
select: {
|
||||
itemStyle: {
|
||||
borderColor: '#007bff',
|
||||
borderWidth: 3
|
||||
}
|
||||
},
|
||||
extra: node.extra // 包含所有额外信息
|
||||
};
|
||||
});
|
||||
|
||||
// 构建图表边数据 - 完全复刻Flask版本样式
|
||||
const chartEdges = data.edges.map(edge => ({
|
||||
source: String(edge.source),
|
||||
target: String(edge.target),
|
||||
value: edge.value,
|
||||
lineStyle: {
|
||||
color: IMPACT_COLORS[edge.extra?.direction] || IMPACT_COLORS.neutral,
|
||||
width: calculateEdgeWidth(edge.extra?.strength || 50),
|
||||
curveness: edge.extra?.is_circular ? 0.3 : 0,
|
||||
type: edge.extra?.is_circular ? 'dashed' : 'solid', // 关键:循环边用虚线
|
||||
},
|
||||
label: {
|
||||
show: false // 通常边的标签会很乱,默认关闭
|
||||
},
|
||||
symbol: ['none', 'arrow'],
|
||||
symbolSize: [0, 10],
|
||||
extra: edge.extra // 包含所有额外信息
|
||||
}));
|
||||
|
||||
// 完全复刻Flask版本的图表选项
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params) => {
|
||||
if (params.dataType === 'node') {
|
||||
const { name, extra } = params.data;
|
||||
const connectionCount = calculateNodeConnections(params.data.id, data.edges);
|
||||
return `<div style="text-align: left;">` +
|
||||
`<b style="font-size: 14px;">${name}</b><br/>` +
|
||||
`类型: ${NODE_TYPE_LABELS[extra?.node_type] || extra?.node_type}<br/>` +
|
||||
`重要性: ${extra?.importance_score}<br/>` +
|
||||
`连接数: ${connectionCount}` +
|
||||
`${extra?.is_main_event ? '<br/><span style="color: #ffd700;">★ 主事件</span>' : ''}` +
|
||||
`<br/><span style="color: #007bff; font-weight: bold;">🖱️ 点击查看详细信息</span>` +
|
||||
`</div>`;
|
||||
}
|
||||
if (params.dataType === 'edge') {
|
||||
const { source, target, extra } = params.data;
|
||||
const sourceNode = data.nodes.find(n => String(n.id) === String(source));
|
||||
const targetNode = data.nodes.find(n => String(n.id) === String(target));
|
||||
if (!sourceNode || !targetNode) return '加载中...';
|
||||
return `<div style="text-align: left;">` +
|
||||
`<b>${sourceNode.name} → ${targetNode.name}</b><br/>` +
|
||||
`类型: ${extra?.transmission_type}<br/>` +
|
||||
`方向: ${extra?.direction}<br/>` +
|
||||
`强度: ${extra?.strength}` +
|
||||
`${extra?.is_circular ? '<br/>🔄 循环效应' : ''}` +
|
||||
`</div>`;
|
||||
}
|
||||
return params.name;
|
||||
}
|
||||
},
|
||||
// 隐藏ECharts默认图例,使用自定义图例
|
||||
legend: {
|
||||
show: false
|
||||
},
|
||||
series: [{
|
||||
type: 'graph',
|
||||
layout: 'force',
|
||||
data: chartNodes,
|
||||
links: chartEdges,
|
||||
categories: categories,
|
||||
roam: true,
|
||||
draggable: true, // 启用节点拖拽
|
||||
focusNodeAdjacency: false, // 降低敏感性
|
||||
force: {
|
||||
repulsion: 250, // 增加节点间距,给点击留更多空间
|
||||
edgeLength: [100, 180], // 增加边长,扩大节点周围空间
|
||||
gravity: 0.05, // 降低重力
|
||||
layoutAnimation: false, // 关闭布局动画降低敏感性
|
||||
friction: 0.6 // 增加摩擦力,减少移动
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'none', // 降低高亮敏感性
|
||||
scale: 1.1 // 轻微放大而不是强烈高亮
|
||||
},
|
||||
lineStyle: {
|
||||
opacity: 0.6,
|
||||
curveness: 0.1
|
||||
},
|
||||
label: {
|
||||
position: 'right',
|
||||
formatter: '{b}',
|
||||
show: true,
|
||||
distance: 10, // 标签距离节点更远一些
|
||||
fontSize: 12
|
||||
},
|
||||
// 增加整体的点击敏感性
|
||||
silent: false,
|
||||
triggerLineEvent: true,
|
||||
triggerEvent: true
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
// 桑基图配置
|
||||
function getSankeyOption(data) {
|
||||
if (!data || !data.nodes || !data.links) {
|
||||
return { title: { text: '暂无桑基图数据', left: 'center', top: 'center' } };
|
||||
}
|
||||
|
||||
return {
|
||||
title: { text: '事件影响力传导流向', left: 'center', top: 10 },
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
triggerOn: 'mousemove',
|
||||
formatter: (params) => {
|
||||
if (params.dataType === 'node') {
|
||||
return `<b>${params.name}</b><br/>类型: ${params.data.type || 'N/A'}<br/>层级: ${params.data.level || 'N/A'}<br/>点击查看详情`;
|
||||
}
|
||||
return params.name;
|
||||
}
|
||||
},
|
||||
series: [{
|
||||
type: 'sankey',
|
||||
layout: 'none',
|
||||
emphasis: { focus: 'adjacency' },
|
||||
nodeAlign: 'justify',
|
||||
layoutIterations: 0,
|
||||
data: data.nodes.map(node => ({
|
||||
name: node.name,
|
||||
type: node.type,
|
||||
level: node.level,
|
||||
itemStyle: {
|
||||
color: node.color,
|
||||
borderColor: node.level === 0 ? '#ffd700' : 'transparent',
|
||||
borderWidth: node.level === 0 ? 3 : 0
|
||||
}
|
||||
})),
|
||||
links: data.links.map(link => ({
|
||||
source: data.nodes[link.source]?.name,
|
||||
target: data.nodes[link.target]?.name,
|
||||
value: link.value,
|
||||
lineStyle: { color: 'source', opacity: 0.6, curveness: 0.5 }
|
||||
})),
|
||||
label: { color: 'rgba(0,0,0,0.7)', fontSize: 12 }
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
const TransmissionChainAnalysis = ({ eventId }) => {
|
||||
// 状态管理
|
||||
const [graphData, setGraphData] = useState(null);
|
||||
const [sankeyData, setSankeyData] = useState(null);
|
||||
const [selectedNode, setSelectedNode] = useState(null);
|
||||
const [nodeDetail, setNodeDetail] = useState(null); // 新增:存储API获取的详细节点信息
|
||||
const [transmissionPath, setTransmissionPath] = useState([]);
|
||||
const [viewMode, setViewMode] = useState('graph');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [chartReady, setChartReady] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false); // 新增:控制弹窗显示
|
||||
const [stats, setStats] = useState({
|
||||
totalNodes: 0,
|
||||
involvedIndustries: 0,
|
||||
relatedCompanies: 0,
|
||||
positiveImpact: 0,
|
||||
negativeImpact: 0,
|
||||
circularEffect: 0
|
||||
});
|
||||
|
||||
const containerRef = useRef(null);
|
||||
const modalBgColor = useColorModeValue('white', 'gray.800');
|
||||
const modalBorderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
|
||||
// 延迟初始化图表,确保DOM容器准备好
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setChartReady(true);
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// 计算统计信息
|
||||
useEffect(() => {
|
||||
if (graphData && graphData.nodes) {
|
||||
setStats({
|
||||
totalNodes: graphData.nodes.length,
|
||||
involvedIndustries: graphData.nodes.filter(n => n.extra?.node_type === 'industry').length,
|
||||
relatedCompanies: graphData.nodes.filter(n => n.extra?.node_type === 'company').length,
|
||||
positiveImpact: graphData.edges?.filter(e => e.extra?.direction === 'positive').length || 0,
|
||||
negativeImpact: graphData.edges?.filter(e => e.extra?.direction === 'negative').length || 0,
|
||||
circularEffect: graphData.edges?.filter(e => e.extra?.is_circular).length || 0
|
||||
});
|
||||
}
|
||||
}, [graphData]);
|
||||
|
||||
// 加载数据
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
console.log('开始加载传导链数据,eventId:', eventId);
|
||||
const [graphRes, sankeyRes] = await Promise.all([
|
||||
eventService.getTransmissionChainAnalysis(eventId),
|
||||
eventService.getSankeyData(eventId)
|
||||
]);
|
||||
|
||||
console.log('传导链数据API响应:', graphRes);
|
||||
console.log('桑基图数据API响应:', sankeyRes);
|
||||
|
||||
if (graphRes.success && graphRes.data) {
|
||||
console.log('传导链节点数据:', graphRes.data.nodes);
|
||||
console.log('传导链边数据:', graphRes.data.edges);
|
||||
setGraphData(graphRes.data);
|
||||
} else {
|
||||
console.log('传导链数据加载失败:', graphRes);
|
||||
setGraphData(null);
|
||||
}
|
||||
|
||||
if (sankeyRes.success && sankeyRes.data) {
|
||||
console.log('桑基图数据:', sankeyRes.data);
|
||||
setSankeyData(sankeyRes.data);
|
||||
} else {
|
||||
console.log('桑基图数据加载失败:', sankeyRes);
|
||||
setSankeyData(null);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('传导链数据加载异常:', e);
|
||||
setError('加载传导链数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (eventId) {
|
||||
fetchData();
|
||||
}
|
||||
}, [eventId]);
|
||||
|
||||
// BFS路径查找 - 完全复刻Flask版本
|
||||
function findPath(nodes, edges, fromId, toId) {
|
||||
const adj = {};
|
||||
edges.forEach(e => {
|
||||
if (!adj[e.source]) adj[e.source] = [];
|
||||
adj[e.source].push(e.target);
|
||||
});
|
||||
|
||||
const queue = [[fromId, [nodes.find(n => String(n.id) === String(fromId))]]];
|
||||
const visited = new Set([fromId]);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const [current, path] = queue.shift();
|
||||
if (String(current) === String(toId)) {
|
||||
return path;
|
||||
}
|
||||
|
||||
(adj[current] || []).forEach(next => {
|
||||
if (!visited.has(next)) {
|
||||
visited.add(next);
|
||||
const nextNode = nodes.find(n => String(n.id) === String(next));
|
||||
if (nextNode) {
|
||||
queue.push([next, [...path, nextNode]]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// 获取节点详情 - 完全复刻Flask版本API调用
|
||||
async function getChainNodeDetail(nodeId) {
|
||||
try {
|
||||
const response = await fetch(`/api/events/${eventId}/chain-node/${nodeId}`);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
} else {
|
||||
console.error('获取节点详情失败:', result.message);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('API调用异常:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 力导向图节点点击事件
|
||||
const handleGraphNodeClick = async (params) => {
|
||||
console.log('点击事件详情:', params);
|
||||
|
||||
// 处理节点点击(包括节点本体和标签)
|
||||
if ((params.dataType === 'node' || params.componentType === 'series') && params.data && params.data.id) {
|
||||
console.log('点击图表节点:', params.data.id, 'dataType:', params.dataType, 'componentType:', params.componentType);
|
||||
|
||||
// 获取基本节点信息
|
||||
const clickedNode = graphData.nodes.find(n => String(n.id) === String(params.data.id));
|
||||
if (clickedNode) {
|
||||
setSelectedNode(clickedNode);
|
||||
|
||||
// 计算传导路径
|
||||
const mainEventNode = graphData?.nodes?.find(n => n.extra?.is_main_event);
|
||||
if (mainEventNode && String(clickedNode.id) !== String(mainEventNode.id)) {
|
||||
const path = findPath(graphData.nodes, graphData.edges, mainEventNode.id, clickedNode.id);
|
||||
setTransmissionPath(path);
|
||||
} else {
|
||||
setTransmissionPath([]);
|
||||
}
|
||||
|
||||
// 获取详细节点信息(包括parents和children)
|
||||
console.log('开始获取节点详情,节点ID:', params.data.id);
|
||||
const detail = await getChainNodeDetail(params.data.id);
|
||||
console.log('获取到的节点详情:', detail);
|
||||
setNodeDetail(detail);
|
||||
|
||||
// 打开弹窗
|
||||
setIsModalOpen(true);
|
||||
}
|
||||
}
|
||||
// 如果点击的是空白区域,也尝试查找最近的节点
|
||||
else if (params.componentType === 'series' && !params.data) {
|
||||
console.log('点击了图表空白区域');
|
||||
// 这里可以添加点击空白区域的处理逻辑
|
||||
}
|
||||
};
|
||||
|
||||
// 桑基图节点点击事件
|
||||
const handleSankeyNodeClick = async (params) => {
|
||||
if (params.dataType === 'node' && params.data && params.data.name) {
|
||||
console.log('点击桑基图节点:', params.data.name);
|
||||
|
||||
// 通过名称在原始数据中查找对应的节点
|
||||
if (graphData && graphData.nodes) {
|
||||
const clickedNode = graphData.nodes.find(n => n.name === params.data.name);
|
||||
if (clickedNode) {
|
||||
console.log('找到对应节点:', clickedNode);
|
||||
setSelectedNode(clickedNode);
|
||||
|
||||
// 计算传导路径
|
||||
const mainEventNode = graphData?.nodes?.find(n => n.extra?.is_main_event);
|
||||
if (mainEventNode && String(clickedNode.id) !== String(mainEventNode.id)) {
|
||||
const path = findPath(graphData.nodes, graphData.edges, mainEventNode.id, clickedNode.id);
|
||||
setTransmissionPath(path);
|
||||
} else {
|
||||
setTransmissionPath([]);
|
||||
}
|
||||
|
||||
// 获取详细节点信息(包括parents和children)
|
||||
console.log('开始获取桑基图节点详情,节点ID:', clickedNode.id);
|
||||
const detail = await getChainNodeDetail(clickedNode.id);
|
||||
console.log('获取到的桑基图节点详情:', detail);
|
||||
setNodeDetail(detail);
|
||||
|
||||
// 打开弹窗
|
||||
setIsModalOpen(true);
|
||||
} else {
|
||||
console.log('未找到对应的节点数据');
|
||||
// 创建一个临时节点信息用于显示
|
||||
const tempNode = {
|
||||
id: params.data.name,
|
||||
name: params.data.name,
|
||||
extra: {
|
||||
node_type: params.data.type,
|
||||
description: `桑基图节点 - ${params.data.name}`,
|
||||
importance_score: 'N/A'
|
||||
}
|
||||
};
|
||||
setSelectedNode(tempNode);
|
||||
setTransmissionPath([]);
|
||||
setNodeDetail(null);
|
||||
|
||||
// 打开弹窗
|
||||
setIsModalOpen(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box p={6}>
|
||||
{/* 统计信息条 */}
|
||||
<Box mb={4}>
|
||||
<HStack spacing={6} wrap="wrap">
|
||||
<Stat>
|
||||
<StatLabel>总节点数</StatLabel>
|
||||
<StatNumber>{stats.totalNodes}</StatNumber>
|
||||
</Stat>
|
||||
<Stat>
|
||||
<StatLabel>涉及行业</StatLabel>
|
||||
<StatNumber>{stats.involvedIndustries}</StatNumber>
|
||||
</Stat>
|
||||
<Stat>
|
||||
<StatLabel>相关公司</StatLabel>
|
||||
<StatNumber>{stats.relatedCompanies}</StatNumber>
|
||||
</Stat>
|
||||
<Stat>
|
||||
<StatLabel>正向影响</StatLabel>
|
||||
<StatNumber color="green.500">{stats.positiveImpact}</StatNumber>
|
||||
</Stat>
|
||||
<Stat>
|
||||
<StatLabel>负向影响</StatLabel>
|
||||
<StatNumber color="red.500">{stats.negativeImpact}</StatNumber>
|
||||
</Stat>
|
||||
<Stat>
|
||||
<StatLabel>循环效应</StatLabel>
|
||||
<StatNumber color="purple.500">{stats.circularEffect}</StatNumber>
|
||||
</Stat>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* 自定义图例 */}
|
||||
<Box mb={4}>
|
||||
<HStack spacing={4} wrap="wrap">
|
||||
{Object.entries(NODE_STYLES).map(([type, style]) => (
|
||||
<Tag key={type} size="md">
|
||||
<Box w={3} h={3} bg={style.color} borderRadius="sm" mr={2} />
|
||||
{NODE_TYPE_LABELS[type] || type}
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* 视图切换按钮 */}
|
||||
<Flex mb={4} gap={2}>
|
||||
<Button
|
||||
colorScheme={viewMode === 'graph' ? 'blue' : 'gray'}
|
||||
onClick={() => setViewMode('graph')}
|
||||
size="sm"
|
||||
>
|
||||
力导向图
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme={viewMode === 'sankey' ? 'blue' : 'gray'}
|
||||
onClick={() => setViewMode('sankey')}
|
||||
size="sm"
|
||||
>
|
||||
桑基图
|
||||
</Button>
|
||||
|
||||
</Flex>
|
||||
|
||||
{loading && (
|
||||
<Flex justify="center" align="center" h="400px">
|
||||
<Spinner size="xl" />
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert status="error" mb={4}>
|
||||
<AlertIcon />
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<Box>
|
||||
{/* 提示信息 */}
|
||||
<Alert status="info" mb={4} borderRadius="md">
|
||||
<AlertIcon />
|
||||
<Text fontSize="sm">
|
||||
<Icon as={ViewIcon} mr={2} />
|
||||
点击图表中的节点可以查看详细信息
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
{/* 图表容器 */}
|
||||
<Box
|
||||
h={viewMode === 'sankey' ? "600px" : "700px"}
|
||||
border="1px solid"
|
||||
borderColor="gray.300"
|
||||
borderRadius="lg"
|
||||
boxShadow="md"
|
||||
bg="white"
|
||||
p={4}
|
||||
ref={containerRef}
|
||||
>
|
||||
{chartReady && (
|
||||
<>
|
||||
{viewMode === 'graph' ? (
|
||||
<ReactECharts
|
||||
option={graphData ? getGraphOption(graphData) : {}}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
onEvents={{
|
||||
click: handleGraphNodeClick,
|
||||
// 添加更多事件以提高点击敏感性
|
||||
mouseover: (params) => {
|
||||
console.log('鼠标悬停:', params);
|
||||
// 可以在这里添加悬停效果
|
||||
},
|
||||
mouseout: (params) => {
|
||||
// 鼠标离开的处理
|
||||
}
|
||||
}}
|
||||
opts={{
|
||||
renderer: 'canvas',
|
||||
devicePixelRatio: window.devicePixelRatio || 1
|
||||
}}
|
||||
lazyUpdate={true}
|
||||
notMerge={false}
|
||||
shouldSetOption={(prevProps, props) => {
|
||||
// 减少不必要的重新渲染
|
||||
return JSON.stringify(prevProps.option) !== JSON.stringify(props.option);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ReactECharts
|
||||
option={sankeyData ? getSankeyOption(sankeyData) : {}}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
onEvents={{
|
||||
click: handleSankeyNodeClick,
|
||||
// 添加更多事件以提高点击敏感性
|
||||
mouseover: (params) => {
|
||||
console.log('桑基图鼠标悬停:', params);
|
||||
// 可以在这里添加悬停效果
|
||||
},
|
||||
mouseout: (params) => {
|
||||
// 鼠标离开的处理
|
||||
}
|
||||
}}
|
||||
opts={{
|
||||
renderer: 'canvas',
|
||||
devicePixelRatio: window.devicePixelRatio || 1
|
||||
}}
|
||||
lazyUpdate={true}
|
||||
notMerge={false}
|
||||
shouldSetOption={(prevProps, props) => {
|
||||
// 减少不必要的重新渲染
|
||||
return JSON.stringify(prevProps.option) !== JSON.stringify(props.option);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 节点详情弹窗 */}
|
||||
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent maxH="80vh" bg={modalBgColor}>
|
||||
<ModalHeader borderBottom="1px solid" borderColor={modalBorderColor}>
|
||||
<HStack justify="space-between">
|
||||
<Text>{selectedNode ? '节点详情' : '传导链分析'}</Text>
|
||||
{selectedNode && (
|
||||
<Badge colorScheme="blue">{NODE_TYPE_LABELS[selectedNode.extra?.node_type] || selectedNode.extra?.node_type}</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
|
||||
<ModalBody overflowY="auto">
|
||||
{selectedNode ? (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{/* 节点基本信息 */}
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={2} color="blue.600">基本信息</Text>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<HStack align="center">
|
||||
<Text fontSize="sm"><strong>名称:</strong> {selectedNode.name}</Text>
|
||||
{selectedNode.extra?.is_main_event && (
|
||||
<Badge colorScheme="red" variant="solid" size="sm">主事件</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<Text fontSize="sm"><strong>类型:</strong> {NODE_TYPE_LABELS[selectedNode.extra?.node_type] || selectedNode.extra?.node_type}</Text>
|
||||
<Text fontSize="sm"><strong>重要性评分:</strong> {selectedNode.extra?.importance_score || 'N/A'}</Text>
|
||||
|
||||
{selectedNode.extra?.stock_code && (
|
||||
<Text fontSize="sm"><strong>股票代码:</strong>
|
||||
<Badge colorScheme="cyan" ml={2}>{selectedNode.extra.stock_code}</Badge>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{nodeDetail ? (
|
||||
<HStack spacing={4}>
|
||||
<Badge colorScheme="gray">
|
||||
总连接: {nodeDetail.node.total_connections || 0}
|
||||
</Badge>
|
||||
<Badge colorScheme="green">
|
||||
来源: {nodeDetail.node.incoming_connections || 0}
|
||||
</Badge>
|
||||
<Badge colorScheme="orange">
|
||||
目标: {nodeDetail.node.outgoing_connections || 0}
|
||||
</Badge>
|
||||
</HStack>
|
||||
) : (
|
||||
<HStack spacing={4}>
|
||||
<Badge colorScheme="gray">
|
||||
总连接: {graphData ? graphData.edges.filter(e =>
|
||||
String(e.source) === String(selectedNode.id) || String(e.target) === String(selectedNode.id)
|
||||
).length : 0}
|
||||
</Badge>
|
||||
<Badge colorScheme="green">
|
||||
来源: {graphData ? graphData.edges.filter(e =>
|
||||
String(e.target) === String(selectedNode.id)
|
||||
).length : 0}
|
||||
</Badge>
|
||||
<Badge colorScheme="orange">
|
||||
目标: {graphData ? graphData.edges.filter(e =>
|
||||
String(e.source) === String(selectedNode.id)
|
||||
).length : 0}
|
||||
</Badge>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* 节点描述 */}
|
||||
{selectedNode.extra?.description && (
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={2} color="blue.600">描述</Text>
|
||||
<Box
|
||||
fontSize="sm"
|
||||
color="gray.600"
|
||||
borderLeft="3px solid"
|
||||
borderColor="blue.200"
|
||||
pl={3}
|
||||
bg="gray.50"
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
fontStyle="italic"
|
||||
>
|
||||
{selectedNode.extra.description}(AI合成)
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 传导路径 */}
|
||||
{transmissionPath && transmissionPath.length > 0 && (
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={2} color="blue.600">传导路径</Text>
|
||||
<Box bg="gray.50" p={3} borderRadius="md" borderLeft="4px solid" borderColor="blue.500">
|
||||
<List spacing={1}>
|
||||
{transmissionPath.map((node, index) => (
|
||||
<ListItem key={index} fontSize="sm">
|
||||
{index === 0 && '🚀 '}
|
||||
{index === transmissionPath.length - 1 && '🎯 '}
|
||||
{index > 0 && index < transmissionPath.length - 1 && '➡️ '}
|
||||
{node.name}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 影响来源 */}
|
||||
{(() => {
|
||||
const sourcesFromAPI = nodeDetail && nodeDetail.parents && nodeDetail.parents.length > 0;
|
||||
const sourcesFromGraph = graphData && graphData.edges.filter(e =>
|
||||
String(e.target) === String(selectedNode.id)
|
||||
).length > 0;
|
||||
|
||||
if (sourcesFromAPI) {
|
||||
return (
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={2} color="blue.600">
|
||||
影响来源 ({nodeDetail.parents.length})(AI合成)
|
||||
</Text>
|
||||
<List spacing={2}>
|
||||
{nodeDetail.parents.map((parent, index) => (
|
||||
<ListItem key={index} p={2} bg="gray.50" borderRadius="md" borderLeft="3px solid" borderColor="green.300">
|
||||
<HStack justify="space-between" align="flex-start">
|
||||
<VStack align="stretch" spacing={1} flex={1}>
|
||||
<Text fontWeight="bold" fontSize="sm">{parent.name}</Text>
|
||||
{parent.transmission_mechanism && (
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
机制: {parent.transmission_mechanism}(AI合成)
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
<HStack spacing={2}>
|
||||
<Badge colorScheme={parent.direction === 'positive' ? 'green' : parent.direction === 'negative' ? 'red' : 'gray'} size="sm">
|
||||
{parent.direction}
|
||||
</Badge>
|
||||
{parent.is_circular && (
|
||||
<Badge colorScheme="purple" size="sm">🔄 循环</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
</HStack>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{/* 影响输出 */}
|
||||
{(() => {
|
||||
const targetsFromAPI = nodeDetail && nodeDetail.children && nodeDetail.children.length > 0;
|
||||
|
||||
if (targetsFromAPI) {
|
||||
return (
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={2} color="blue.600">
|
||||
影响输出 ({nodeDetail.children.length})
|
||||
</Text>
|
||||
<List spacing={2}>
|
||||
{nodeDetail.children.map((child, index) => (
|
||||
<ListItem key={index} p={2} bg="gray.50" borderRadius="md" borderLeft="3px solid" borderColor="orange.300">
|
||||
<HStack justify="space-between" align="flex-start">
|
||||
<VStack align="stretch" spacing={1} flex={1}>
|
||||
<Text fontWeight="bold" fontSize="sm">{child.name}</Text>
|
||||
{child.transmission_mechanism && (
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
机制: {child.transmission_mechanism}(AI合成)
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
<HStack spacing={2}>
|
||||
<Badge colorScheme={child.direction === 'positive' ? 'green' : child.direction === 'negative' ? 'red' : 'gray'} size="sm">
|
||||
{child.direction}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</VStack>
|
||||
) : (
|
||||
<VStack align="center" justify="center" h="300px" spacing={4}>
|
||||
<Text fontSize="lg" color="gray.500">传导链分析</Text>
|
||||
<Text fontSize="sm" color="gray.400" textAlign="center">
|
||||
点击图表中的节点查看详细信息
|
||||
</Text>
|
||||
</VStack>
|
||||
)}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter borderTop="1px solid" borderColor={modalBorderColor}>
|
||||
<Button onClick={() => setIsModalOpen(false)}>关闭</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TransmissionChainAnalysis;
|
||||
856
src/views/EventDetail/index.js
Normal file
856
src/views/EventDetail/index.js
Normal file
@@ -0,0 +1,856 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
VStack,
|
||||
HStack,
|
||||
Spinner,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
Flex,
|
||||
useColorModeValue,
|
||||
Grid,
|
||||
GridItem,
|
||||
Icon,
|
||||
Text,
|
||||
Badge,
|
||||
Divider,
|
||||
useDisclosure,
|
||||
Button,
|
||||
Heading,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
SimpleGrid,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
Textarea,
|
||||
Avatar,
|
||||
IconButton,
|
||||
Input,
|
||||
Collapse,
|
||||
Center,
|
||||
useToast,
|
||||
Skeleton,
|
||||
Link,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiLock } from 'react-icons/fi';
|
||||
import {
|
||||
FiTrendingUp,
|
||||
FiActivity,
|
||||
FiMessageSquare,
|
||||
FiClock,
|
||||
FiBarChart2,
|
||||
FiLink,
|
||||
FiZap,
|
||||
FiGlobe,
|
||||
FiHeart,
|
||||
FiTrash2,
|
||||
FiChevronDown,
|
||||
FiChevronUp,
|
||||
} from 'react-icons/fi';
|
||||
import { FaHeart, FaRegHeart, FaComment } from 'react-icons/fa';
|
||||
import { format } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
|
||||
// 导入新建的业务组件
|
||||
import EventHeader from './components/EventHeader';
|
||||
import RelatedConcepts from './components/RelatedConcepts';
|
||||
import HistoricalEvents from './components/HistoricalEvents';
|
||||
import RelatedStocks from './components/RelatedStocks';
|
||||
import HomeNavbar from '../../components/Navbars/HomeNavbar';
|
||||
import SubscriptionUpgradeModal from '../../components/SubscriptionUpgradeModal';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useSubscription } from '../../hooks/useSubscription';
|
||||
import TransmissionChainAnalysis from './components/TransmissionChainAnalysis';
|
||||
|
||||
// 导入你的 Flask API 服务
|
||||
import { eventService } from '../../services/eventService';
|
||||
import { debugEventService } from '../../utils/debugEventService';
|
||||
|
||||
// 临时调试代码 - 生产环境测试后请删除
|
||||
if (typeof window !== 'undefined') {
|
||||
console.log('EventDetail - 调试 eventService');
|
||||
debugEventService();
|
||||
}
|
||||
|
||||
// 统计卡片组件 - 更简洁的设计
|
||||
const StatCard = ({ icon, label, value, color }) => {
|
||||
const bg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const iconColor = useColorModeValue(`${color}.500`, `${color}.300`);
|
||||
|
||||
return (
|
||||
<Stat
|
||||
p={6}
|
||||
bg={bg}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
_hover={{ shadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<HStack spacing={3} align="flex-start">
|
||||
<Icon as={icon} boxSize={5} color={iconColor} mt={1} />
|
||||
<Box flex={1}>
|
||||
<StatLabel color="gray.500" fontSize="sm">{label}</StatLabel>
|
||||
<StatNumber fontSize="2xl" color={iconColor}>{value}</StatNumber>
|
||||
</Box>
|
||||
</HStack>
|
||||
</Stat>
|
||||
);
|
||||
};
|
||||
|
||||
// 帖子组件
|
||||
const PostItem = ({ post, onRefresh }) => {
|
||||
const [showComments, setShowComments] = useState(false);
|
||||
const [comments, setComments] = useState([]);
|
||||
const [newComment, setNewComment] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [liked, setLiked] = useState(post.liked || false);
|
||||
const [likesCount, setLikesCount] = useState(post.likes_count || 0);
|
||||
const toast = useToast();
|
||||
const { user } = useAuth();
|
||||
const bg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
const loadComments = async () => {
|
||||
if (!showComments) {
|
||||
setShowComments(true);
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await eventService.getPostComments(post.id);
|
||||
if (result.success) {
|
||||
setComments(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load comments:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
} else {
|
||||
setShowComments(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLike = async () => {
|
||||
try {
|
||||
const result = await eventService.likePost(post.id);
|
||||
if (result.success) {
|
||||
setLiked(result.liked);
|
||||
setLikesCount(result.likes_count);
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '操作失败',
|
||||
status: 'error',
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddComment = async () => {
|
||||
if (!newComment.trim()) return;
|
||||
|
||||
try {
|
||||
const result = await eventService.addPostComment(post.id, {
|
||||
content: newComment,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: '评论发表成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
setNewComment('');
|
||||
// 重新加载评论
|
||||
const commentsResult = await eventService.getPostComments(post.id);
|
||||
if (commentsResult.success) {
|
||||
setComments(commentsResult.data);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '评论失败',
|
||||
status: 'error',
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (window.confirm('确定要删除这个帖子吗?')) {
|
||||
try {
|
||||
const result = await eventService.deletePost(post.id);
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
onRefresh();
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '删除失败',
|
||||
status: 'error',
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={bg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
p={6}
|
||||
mb={4}
|
||||
>
|
||||
{/* 帖子头部 */}
|
||||
<HStack justify="space-between" mb={4}>
|
||||
<HStack spacing={3}>
|
||||
<Avatar
|
||||
size="sm"
|
||||
name={post.user?.username}
|
||||
src={post.user?.avatar_url}
|
||||
/>
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontWeight="medium">{post.user?.username || '匿名用户'}</Text>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
{format(new Date(post.created_at), 'yyyy-MM-dd HH:mm', { locale: zhCN })}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<IconButton
|
||||
icon={<FiTrash2 />}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{/* 帖子内容 */}
|
||||
{post.title && (
|
||||
<Heading size="md" mb={2}>
|
||||
{post.title}
|
||||
</Heading>
|
||||
)}
|
||||
<Text mb={4} whiteSpace="pre-wrap">
|
||||
{post.content}
|
||||
</Text>
|
||||
|
||||
{/* 操作栏 */}
|
||||
<HStack spacing={4}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leftIcon={liked ? <FaHeart /> : <FaRegHeart />}
|
||||
color={liked ? 'red.500' : 'gray.500'}
|
||||
onClick={handleLike}
|
||||
>
|
||||
{likesCount}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leftIcon={<FaComment />}
|
||||
rightIcon={showComments ? <FiChevronUp /> : <FiChevronDown />}
|
||||
onClick={loadComments}
|
||||
>
|
||||
{post.comments_count || 0} 评论
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{/* 评论区 */}
|
||||
<Collapse in={showComments} animateOpacity>
|
||||
<Box mt={4} pt={4} borderTopWidth="1px" borderColor={borderColor}>
|
||||
{/* 评论输入 */}
|
||||
<HStack mb={4}>
|
||||
<Textarea
|
||||
placeholder="写下你的评论..."
|
||||
size="sm"
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
size="sm"
|
||||
onClick={handleAddComment}
|
||||
isDisabled={!newComment.trim()}
|
||||
>
|
||||
评论
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{/* 评论列表 */}
|
||||
{isLoading ? (
|
||||
<Center py={4}>
|
||||
<Spinner size="sm" />
|
||||
</Center>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{comments.map((comment) => (
|
||||
<Box key={comment.id} pl={4} borderLeftWidth="2px" borderColor="gray.200">
|
||||
<HStack mb={1}>
|
||||
<Text fontWeight="medium" fontSize="sm">
|
||||
{comment.user?.username || '匿名用户'}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{format(new Date(comment.created_at), 'MM-dd HH:mm')}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text fontSize="sm">{comment.content}</Text>
|
||||
</Box>
|
||||
))}
|
||||
{comments.length === 0 && (
|
||||
<Text color="gray.500" textAlign="center" py={2}>
|
||||
暂无评论
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const EventDetail = () => {
|
||||
const { eventId } = useParams();
|
||||
const location = useLocation();
|
||||
const bgColor = useColorModeValue('gray.50', 'gray.900');
|
||||
const toast = useToast();
|
||||
|
||||
// 用户认证和权限控制
|
||||
const { user } = useAuth();
|
||||
const { hasFeatureAccess, getUpgradeRecommendation } = useSubscription();
|
||||
|
||||
// State hooks
|
||||
const [eventData, setEventData] = useState(null);
|
||||
const [relatedStocks, setRelatedStocks] = useState([]);
|
||||
const [relatedConcepts, setRelatedConcepts] = useState([]);
|
||||
const [historicalEvents, setHistoricalEvents] = useState([]);
|
||||
const [posts, setPosts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [postsLoading, setPostsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [newPostContent, setNewPostContent] = useState('');
|
||||
const [newPostTitle, setNewPostTitle] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [upgradeModal, setUpgradeModal] = useState({ isOpen: false, feature: '功能', required: 'pro' });
|
||||
|
||||
// 从URL路径中提取eventId(处理多种URL格式)
|
||||
const getEventIdFromPath = () => {
|
||||
const pathParts = location.pathname.split('/');
|
||||
const lastPart = pathParts[pathParts.length - 1];
|
||||
const secondLastPart = pathParts[pathParts.length - 2];
|
||||
|
||||
if (!isNaN(lastPart) && lastPart) {
|
||||
return lastPart;
|
||||
}
|
||||
if (!isNaN(secondLastPart) && secondLastPart) {
|
||||
return secondLastPart;
|
||||
}
|
||||
return eventId;
|
||||
};
|
||||
|
||||
const actualEventId = getEventIdFromPath();
|
||||
|
||||
const loadEventData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 加载基本事件信息(免费用户也可以访问)
|
||||
const eventResponse = await eventService.getEventDetail(actualEventId);
|
||||
setEventData(eventResponse.data);
|
||||
|
||||
// 总是尝试加载相关股票(权限在组件内部检查)
|
||||
try {
|
||||
const stocksResponse = await eventService.getRelatedStocks(actualEventId);
|
||||
setRelatedStocks(stocksResponse.data || []);
|
||||
} catch (e) {
|
||||
console.warn('加载相关股票失败:', e);
|
||||
setRelatedStocks([]);
|
||||
}
|
||||
|
||||
// 根据权限决定是否加载相关概念
|
||||
if (hasFeatureAccess('related_concepts')) {
|
||||
try {
|
||||
const conceptsResponse = await eventService.getRelatedConcepts(actualEventId);
|
||||
setRelatedConcepts(conceptsResponse.data || []);
|
||||
} catch (e) {
|
||||
console.warn('加载相关概念失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 历史事件所有用户都可以访问,但免费用户只看到前2条
|
||||
try {
|
||||
const eventsResponse = await eventService.getHistoricalEvents(actualEventId);
|
||||
setHistoricalEvents(eventsResponse.data || []);
|
||||
} catch (e) {
|
||||
console.warn('历史事件加载失败', e);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading event data:', err);
|
||||
setError(err.message || '加载事件数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refetchStocks = async () => {
|
||||
if (!hasFeatureAccess('related_stocks')) return;
|
||||
try {
|
||||
const stocksResponse = await eventService.getRelatedStocks(actualEventId);
|
||||
setRelatedStocks(stocksResponse.data);
|
||||
} catch (err) {
|
||||
console.error('重新获取股票数据失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFollowToggle = async () => {
|
||||
try {
|
||||
await eventService.toggleFollow(actualEventId, eventData.is_following);
|
||||
|
||||
setEventData(prev => ({
|
||||
...prev,
|
||||
is_following: !prev.is_following,
|
||||
follower_count: prev.is_following
|
||||
? prev.follower_count - 1
|
||||
: prev.follower_count + 1
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('关注操作失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载帖子列表
|
||||
const loadPosts = async () => {
|
||||
setPostsLoading(true);
|
||||
try {
|
||||
const result = await eventService.getPosts(actualEventId);
|
||||
if (result.success) {
|
||||
setPosts(result.data || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载帖子失败:', err);
|
||||
} finally {
|
||||
setPostsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 创建新帖子
|
||||
const handleCreatePost = async () => {
|
||||
if (!newPostContent.trim()) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await eventService.createPost(actualEventId, {
|
||||
title: newPostTitle.trim(),
|
||||
content: newPostContent.trim(),
|
||||
content_type: 'text',
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: '帖子发布成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
setNewPostContent('');
|
||||
setNewPostTitle('');
|
||||
loadPosts();
|
||||
// 更新帖子数
|
||||
setEventData(prev => ({
|
||||
...prev,
|
||||
post_count: (prev.post_count || 0) + 1
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: '发布失败',
|
||||
description: err.message,
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Effect hook - must be called after all state hooks
|
||||
useEffect(() => {
|
||||
if (actualEventId) {
|
||||
loadEventData();
|
||||
loadPosts();
|
||||
} else {
|
||||
setError('无效的事件ID');
|
||||
setLoading(false);
|
||||
}
|
||||
}, [actualEventId, location.pathname]);
|
||||
|
||||
// 加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<Box bg={bgColor} minH="100vh" w="100%" p={4}>
|
||||
<Container maxW="7xl" py={8}>
|
||||
<VStack spacing={6}>
|
||||
<Skeleton height="150px" borderRadius="lg" />
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4} w="100%">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} height="80px" borderRadius="md" />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Grid templateColumns={{ base: "1fr", lg: "1fr 1fr" }} gap={6} w="100%">
|
||||
<Skeleton height="300px" borderRadius="lg" />
|
||||
<Skeleton height="300px" borderRadius="lg" />
|
||||
</Grid>
|
||||
</VStack>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
if (error) {
|
||||
return (
|
||||
<Box bg={bgColor} minH="100vh" w="100%" p={4}>
|
||||
<Container maxW="7xl" py={8}>
|
||||
<Center minH="60vh">
|
||||
<Alert
|
||||
status="error"
|
||||
borderRadius="lg"
|
||||
maxW="md"
|
||||
flexDirection="column"
|
||||
textAlign="center"
|
||||
p={6}
|
||||
>
|
||||
<AlertIcon boxSize="40px" mr={0} />
|
||||
<AlertTitle mt={4} mb={2} fontSize="lg">
|
||||
加载失败
|
||||
</AlertTitle>
|
||||
<AlertDescription maxWidth="sm">
|
||||
{error}
|
||||
{actualEventId && (
|
||||
<Text mt={2} fontSize="sm" color="gray.500">
|
||||
事件ID: {actualEventId}
|
||||
</Text>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</Center>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 主要内容
|
||||
return (
|
||||
<Box bg={bgColor} minH="100vh" w="100%">
|
||||
<HomeNavbar />
|
||||
<Container maxW="7xl" py={8}>
|
||||
<VStack spacing={6} align="stretch">
|
||||
{/* 事件基本信息 */}
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
p={6}
|
||||
>
|
||||
<EventHeader
|
||||
event={eventData}
|
||||
onFollowToggle={handleFollowToggle}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 统计数据 */}
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4}>
|
||||
<StatCard
|
||||
icon={FiTrendingUp}
|
||||
label="关注度"
|
||||
value={eventData?.follower_count || 0}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
icon={hasFeatureAccess('related_stocks') ? FiActivity : FiLock}
|
||||
label="相关标的"
|
||||
value={hasFeatureAccess('related_stocks') ? relatedStocks.length : '🔒需Pro'}
|
||||
color={hasFeatureAccess('related_stocks') ? "green" : "orange"}
|
||||
/>
|
||||
<StatCard
|
||||
icon={FiZap}
|
||||
label="预期偏离度"
|
||||
value={`${(eventData?.expectation_surprise_score || 0).toFixed(1)}%`}
|
||||
color="purple"
|
||||
/>
|
||||
<StatCard
|
||||
icon={FiMessageSquare}
|
||||
label="讨论数"
|
||||
value={eventData?.post_count || 0}
|
||||
color="orange"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* 主要内容标签页 */}
|
||||
<Tabs colorScheme="blue" size="md">
|
||||
<TabList>
|
||||
<Tab>
|
||||
相关标的
|
||||
{!hasFeatureAccess('related_stocks') && (
|
||||
<Icon as={FiLock} ml={1} boxSize={3} color="orange.400" />
|
||||
)}
|
||||
</Tab>
|
||||
<Tab>
|
||||
相关概念
|
||||
{!hasFeatureAccess('related_concepts') && (
|
||||
<Icon as={FiLock} ml={1} boxSize={3} color="orange.400" />
|
||||
)}
|
||||
</Tab>
|
||||
<Tab>历史事件</Tab>
|
||||
<Tab>
|
||||
传导链分析
|
||||
{!hasFeatureAccess('transmission_chain') && (
|
||||
<Icon as={FiLock} ml={1} boxSize={3} color="purple.400" />
|
||||
)}
|
||||
</Tab>
|
||||
<Tab>讨论区</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{/* 相关标的标签页 */}
|
||||
<TabPanel px={0}>
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
p={6}
|
||||
>
|
||||
{!hasFeatureAccess('related_stocks') ? (
|
||||
<VStack spacing={3} align="center" py={8}>
|
||||
<Icon as={FiLock} boxSize={8} color="orange.400" />
|
||||
<Text>该功能为Pro专享,请升级订阅后查看相关标的。</Text>
|
||||
<Button colorScheme="blue" onClick={() => setUpgradeModal({ isOpen: true, feature: '相关标的', required: 'pro' })}>升级到Pro版</Button>
|
||||
</VStack>
|
||||
) : (
|
||||
<RelatedStocks
|
||||
eventId={actualEventId}
|
||||
eventTime={eventData?.created_at}
|
||||
stocks={relatedStocks}
|
||||
loading={false}
|
||||
error={null}
|
||||
onStockAdded={refetchStocks}
|
||||
onStockDeleted={refetchStocks}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* 相关概念标签页 */}
|
||||
<TabPanel px={0}>
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
p={6}
|
||||
>
|
||||
{!hasFeatureAccess('related_concepts') ? (
|
||||
<VStack spacing={3} align="center" py={8}>
|
||||
<Icon as={FiLock} boxSize={8} color="orange.400" />
|
||||
<Text>该功能为Pro专享,请升级订阅后查看相关概念。</Text>
|
||||
<Button colorScheme="blue" onClick={() => setUpgradeModal({ isOpen: true, feature: '相关概念', required: 'pro' })}>升级到Pro版</Button>
|
||||
</VStack>
|
||||
) : (
|
||||
<RelatedConcepts
|
||||
eventTitle={eventData?.title}
|
||||
eventTime={eventData?.created_at}
|
||||
eventId={actualEventId}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* 历史事件标签页 */}
|
||||
<TabPanel px={0}>
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
p={6}
|
||||
>
|
||||
<HistoricalEvents
|
||||
events={historicalEvents}
|
||||
expectationScore={eventData?.expectation_surprise_score}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
{!hasFeatureAccess('historical_events_full') && historicalEvents.length > 0 && (
|
||||
<Box mt={4} p={3} bg="orange.50" borderRadius="md" border="1px solid" borderColor="orange.200">
|
||||
<HStack>
|
||||
<Icon as={FiLock} color="orange.400" />
|
||||
<Text color="orange.700" fontSize="sm">
|
||||
免费版仅展示前2条历史事件,
|
||||
<Button
|
||||
variant="link"
|
||||
colorScheme="orange"
|
||||
size="sm"
|
||||
onClick={() => setUpgradeModal({ isOpen: true, feature: '完整历史事件', required: 'pro' })}
|
||||
>
|
||||
升级Pro版
|
||||
</Button>
|
||||
可查看全部。
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* 传导链分析标签页 */}
|
||||
<TabPanel px={0}>
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
p={6}
|
||||
>
|
||||
{!hasFeatureAccess('transmission_chain') ? (
|
||||
<VStack spacing={3} align="center" py={8}>
|
||||
<Icon as={FiLock} boxSize={8} color="purple.400" />
|
||||
<Text>传导链分析为Max专享,请升级订阅后查看。</Text>
|
||||
<Button colorScheme="purple" onClick={() => setUpgradeModal({ isOpen: true, feature: '传导链分析', required: 'max' })}>升级到Max版</Button>
|
||||
</VStack>
|
||||
) : (
|
||||
<TransmissionChainAnalysis
|
||||
eventId={actualEventId}
|
||||
eventService={eventService}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* 讨论区标签页 */}
|
||||
<TabPanel px={0}>
|
||||
<VStack spacing={6}>
|
||||
{/* 发布新帖子 */}
|
||||
{user && (
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
p={6}
|
||||
w="100%"
|
||||
>
|
||||
<VStack spacing={4}>
|
||||
<Input
|
||||
placeholder="帖子标题(可选)"
|
||||
value={newPostTitle}
|
||||
onChange={(e) => setNewPostTitle(e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
placeholder="分享你的想法..."
|
||||
value={newPostContent}
|
||||
onChange={(e) => setNewPostContent(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
<HStack w="100%" justify="flex-end">
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={handleCreatePost}
|
||||
isLoading={submitting}
|
||||
isDisabled={!newPostContent.trim()}
|
||||
>
|
||||
发布
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 帖子列表 */}
|
||||
<Box w="100%">
|
||||
{postsLoading ? (
|
||||
<VStack spacing={4}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} height="120px" w="100%" borderRadius="lg" />
|
||||
))}
|
||||
</VStack>
|
||||
) : posts.length > 0 ? (
|
||||
posts.map((post) => (
|
||||
<PostItem key={post.id} post={post} onRefresh={loadPosts} />
|
||||
))
|
||||
) : (
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
p={8}
|
||||
textAlign="center"
|
||||
>
|
||||
<Text color="gray.500">还没有讨论,来发布第一个帖子吧!</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</VStack>
|
||||
</Container>
|
||||
|
||||
{/* Footer区域 */}
|
||||
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={6} mt={8}>
|
||||
<Container maxW="7xl">
|
||||
<VStack spacing={2}>
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
© 2024 价值前沿. 保留所有权利.
|
||||
</Text>
|
||||
<HStack spacing={4} fontSize="xs" color="gray.400">
|
||||
<Link
|
||||
href="https://beian.mps.gov.cn/#/query/webSearch?code=11010802046286"
|
||||
isExternal
|
||||
_hover={{ color: 'gray.600' }}
|
||||
>
|
||||
京公网安备11010802046286号
|
||||
</Link>
|
||||
<Text>京ICP备2025107343号-1</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Container>
|
||||
</Box>
|
||||
{/* 升级弹窗 */}
|
||||
<SubscriptionUpgradeModal
|
||||
isOpen={upgradeModal.isOpen}
|
||||
onClose={() => setUpgradeModal({ isOpen: false, feature: '功能', required: 'pro' })}
|
||||
requiredLevel={upgradeModal.required}
|
||||
featureName={upgradeModal.feature}
|
||||
currentLevel={user?.subscription_type || 'free'}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventDetail;
|
||||
Reference in New Issue
Block a user