Files
vf_react/src/views/EventDetail/index.js
2025-10-11 16:16:02 +08:00

856 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;