fix: 调整事件详情页面
This commit is contained in:
@@ -30,12 +30,12 @@ const MemoizedAppFooter = memo(AppFooter);
|
||||
*/
|
||||
export default function MainLayout() {
|
||||
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 后不会在路由切换时重新渲染 */}
|
||||
<MemoizedHomeNavbar />
|
||||
|
||||
{/* 页面内容区域 - flex: 1 占据剩余空间,包含错误边界、懒加载 */}
|
||||
<Box flex="1" pt="60px">
|
||||
<Box flex="1" pt="60px" bg="#1A202C">
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<PageLoader message="页面加载中..." />}>
|
||||
<Outlet />
|
||||
|
||||
@@ -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 }) => {
|
||||
await delay(300);
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -27,6 +27,18 @@ import { MainPanelComponent } from "./additions/layout/MainPanel";
|
||||
import { PanelContentComponent } from "./additions/layout/PanelContent";
|
||||
import { PanelContainerComponent } from "./additions/layout/PanelContainer";
|
||||
// import { mode } from "@chakra-ui/theme-tools";
|
||||
|
||||
// Container 组件样式覆盖 - 移除默认背景色
|
||||
const ContainerComponent = {
|
||||
components: {
|
||||
Container: {
|
||||
baseStyle: {
|
||||
bg: "1A202C",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default extendTheme(
|
||||
{ breakpoints }, // Breakpoints
|
||||
globalStyles,
|
||||
@@ -37,5 +49,6 @@ export default extendTheme(
|
||||
CardComponent, // Card component
|
||||
MainPanelComponent, // Main Panel component
|
||||
PanelContentComponent, // Panel Content component
|
||||
PanelContainerComponent // Panel Container component
|
||||
PanelContainerComponent, // Panel Container component
|
||||
ContainerComponent // Container 背景透明
|
||||
);
|
||||
|
||||
@@ -1,899 +1,112 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { decodeEventId } from '@/utils/idEncoder';
|
||||
/**
|
||||
* EventDetail - 事件详情页面
|
||||
* 使用 DynamicNewsDetailPanel 组件展示事件详情
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useSearchParams } 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,
|
||||
Box,
|
||||
Container,
|
||||
Spinner,
|
||||
Center,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
Text,
|
||||
} 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';
|
||||
// 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>
|
||||
);
|
||||
};
|
||||
import { decodeEventId } from '@/utils/idEncoder';
|
||||
import { eventService } from '@/services/eventService';
|
||||
import { DynamicNewsDetailPanel } from '@/views/Community/components/DynamicNewsDetail';
|
||||
import { logger } from '@/utils/logger';
|
||||
|
||||
const EventDetail = () => {
|
||||
const { eventId: pathEventId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const location = useLocation();
|
||||
const bgColor = useColorModeValue('gray.50', 'gray.900');
|
||||
const toast = useToast();
|
||||
const { eventId: pathEventId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// 优先从查询参数获取加密 ID,兼容旧的路径参数
|
||||
const encodedId = searchParams.get('id');
|
||||
const eventId = encodedId ? decodeEventId(encodedId) : pathEventId;
|
||||
// 优先从查询参数获取加密 ID,兼容旧的路径参数
|
||||
const encodedId = searchParams.get('id');
|
||||
const eventId = encodedId ? decodeEventId(encodedId) : pathEventId;
|
||||
|
||||
// 用户认证和权限控制
|
||||
const { user } = useAuth();
|
||||
const { hasFeatureAccess, getUpgradeRecommendation } = useSubscription();
|
||||
|
||||
// 滚动位置管理
|
||||
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);
|
||||
};
|
||||
// 状态
|
||||
const [eventData, setEventData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// 加载事件基础数据
|
||||
useEffect(() => {
|
||||
const loadEventData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
if (!eventId) {
|
||||
setError('无效的事件ID');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 加载基本事件信息(免费用户也可以访问)
|
||||
const eventResponse = await eventService.getEventDetail(eventId);
|
||||
setEventData(eventResponse.data);
|
||||
|
||||
// 总是尝试加载相关股票(权限在组件内部检查)
|
||||
let stocksCount = 0;
|
||||
try {
|
||||
const stocksResponse = await eventService.getRelatedStocks(eventId);
|
||||
setRelatedStocks(stocksResponse.data || []);
|
||||
stocksCount = stocksResponse.data?.length || 0;
|
||||
} 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);
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await eventService.getEventDetail(eventId);
|
||||
setEventData(response.data);
|
||||
} catch (err) {
|
||||
logger.error('EventDetail', 'loadEventData', err, { eventId });
|
||||
setError(err.message || '加载事件数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refetchStocks = async () => {
|
||||
if (!hasFeatureAccess('related_stocks')) return;
|
||||
try {
|
||||
const stocksResponse = await eventService.getRelatedStocks(eventId);
|
||||
setRelatedStocks(stocksResponse.data);
|
||||
} catch (err) {
|
||||
logger.error('EventDetail', 'refetchStocks', err, { eventId: eventId });
|
||||
}
|
||||
};
|
||||
loadEventData();
|
||||
}, [eventId]);
|
||||
|
||||
const handleFollowToggle = async () => {
|
||||
try {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// 主要内容
|
||||
// 加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<Box bg={bgColor} minH="100vh" w="100%">
|
||||
{/* Navigation bar provided by MainLayout */}
|
||||
<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={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>
|
||||
<Box minH="100vh" w="100%">
|
||||
<Center py={20}>
|
||||
<Spinner size="xl" color="blue.500" />
|
||||
</Center>
|
||||
</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;
|
||||
|
||||
Reference in New Issue
Block a user