Initial commit

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

View File

@@ -0,0 +1,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;

View File

@@ -0,0 +1,539 @@
// src/views/EventDetail/components/HistoricalEvents.js
import React, { useState, useEffect } from 'react';
import {
Box,
VStack,
HStack,
Text,
Badge,
Button,
Collapse,
useDisclosure,
Skeleton,
Alert,
AlertIcon,
Card,
CardBody,
CardHeader,
Divider,
Icon,
useColorModeValue,
Tooltip,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
Spinner,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
Link
} from '@chakra-ui/react';
import {
FaExclamationTriangle,
FaClock,
FaCalendarAlt,
FaChartLine,
FaEye,
FaTimes,
FaInfoCircle
} from 'react-icons/fa';
import { stockService } from '../../../services/eventService';
const HistoricalEvents = ({
events = [],
expectationScore = null,
loading = false,
error = null
}) => {
// 所有 useState/useEffect/useContext/useRef/useCallback/useMemo 必须在组件顶层、顺序一致
// 不要在 if/循环/回调中调用 Hook
const [expandedEvents, setExpandedEvents] = useState(new Set());
const [selectedEvent, setSelectedEvent] = useState(null);
const [eventStocks, setEventStocks] = useState({});
const [loadingStocks, setLoadingStocks] = useState(false);
const { isOpen, onOpen, onClose } = useDisclosure();
// 颜色主题
const timelineBg = useColorModeValue('#D4AF37', '#B8860B');
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const textSecondary = useColorModeValue('gray.600', 'gray.400');
// 切换事件展开状态
const toggleEventExpansion = (eventId) => {
const newExpanded = new Set(expandedEvents);
if (newExpanded.has(eventId)) {
newExpanded.delete(eventId);
} else {
newExpanded.add(eventId);
}
setExpandedEvents(newExpanded);
};
// 显示事件相关股票
const showEventStocks = async (event) => {
setSelectedEvent(event);
setLoadingStocks(true);
onOpen();
try {
// 如果已经加载过该事件的股票数据,直接使用缓存
if (eventStocks[event.id]) {
setLoadingStocks(false);
return;
}
// 调用API获取历史事件相关股票
const response = await stockService.getHistoricalEventStocks(event.id);
setEventStocks(prev => ({
...prev,
[event.id]: response.data || []
}));
} catch (err) {
console.error('加载事件股票失败:', err);
setEventStocks(prev => ({
...prev,
[event.id]: []
}));
} finally {
setLoadingStocks(false);
}
};
// 获取重要性图标
const getImportanceIcon = (importance) => {
if (importance >= 4) return FaExclamationTriangle;
if (importance >= 2) return FaCalendarAlt;
return FaClock;
};
// 获取重要性颜色
const getImportanceColor = (importance) => {
if (importance >= 4) return 'red';
if (importance >= 2) return 'orange';
return 'green';
};
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return '日期未知';
return new Date(dateString).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
};
// 计算相对时间
const getRelativeTime = (dateString) => {
if (!dateString) return '';
const date = new Date(dateString);
const now = new Date();
const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
if (diffDays < 30) return `${diffDays}天前`;
if (diffDays < 365) return `${Math.floor(diffDays / 30)}个月前`;
return `${Math.floor(diffDays / 365)}年前`;
};
// 可展开的文本组件
const ExpandableText = ({ text, maxLength = 20 }) => {
const { isOpen, onToggle } = useDisclosure();
const [shouldTruncate, setShouldTruncate] = useState(false);
useEffect(() => {
if (text && text.length > maxLength) {
setShouldTruncate(true);
} else {
setShouldTruncate(false);
}
}, [text, maxLength]);
if (!text) return <Text fontSize="xs">--</Text>;
const displayText = shouldTruncate && !isOpen
? text.substring(0, maxLength) + '...'
: text;
return (
<VStack align="flex-start" spacing={1}>
<Text fontSize="xs" noOfLines={isOpen ? undefined : 2} maxW="300px">
{displayText}{text.includes('AI合成') ? '' : 'AI合成'}
</Text>
{shouldTruncate && (
<Button
size="xs"
variant="link"
color="blue.500"
onClick={onToggle}
height="auto"
py={0}
minH={0}
>
{isOpen ? '收起' : '展开'}
</Button>
)}
</VStack>
);
};
// 加载状态
if (loading) {
return (
<VStack spacing={4} align="stretch">
{[1, 2, 3].map((i) => (
<Card key={i} borderLeft="4px solid" borderLeftColor="gray.200">
<CardBody>
<HStack spacing={4} align="flex-start">
<Skeleton boxSize="40px" borderRadius="full" />
<VStack align="flex-start" spacing={2} flex="1">
<Skeleton height="20px" width="70%" />
<Skeleton height="16px" width="40%" />
<Skeleton height="14px" width="90%" />
</VStack>
</HStack>
</CardBody>
</Card>
))}
</VStack>
);
}
// 错误状态
if (error) {
return (
<Alert status="error" borderRadius="lg">
<AlertIcon />
加载历史事件失败: {error}
</Alert>
);
}
// 无数据状态
if (!events || events.length === 0) {
return (
<Box
textAlign="center"
py={12}
color="gray.500"
bg={useColorModeValue('gray.50', 'gray.700')}
borderRadius="lg"
>
<Text fontSize="lg" mb={2}>暂无历史事件</Text>
<Text fontSize="sm">历史事件数据将在这里显示</Text>
</Box>
);
}
return (
<>
<VStack spacing={4} align="stretch">
{/* 超预期得分显示 */}
{expectationScore && (
<Card bg={useColorModeValue('yellow.50', 'yellow.900')} borderColor="yellow.200">
<CardBody>
<HStack spacing={3}>
<Icon as={FaChartLine} color="yellow.600" boxSize="20px" />
<VStack align="flex-start" spacing={1}>
<Text fontSize="sm" fontWeight="bold" color="yellow.800">
超预期得分: {expectationScore}
</Text>
<Text fontSize="xs" color="yellow.700">
基于历史事件判断当前事件的超预期情况满分100分AI合成
</Text>
</VStack>
</HStack>
</CardBody>
</Card>
)}
{/* 历史事件时间轴 */}
<Box position="relative">
{/* 时间轴线 */}
<Box
position="absolute"
left="20px"
top="20px"
bottom="20px"
width="2px"
background={`linear-gradient(to bottom, ${timelineBg}, #996515)`}
zIndex={0}
/>
{/* 事件列表 */}
<VStack spacing={6} align="stretch">
{events.map((event, index) => {
const ImportanceIcon = getImportanceIcon(event.importance);
const importanceColor = getImportanceColor(event.importance);
const isExpanded = expandedEvents.has(event.id);
return (
<Box key={event.id} position="relative">
{/* 时间轴节点 */}
<Box
position="absolute"
left="0"
top="20px"
width="40px"
height="40px"
borderRadius="full"
bg={cardBg}
border="2px solid"
borderColor={timelineBg}
display="flex"
alignItems="center"
justifyContent="center"
zIndex={1}
>
<Icon
as={ImportanceIcon}
color={`${importanceColor}.500`}
boxSize="16px"
/>
</Box>
{/* 事件内容卡片 */}
<Box ml="60px">
<Card
borderLeft="3px solid"
borderLeftColor={timelineBg}
bg={cardBg}
_hover={{ shadow: 'md' }}
transition="all 0.2s"
>
<CardBody>
<VStack align="flex-start" spacing={3}>
{/* 事件标题和操作 */}
<HStack justify="space-between" align="flex-start" w="100%">
<VStack align="flex-start" spacing={1} flex="1">
<Button
variant="link"
color={useColorModeValue('blue.600', 'blue.400')}
fontWeight="bold"
fontSize="md"
p={0}
h="auto"
onClick={() => toggleEventExpansion(event.id)}
_hover={{ textDecoration: 'underline' }}
>
{event.title || '未命名事件'}
</Button>
<HStack spacing={3} fontSize="sm" color={textSecondary}>
<Text>{formatDate(event.event_date)}</Text>
<Text>({getRelativeTime(event.event_date)})</Text>
{event.relevance && (
<Badge colorScheme="blue" size="sm">
相关度: {event.relevance}
</Badge>
)}
</HStack>
</VStack>
<HStack spacing={2}>
{event.importance && (
<Tooltip label={`重要性等级: ${event.importance}/5`}>
<Badge colorScheme={importanceColor} size="sm">
重要性: {event.importance}
</Badge>
</Tooltip>
)}
<Button
size="sm"
leftIcon={<Icon as={FaChartLine} />}
onClick={() => showEventStocks(event)}
colorScheme="blue"
variant="outline"
>
相关股票
</Button>
</HStack>
</HStack>
{/* 事件简介 */}
<Text fontSize="sm" color={textSecondary} lineHeight="1.5">
{event.content ? `${event.content}AI合成` : '暂无内容'}
</Text>
{/* 展开的详细信息 */}
<Collapse in={isExpanded} animateOpacity>
<Box pt={3} borderTop="1px solid" borderTopColor={borderColor}>
<VStack align="flex-start" spacing={2}>
<Text fontSize="xs" color={textSecondary}>
事件ID: {event.id}
</Text>
{event.source && (
<Text fontSize="xs" color={textSecondary}>
来源: {event.source}
</Text>
)}
{event.tags && event.tags.length > 0 && (
<HStack spacing={1} flexWrap="wrap">
<Text fontSize="xs" color={textSecondary}>标签:</Text>
{event.tags.map((tag, idx) => (
<Badge key={idx} size="sm" variant="outline">
{tag}
</Badge>
))}
</HStack>
)}
</VStack>
</Box>
</Collapse>
</VStack>
</CardBody>
</Card>
</Box>
</Box>
);
})}
</VStack>
</Box>
</VStack>
{/* 事件相关股票模态框 */}
<Modal isOpen={isOpen} onClose={onClose} size="4xl">
<ModalOverlay />
<ModalContent maxW="80vw" maxH="85vh">
<ModalHeader>
<VStack align="flex-start" spacing={1}>
<Text>{selectedEvent?.title || '历史事件'}</Text>
<Text fontSize="sm" color={textSecondary} fontWeight="normal">
相关股票信息
</Text>
</VStack>
</ModalHeader>
<ModalCloseButton />
<ModalBody overflowY="auto" maxH="calc(85vh - 180px)">
{loadingStocks ? (
<VStack spacing={4} py={8}>
<Spinner size="lg" color="blue.500" />
<Text color={textSecondary}>加载相关股票数据...</Text>
</VStack>
) : (
<StocksList
stocks={selectedEvent ? eventStocks[selectedEvent.id] || [] : []}
eventTradingDate={selectedEvent ? selectedEvent.event_date : null}
/>
)}
</ModalBody>
<ModalFooter>
<Button onClick={onClose}>关闭</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
};
// 股票列表子组件
const StocksList = ({ stocks, eventTradingDate }) => {
const textSecondary = useColorModeValue('gray.600', 'gray.400');
// 处理股票代码,移除.SZ/.SH后缀
const formatStockCode = (stockCode) => {
if (!stockCode) return '';
return stockCode.replace(/\.(SZ|SH)$/i, '');
};
if (!stocks || stocks.length === 0) {
return (
<Box textAlign="center" py={8} color={textSecondary}>
<Icon as={FaInfoCircle} boxSize="48px" mb={4} />
<Text fontSize="lg" mb={2}>暂无相关股票数据</Text>
<Text fontSize="sm">该历史事件暂未关联股票信息</Text>
</Box>
);
}
return (
<>
{eventTradingDate && (
<Box mb={4} p={3} bg={useColorModeValue('blue.50', 'blue.900')} borderRadius="md">
<Text fontSize="sm" color={useColorModeValue('blue.700', 'blue.300')}>
📅 事件对应交易日{new Date(eventTradingDate).toLocaleDateString('zh-CN')}
</Text>
</Box>
)}
<TableContainer>
<Table size="md">
<Thead>
<Tr>
<Th>股票代码</Th>
<Th>股票名称</Th>
<Th>板块</Th>
<Th isNumeric>相关度</Th>
<Th isNumeric>事件日涨幅</Th>
<Th>关联原因</Th>
</Tr>
</Thead>
<Tbody>
{stocks.map((stock, index) => (
<Tr key={stock.id || index}>
<Td fontFamily="mono" fontWeight="medium">
<Link
href={`https://valuefrontier.cn/company?scode=${stock.stock_code ? stock.stock_code.replace(/\.(SZ|SH)$/i, '') : ''}`}
isExternal
color="blue.500"
_hover={{ textDecoration: 'underline' }}
>
{stock.stock_code ? stock.stock_code.replace(/\.(SZ|SH)$/i, '') : ''}
</Link>
</Td>
<Td>{stock.stock_name || '--'}</Td>
<Td>
<Badge size="sm" variant="outline">
{stock.sector || '未知'}
</Badge>
</Td>
<Td isNumeric>
<Badge
colorScheme={
stock.correlation >= 0.8 ? 'red' :
stock.correlation >= 0.6 ? 'orange' : 'green'
}
size="sm"
>
{Math.round((stock.correlation || 0) * 100)}%
</Badge>
</Td>
<Td isNumeric>
{stock.event_day_change_pct !== null && stock.event_day_change_pct !== undefined ? (
<Text
fontWeight="medium"
color={stock.event_day_change_pct >= 0 ? 'red.500' : 'green.500'}
>
{stock.event_day_change_pct >= 0 ? '+' : ''}{stock.event_day_change_pct.toFixed(2)}%
</Text>
) : (
<Text color={textSecondary} fontSize="sm">--</Text>
)}
</Td>
<Td>
<VStack align="flex-start" spacing={1}>
<Text fontSize="xs" noOfLines={2} maxW="300px">
{stock.relation_desc ? `${stock.relation_desc}AI合成` : '--'}
</Text>
</VStack>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</>
);
};
export default HistoricalEvents;

View 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;

View 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;

View 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;

View 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;

View 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;