fix: 调整事件详情页面

This commit is contained in:
zdl
2025-12-04 19:01:35 +08:00
parent f64c1ffb19
commit 1d5d06c567
5 changed files with 224 additions and 886 deletions

View File

@@ -30,12 +30,12 @@ const MemoizedAppFooter = memo(AppFooter);
*/ */
export default function MainLayout() { export default function MainLayout() {
return ( return (
<Box flex="1" h="100vh" w="100%" position="relative" display="flex" flexDirection="column"> <Box flex="1" h="100vh" w="100%" position="relative" display="flex" flexDirection="column" bg="#1A202C">
{/* 导航栏 - 在所有页面间共享memo 后不会在路由切换时重新渲染 */} {/* 导航栏 - 在所有页面间共享memo 后不会在路由切换时重新渲染 */}
<MemoizedHomeNavbar /> <MemoizedHomeNavbar />
{/* 页面内容区域 - flex: 1 占据剩余空间,包含错误边界、懒加载 */} {/* 页面内容区域 - flex: 1 占据剩余空间,包含错误边界、懒加载 */}
<Box flex="1" pt="60px"> <Box flex="1" pt="60px" bg="#1A202C">
<ErrorBoundary> <ErrorBoundary>
<Suspense fallback={<PageLoader message="页面加载中..." />}> <Suspense fallback={<PageLoader message="页面加载中..." />}>
<Outlet /> <Outlet />

View File

@@ -255,6 +255,48 @@ export const eventHandlers = [
// ==================== 事件详情相关 ==================== // ==================== 事件详情相关 ====================
// 获取事件详情
http.get('/api/events/:eventId', async ({ params }) => {
await delay(200);
const { eventId } = params;
console.log('[Mock] 获取事件详情, eventId:', eventId);
try {
// 返回模拟的事件详情数据
return HttpResponse.json({
success: true,
data: {
id: parseInt(eventId),
title: `测试事件 ${eventId} - 重大政策发布`,
description: '这是一个模拟的事件描述,用于开发测试。该事件涉及重要政策变化,可能对相关板块产生显著影响。建议关注后续发展动态。',
importance: ['S', 'A', 'B', 'C'][Math.floor(Math.random() * 4)],
created_at: new Date().toISOString(),
trading_date: new Date().toISOString().split('T')[0],
event_type: ['政策', '财报', '行业', '宏观'][Math.floor(Math.random() * 4)],
related_avg_chg: parseFloat((Math.random() * 10 - 5).toFixed(2)),
follower_count: Math.floor(Math.random() * 500) + 50,
view_count: Math.floor(Math.random() * 5000) + 100,
is_following: false,
post_count: Math.floor(Math.random() * 50),
expectation_surprise_score: parseFloat((Math.random() * 100).toFixed(1)),
},
message: '获取成功'
});
} catch (error) {
console.error('[Mock] 获取事件详情失败:', error);
return HttpResponse.json(
{
success: false,
error: '获取事件详情失败',
data: null
},
{ status: 500 }
);
}
}),
// 获取事件相关股票 // 获取事件相关股票
http.get('/api/events/:eventId/stocks', async ({ params }) => { http.get('/api/events/:eventId/stocks', async ({ params }) => {
await delay(300); await delay(300);

View File

@@ -318,4 +318,74 @@ export const stockHandlers = [
); );
} }
}), }),
// 获取股票报价(批量)
http.post('/api/stock/quotes', async ({ request }) => {
await delay(200);
try {
const body = await request.json();
const { codes, event_time } = body;
console.log('[Mock Stock] 获取股票报价:', {
stockCount: codes?.length,
eventTime: event_time
});
if (!codes || !Array.isArray(codes) || codes.length === 0) {
return HttpResponse.json(
{ success: false, error: '股票代码列表不能为空' },
{ status: 400 }
);
}
// 生成股票列表用于查找名称
const stockList = generateStockList();
const stockMap = {};
stockList.forEach(s => {
stockMap[s.code] = s.name;
});
// 为每只股票生成报价数据
const quotesData = {};
codes.forEach(stockCode => {
// 生成基础价格10-200之间
const basePrice = parseFloat((Math.random() * 190 + 10).toFixed(2));
// 涨跌幅(-10% 到 +10%
const changePercent = parseFloat((Math.random() * 20 - 10).toFixed(2));
// 涨跌额
const change = parseFloat((basePrice * changePercent / 100).toFixed(2));
// 昨收
const prevClose = parseFloat((basePrice - change).toFixed(2));
quotesData[stockCode] = {
code: stockCode,
name: stockMap[stockCode] || `股票${stockCode}`,
price: basePrice,
change: change,
change_percent: changePercent,
prev_close: prevClose,
open: parseFloat((prevClose * (1 + (Math.random() * 0.02 - 0.01))).toFixed(2)),
high: parseFloat((basePrice * (1 + Math.random() * 0.05)).toFixed(2)),
low: parseFloat((basePrice * (1 - Math.random() * 0.05)).toFixed(2)),
volume: Math.floor(Math.random() * 100000000),
amount: parseFloat((Math.random() * 10000000000).toFixed(2)),
market: stockCode.startsWith('6') ? 'SH' : 'SZ',
update_time: new Date().toISOString()
};
});
return HttpResponse.json({
success: true,
data: quotesData,
message: '获取成功'
});
} catch (error) {
console.error('[Mock Stock] 获取股票报价失败:', error);
return HttpResponse.json(
{ success: false, error: '获取股票报价失败' },
{ status: 500 }
);
}
}),
]; ];

View File

@@ -27,6 +27,18 @@ import { MainPanelComponent } from "./additions/layout/MainPanel";
import { PanelContentComponent } from "./additions/layout/PanelContent"; import { PanelContentComponent } from "./additions/layout/PanelContent";
import { PanelContainerComponent } from "./additions/layout/PanelContainer"; import { PanelContainerComponent } from "./additions/layout/PanelContainer";
// import { mode } from "@chakra-ui/theme-tools"; // import { mode } from "@chakra-ui/theme-tools";
// Container 组件样式覆盖 - 移除默认背景色
const ContainerComponent = {
components: {
Container: {
baseStyle: {
bg: "1A202C",
},
},
},
};
export default extendTheme( export default extendTheme(
{ breakpoints }, // Breakpoints { breakpoints }, // Breakpoints
globalStyles, globalStyles,
@@ -37,5 +49,6 @@ export default extendTheme(
CardComponent, // Card component CardComponent, // Card component
MainPanelComponent, // Main Panel component MainPanelComponent, // Main Panel component
PanelContentComponent, // Panel Content component PanelContentComponent, // Panel Content component
PanelContainerComponent // Panel Container component PanelContainerComponent, // Panel Container component
ContainerComponent // Container 背景透明
); );

View File

@@ -1,899 +1,112 @@
import React, { useState, useEffect, useRef } from 'react'; /**
import { useParams, useLocation, useSearchParams } from 'react-router-dom'; * EventDetail - 事件详情页面
import { decodeEventId } from '@/utils/idEncoder'; * 使用 DynamicNewsDetailPanel 组件展示事件详情
*/
import React, { useState, useEffect } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import { import {
Box, Box,
Container, Container,
VStack, Spinner,
HStack, Center,
Spinner, Alert,
Alert, AlertIcon,
AlertIcon, AlertTitle,
AlertTitle, AlertDescription,
AlertDescription, Text,
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,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { FiLock } from 'react-icons/fi'; import { decodeEventId } from '@/utils/idEncoder';
import { import { eventService } from '@/services/eventService';
FiTrendingUp, import { DynamicNewsDetailPanel } from '@/views/Community/components/DynamicNewsDetail';
FiActivity, import { logger } from '@/utils/logger';
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';
// Navigation bar now provided by MainLayout
// 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';
import { logger } from '../../utils/logger';
import { useEventDetailEvents } from './hooks/useEventDetailEvents';
// 临时调试代码 - 生产环境测试后请删除
if (typeof window !== 'undefined') {
logger.debug('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, eventEvents }) => {
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) {
logger.error('PostItem', 'loadComments', error, { postId: post.id });
} finally {
setIsLoading(false);
}
} else {
setShowComments(false);
}
};
const handleLike = async () => {
try {
const result = await eventService.likePost(post.id);
if (result.success) {
const newLikedState = result.liked;
setLiked(newLikedState);
setLikesCount(result.likes_count);
// 🎯 追踪评论点赞
if (eventEvents && eventEvents.trackCommentLiked) {
eventEvents.trackCommentLiked(post.id, newLikedState);
}
}
} 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) {
// 🎯 追踪添加评论
if (eventEvents && eventEvents.trackCommentAdded) {
eventEvents.trackCommentAdded(
result.data?.id || post.id,
newComment.length
);
}
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) {
// 🎯 追踪删除评论
if (eventEvents && eventEvents.trackCommentDeleted) {
eventEvents.trackCommentDeleted(post.id);
}
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 EventDetail = () => {
const { eventId: pathEventId } = useParams(); const { eventId: pathEventId } = useParams();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const location = useLocation();
const bgColor = useColorModeValue('gray.50', 'gray.900');
const toast = useToast();
// 优先从查询参数获取加密 ID兼容旧的路径参数 // 优先从查询参数获取加密 ID兼容旧的路径参数
const encodedId = searchParams.get('id'); const encodedId = searchParams.get('id');
const eventId = encodedId ? decodeEventId(encodedId) : pathEventId; const eventId = encodedId ? decodeEventId(encodedId) : pathEventId;
// 用户认证和权限控制 // 状态
const { user } = useAuth(); const [eventData, setEventData] = useState(null);
const { hasFeatureAccess, getUpgradeRecommendation } = useSubscription(); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// 滚动位置管理
const scrollPositionRef = useRef(0);
// 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);
// 🎯 初始化事件详情埋点Hook传入event对象
const eventEvents = useEventDetailEvents({
event: eventData ? {
id: eventData.id,
title: eventData.title,
importance: eventData.importance
} : null
});
const [newPostContent, setNewPostContent] = useState('');
const [newPostTitle, setNewPostTitle] = useState('');
const [submitting, setSubmitting] = useState(false);
const [upgradeModal, setUpgradeModal] = useState({ isOpen: false, feature: '功能', required: 'pro' });
// 保存当前滚动位置
const saveScrollPosition = () => {
scrollPositionRef.current = window.scrollY || window.pageYOffset;
};
// 恢复滚动位置
const restoreScrollPosition = () => {
window.scrollTo(0, scrollPositionRef.current);
};
// 加载事件基础数据
useEffect(() => {
const loadEventData = async () => { const loadEventData = async () => {
try { if (!eventId) {
setLoading(true); setError('无效的事件ID');
setError(null); setLoading(false);
return;
}
// 加载基本事件信息(免费用户也可以访问) try {
const eventResponse = await eventService.getEventDetail(eventId); setLoading(true);
setEventData(eventResponse.data); setError(null);
const response = await eventService.getEventDetail(eventId);
// 总是尝试加载相关股票(权限在组件内部检查) setEventData(response.data);
let stocksCount = 0; } catch (err) {
try { logger.error('EventDetail', 'loadEventData', err, { eventId });
const stocksResponse = await eventService.getRelatedStocks(eventId); setError(err.message || '加载事件数据失败');
setRelatedStocks(stocksResponse.data || []); } finally {
stocksCount = stocksResponse.data?.length || 0; setLoading(false);
} catch (e) { }
logger.warn('EventDetail', '加载相关股票失败', { eventId: eventId, error: e.message });
setRelatedStocks([]);
}
// 根据权限决定是否加载相关概念
if (hasFeatureAccess('related_concepts')) {
try {
const conceptsResponse = await eventService.getRelatedConcepts(eventId);
setRelatedConcepts(conceptsResponse.data || []);
} catch (e) {
logger.warn('EventDetail', '加载相关概念失败', { eventId: eventId, error: e.message });
}
}
// 历史事件所有用户都可以访问但免费用户只看到前2条
let timelineCount = 0;
try {
const eventsResponse = await eventService.getHistoricalEvents(eventId);
setHistoricalEvents(eventsResponse.data || []);
timelineCount = eventsResponse.data?.length || 0;
} catch (e) {
logger.warn('EventDetail', '历史事件加载失败', { eventId: eventId, error: e.message });
}
// 🎯 追踪事件分析内容查看(数据加载完成后)
if (eventResponse.data && eventEvents) {
eventEvents.trackEventAnalysisViewed({
type: 'overview',
relatedStockCount: stocksCount,
timelineEventCount: timelineCount,
marketImpact: eventResponse.data.market_impact
});
}
} catch (err) {
logger.error('EventDetail', 'loadEventData', err, { eventId: eventId });
setError(err.message || '加载事件数据失败');
} finally {
setLoading(false);
}
}; };
const refetchStocks = async () => { loadEventData();
if (!hasFeatureAccess('related_stocks')) return; }, [eventId]);
try {
const stocksResponse = await eventService.getRelatedStocks(eventId);
setRelatedStocks(stocksResponse.data);
} catch (err) {
logger.error('EventDetail', 'refetchStocks', err, { eventId: eventId });
}
};
const handleFollowToggle = async () => { // 加载状态
try { if (loading) {
await eventService.toggleFollow(eventId, 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) {
logger.error('EventDetail', 'handleFollowToggle', err, { eventId: eventId });
}
};
// 加载帖子列表
const loadPosts = async () => {
setPostsLoading(true);
try {
const result = await eventService.getPosts(eventId);
if (result.success) {
setPosts(result.data || []);
}
} catch (err) {
logger.error('EventDetail', 'loadPosts', err, { eventId: eventId });
} finally {
setPostsLoading(false);
}
};
// 创建新帖子
const handleCreatePost = async () => {
if (!newPostContent.trim()) return;
setSubmitting(true);
try {
const result = await eventService.createPost(eventId, {
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 (eventId) {
// 保存当前滚动位置
saveScrollPosition();
loadEventData();
loadPosts();
// 数据加载完成后恢复滚动位置
// 使用 setTimeout 确保 DOM 已更新
const timer = setTimeout(() => {
restoreScrollPosition();
}, 100);
return () => clearTimeout(timer);
} else {
setError('无效的事件ID');
setLoading(false);
}
}, [eventId]);
// 加载状态
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}
{eventId && (
<Text mt={2} fontSize="sm" color="gray.500">
事件ID: {eventId}
</Text>
)}
</AlertDescription>
</Alert>
</Center>
</Container>
</Box>
);
}
// 主要内容
return ( return (
<Box bg={bgColor} minH="100vh" w="100%"> <Box minH="100vh" w="100%">
{/* Navigation bar provided by MainLayout */} <Center py={20}>
<Container maxW="7xl" py={8}> <Spinner size="xl" color="blue.500" />
<VStack spacing={6} align="stretch"> </Center>
{/* 事件基本信息 */} </Box>
<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={eventId}
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={eventId}
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={eventId}
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}
eventEvents={eventEvents}
/>
))
) : (
<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>
{/* 升级弹窗 */}
<SubscriptionUpgradeModal
isOpen={upgradeModal.isOpen}
onClose={() => setUpgradeModal({ isOpen: false, feature: '功能', required: 'pro' })}
requiredLevel={upgradeModal.required}
featureName={upgradeModal.feature}
currentLevel={user?.subscription_type || 'free'}
/>
</Box>
); );
}
// 错误状态
if (error) {
return (
<Box 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}
{eventId && (
<Text mt={2} fontSize="sm" color="gray.500">
事件ID: {eventId}
</Text>
)}
</AlertDescription>
</Alert>
</Center>
</Container>
</Box>
);
}
// 主内容
return (
<Box maxW="7xl" mx="auto"><DynamicNewsDetailPanel event={eventData} showHeader={true} /></Box>
);
}; };
export default EventDetail; export default EventDetail;