fix: 调整事件详情页面
This commit is contained in:
@@ -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 />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 { 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 背景透明
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,585 +1,71 @@
|
|||||||
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,
|
|
||||||
HStack,
|
|
||||||
Spinner,
|
Spinner,
|
||||||
|
Center,
|
||||||
Alert,
|
Alert,
|
||||||
AlertIcon,
|
AlertIcon,
|
||||||
AlertTitle,
|
AlertTitle,
|
||||||
AlertDescription,
|
AlertDescription,
|
||||||
Flex,
|
|
||||||
useColorModeValue,
|
|
||||||
Grid,
|
|
||||||
GridItem,
|
|
||||||
Icon,
|
|
||||||
Text,
|
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 { hasFeatureAccess, getUpgradeRecommendation } = useSubscription();
|
|
||||||
|
|
||||||
// 滚动位置管理
|
|
||||||
const scrollPositionRef = useRef(0);
|
|
||||||
|
|
||||||
// State hooks
|
|
||||||
const [eventData, setEventData] = useState(null);
|
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 [loading, setLoading] = useState(true);
|
||||||
const [postsLoading, setPostsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState(null);
|
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 () => {
|
||||||
|
if (!eventId) {
|
||||||
|
setError('无效的事件ID');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
const response = await eventService.getEventDetail(eventId);
|
||||||
// 加载基本事件信息(免费用户也可以访问)
|
setEventData(response.data);
|
||||||
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) {
|
} catch (err) {
|
||||||
logger.error('EventDetail', 'loadEventData', err, { eventId: eventId });
|
logger.error('EventDetail', 'loadEventData', err, { eventId });
|
||||||
setError(err.message || '加载事件数据失败');
|
setError(err.message || '加载事件数据失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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();
|
loadEventData();
|
||||||
loadPosts();
|
|
||||||
|
|
||||||
// 数据加载完成后恢复滚动位置
|
|
||||||
// 使用 setTimeout 确保 DOM 已更新
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
restoreScrollPosition();
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
} else {
|
|
||||||
setError('无效的事件ID');
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [eventId]);
|
}, [eventId]);
|
||||||
|
|
||||||
// 加载状态
|
// 加载状态
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Box bg={bgColor} minH="100vh" w="100%" p={4}>
|
<Box minH="100vh" w="100%">
|
||||||
<Container maxW="7xl" py={8}>
|
<Center py={20}>
|
||||||
<VStack spacing={6}>
|
<Spinner size="xl" color="blue.500" />
|
||||||
<Skeleton height="150px" borderRadius="lg" />
|
</Center>
|
||||||
<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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -587,7 +73,7 @@ const EventDetail = () => {
|
|||||||
// 错误状态
|
// 错误状态
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Box bg={bgColor} minH="100vh" w="100%" p={4}>
|
<Box minH="100vh" w="100%" p={4}>
|
||||||
<Container maxW="7xl" py={8}>
|
<Container maxW="7xl" py={8}>
|
||||||
<Center minH="60vh">
|
<Center minH="60vh">
|
||||||
<Alert
|
<Alert
|
||||||
@@ -617,282 +103,9 @@ const EventDetail = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 主要内容
|
// 主内容
|
||||||
return (
|
return (
|
||||||
<Box bg={bgColor} minH="100vh" w="100%">
|
<Box maxW="7xl" mx="auto"><DynamicNewsDetailPanel event={eventData} showHeader={true} /></Box>
|
||||||
{/* 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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user