Initial commit

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

View File

@@ -0,0 +1,734 @@
// src/views/Dashboard/Center.js
import React, { useEffect, useState, useCallback } from 'react';
import {
Box,
Flex,
Grid,
SimpleGrid,
Stack,
Text,
Badge,
Button,
VStack,
HStack,
Card,
CardHeader,
CardBody,
Heading,
useColorModeValue,
Icon,
IconButton,
Stat,
StatLabel,
StatNumber,
StatHelpText,
StatArrow,
Divider,
Tag,
TagLabel,
TagLeftIcon,
Wrap,
WrapItem,
Avatar,
Tooltip,
Progress,
useToast,
LinkBox,
LinkOverlay,
Spinner,
Center,
Image,
} from '@chakra-ui/react';
import { useAuth } from '../../contexts/AuthContext';
import { useLocation, useNavigate, Link } from 'react-router-dom';
import {
FiTrendingUp,
FiEye,
FiMessageSquare,
FiThumbsUp,
FiClock,
FiCalendar,
FiRefreshCw,
FiTrash2,
FiExternalLink,
FiPlus,
FiBarChart2,
FiStar,
FiActivity,
FiAlertCircle,
} from 'react-icons/fi';
import MyFutureEvents from './components/MyFutureEvents';
import InvestmentCalendarChakra from './components/InvestmentCalendarChakra';
import InvestmentPlansAndReviews from './components/InvestmentPlansAndReviews';
export default function CenterDashboard() {
const { user } = useAuth();
const location = useLocation();
const navigate = useNavigate();
const toast = useToast();
// 颜色主题
const textColor = useColorModeValue('gray.700', 'white');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const bgColor = useColorModeValue('white', 'gray.800');
const hoverBg = useColorModeValue('gray.50', 'gray.700');
const secondaryText = useColorModeValue('gray.600', 'gray.400');
const cardBg = useColorModeValue('white', 'gray.800');
const sectionBg = useColorModeValue('gray.50', 'gray.900');
const [watchlist, setWatchlist] = useState([]);
const [realtimeQuotes, setRealtimeQuotes] = useState({});
const [followingEvents, setFollowingEvents] = useState([]);
const [eventComments, setEventComments] = useState([]);
const [subscriptionInfo, setSubscriptionInfo] = useState({ type: 'free', status: 'active', days_left: 999, is_active: true });
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [quotesLoading, setQuotesLoading] = useState(false);
const loadData = useCallback(async () => {
try {
setRefreshing(true);
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
const ts = Date.now();
const [w, e, c, s] = await Promise.all([
fetch(base + `/api/account/watchlist?_=${ts}`, { credentials: 'include', cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } }),
fetch(base + `/api/account/events/following?_=${ts}`, { credentials: 'include', cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } }),
fetch(base + `/api/account/events/comments?_=${ts}`, { credentials: 'include', cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } }),
fetch(base + `/api/subscription/current?_=${ts}`, { credentials: 'include', cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } }),
]);
const jw = await w.json();
const je = await e.json();
const jc = await c.json();
const js = await s.json();
if (jw.success) {
setWatchlist(Array.isArray(jw.data) ? jw.data : []);
// 加载实时行情
if (jw.data && jw.data.length > 0) {
loadRealtimeQuotes();
}
}
if (je.success) setFollowingEvents(Array.isArray(je.data) ? je.data : []);
if (jc.success) setEventComments(Array.isArray(jc.data) ? jc.data : []);
if (js.success) setSubscriptionInfo(js.data);
} catch (err) {
console.warn('加载个人中心数据失败', err);
toast({
title: '数据加载失败',
description: '请检查网络连接后重试',
status: 'error',
duration: 3000,
isClosable: true,
});
} finally {
setLoading(false);
setRefreshing(false);
}
}, [user, toast]);
// 加载实时行情
const loadRealtimeQuotes = useCallback(async () => {
try {
setQuotesLoading(true);
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
const response = await fetch(base + '/api/account/watchlist/realtime', {
credentials: 'include',
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache' }
});
if (response.ok) {
const data = await response.json();
if (data.success) {
const quotesMap = {};
data.data.forEach(item => {
quotesMap[item.stock_code] = item;
});
setRealtimeQuotes(quotesMap);
}
}
} catch (error) {
console.error('加载实时行情失败:', error);
} finally {
setQuotesLoading(false);
}
}, []);
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return '';
const date = new Date(dateString);
const now = new Date();
const diffTime = Math.abs(now - date);
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays < 1) {
const diffHours = Math.ceil(diffTime / (1000 * 60 * 60));
if (diffHours < 1) {
const diffMinutes = Math.ceil(diffTime / (1000 * 60));
return `${diffMinutes}分钟前`;
}
return `${diffHours}小时前`;
} else if (diffDays < 7) {
return `${diffDays}天前`;
} else {
return date.toLocaleDateString('zh-CN');
}
};
// 格式化数字
const formatNumber = (num) => {
if (!num) return '0';
if (num >= 10000) {
return (num / 10000).toFixed(1) + 'w';
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'k';
}
return num.toString();
};
// 获取事件热度颜色
const getHeatColor = (score) => {
if (score >= 80) return 'red';
if (score >= 60) return 'orange';
if (score >= 40) return 'yellow';
return 'green';
};
useEffect(() => {
if (user && location.pathname.includes('/home/center')) {
loadData();
}
const onVis = () => {
if (document.visibilityState === 'visible' && location.pathname.includes('/home/center')) {
loadData();
}
};
document.addEventListener('visibilitychange', onVis);
return () => document.removeEventListener('visibilitychange', onVis);
}, [user, location.pathname, loadData]);
// 定时刷新实时行情(每分钟一次)
useEffect(() => {
if (watchlist.length > 0) {
const interval = setInterval(() => {
loadRealtimeQuotes();
}, 60000); // 60秒刷新一次
return () => clearInterval(interval);
}
}, [watchlist.length, loadRealtimeQuotes]);
// 渲染加载状态
if (loading) {
return (
<Center h="60vh">
<VStack spacing={4}>
<Spinner size="xl" color="blue.500" thickness="4px" />
<Text color={secondaryText}>加载个人中心数据...</Text>
</VStack>
</Center>
);
}
return (
<Box bg={sectionBg} minH="100vh">
<Box px={{ base: 4, md: 8 }} py={6} maxW="1400px" mx="auto">
{/* 头部 */}
<Flex justify="space-between" align="center" mb={8}>
<VStack align="start" spacing={1}>
<Heading size="lg" color={textColor}>
个人中心
</Heading>
<Text color={secondaryText} fontSize="sm">
管理您的自选股事件关注和互动记录
</Text>
</VStack>
<Button
leftIcon={<FiRefreshCw />}
onClick={loadData}
isLoading={refreshing}
loadingText="刷新中"
variant="solid"
colorScheme="blue"
size="sm"
>
刷新数据
</Button>
</Flex>
{/* 统计卡片 */}
<SimpleGrid columns={{ base: 1, sm: 2, md: 4 }} spacing={4} mb={8}>
<Card bg={cardBg} shadow="sm">
<CardBody>
<Stat>
<StatLabel color={secondaryText}>自选股票</StatLabel>
<StatNumber fontSize="2xl">{watchlist.length}</StatNumber>
<StatHelpText>
<Icon as={FiTrendingUp} color="green.500" mr={1} />
关注市场动态
</StatHelpText>
</Stat>
</CardBody>
</Card>
<Card bg={cardBg} shadow="sm">
<CardBody>
<Stat>
<StatLabel color={secondaryText}>关注事件</StatLabel>
<StatNumber fontSize="2xl">{followingEvents.length}</StatNumber>
<StatHelpText>
<Icon as={FiActivity} color="blue.500" mr={1} />
追踪热点事件
</StatHelpText>
</Stat>
</CardBody>
</Card>
<Card bg={cardBg} shadow="sm">
<CardBody>
<Stat>
<StatLabel color={secondaryText}>我的评论</StatLabel>
<StatNumber fontSize="2xl">{eventComments.length}</StatNumber>
<StatHelpText>
<Icon as={FiMessageSquare} color="purple.500" mr={1} />
参与讨论
</StatHelpText>
</Stat>
</CardBody>
</Card>
<Card bg={cardBg} shadow="sm" cursor="pointer" onClick={() => navigate('/home/pages/account/subscription')} _hover={{ transform: 'translateY(-2px)', shadow: 'lg' }} transition="all 0.2s">
<CardBody>
<Stat>
<StatLabel color={secondaryText}>订阅状态</StatLabel>
<StatNumber fontSize="xl" color={subscriptionInfo.type === 'free' ? 'gray.500' : subscriptionInfo.type === 'pro' ? 'blue.500' : 'purple.500'}>
{subscriptionInfo.type === 'free' ? '免费版' : subscriptionInfo.type === 'pro' ? 'Pro版' : 'Max版'}
</StatNumber>
<StatHelpText>
<Icon as={FiStar} color={subscriptionInfo.type === 'free' ? 'gray.400' : 'orange.400'} mr={1} />
{subscriptionInfo.type === 'free' ? '点击升级' : `剩余${subscriptionInfo.days_left}`}
</StatHelpText>
</Stat>
</CardBody>
</Card>
</SimpleGrid>
{/* 投资日历 */}
<Box mb={8}>
<InvestmentCalendarChakra />
</Box>
{/* 主要内容区域 */}
<Grid templateColumns={{ base: '1fr', lg: '1fr 2fr' }} gap={6}>
{/* 左侧:自选股 */}
<VStack spacing={6} align="stretch">
<Card bg={cardBg} shadow="md">
<CardHeader pb={4}>
<Flex justify="space-between" align="center">
<HStack>
<Icon as={FiBarChart2} color="blue.500" boxSize={5} />
<Heading size="md">自选股票</Heading>
<Badge colorScheme="blue" variant="subtle">
{watchlist.length}
</Badge>
{quotesLoading && <Spinner size="sm" color="blue.500" />}
</HStack>
<HStack>
<IconButton
icon={<FiRefreshCw />}
variant="ghost"
size="sm"
onClick={loadRealtimeQuotes}
isLoading={quotesLoading}
aria-label="刷新行情"
/>
<IconButton
icon={<FiPlus />}
variant="ghost"
size="sm"
onClick={() => navigate('/stock-analysis/overview')}
aria-label="添加自选股"
/>
</HStack>
</Flex>
</CardHeader>
<CardBody pt={0}>
{watchlist.length === 0 ? (
<Center py={8}>
<VStack spacing={3}>
<Icon as={FiBarChart2} boxSize={12} color="gray.300" />
<Text color={secondaryText} fontSize="sm">
暂无自选股
</Text>
<Button
size="sm"
variant="outline"
colorScheme="blue"
onClick={() => navigate('/stock-analysis/overview')}
>
添加自选股
</Button>
</VStack>
</Center>
) : (
<VStack align="stretch" spacing={2}>
{watchlist.slice(0, 10).map((stock) => (
<LinkBox
key={stock.id}
p={3}
borderRadius="md"
_hover={{ bg: hoverBg }}
transition="all 0.2s"
cursor="pointer"
>
<HStack justify="space-between">
<VStack align="start" spacing={0}>
<LinkOverlay
as={Link}
to={`/stock-analysis/company?scode=${stock.stock_code}`}
>
<Text fontWeight="medium" fontSize="sm">
{stock.stock_name || stock.stock_code}
</Text>
</LinkOverlay>
<HStack spacing={2}>
<Badge variant="subtle" fontSize="xs">
{stock.stock_code}
</Badge>
{realtimeQuotes[stock.stock_code] ? (
<Badge
colorScheme={realtimeQuotes[stock.stock_code].change_percent > 0 ? 'red' : 'green'}
fontSize="xs"
>
{realtimeQuotes[stock.stock_code].change_percent > 0 ? '+' : ''}
{realtimeQuotes[stock.stock_code].change_percent.toFixed(2)}%
</Badge>
) : stock.change_percent ? (
<Badge
colorScheme={stock.change_percent > 0 ? 'red' : 'green'}
fontSize="xs"
>
{stock.change_percent > 0 ? '+' : ''}
{stock.change_percent}%
</Badge>
) : null}
</HStack>
</VStack>
<VStack align="end" spacing={0}>
<Text fontWeight="bold" fontSize="sm">
{realtimeQuotes[stock.stock_code]?.current_price?.toFixed(2) || stock.current_price || '--'}
</Text>
<Text fontSize="xs" color={secondaryText}>
{realtimeQuotes[stock.stock_code]?.update_time || stock.industry || '未分类'}
</Text>
</VStack>
</HStack>
</LinkBox>
))}
{watchlist.length > 10 && (
<Button
size="sm"
variant="ghost"
onClick={() => navigate('/stock-analysis/overview')}
>
查看全部 ({watchlist.length})
</Button>
)}
</VStack>
)}
</CardBody>
</Card>
{/* 订阅管理 */}
<Card bg={cardBg} shadow="md">
<CardHeader pb={4}>
<Flex justify="space-between" align="center">
<HStack>
<Icon as={FiStar} color={subscriptionInfo.type === 'free' ? 'gray.500' : subscriptionInfo.type === 'pro' ? 'blue.500' : 'purple.500'} boxSize={5} />
<Heading size="md">我的订阅</Heading>
<Badge
colorScheme={subscriptionInfo.type === 'free' ? 'gray' : subscriptionInfo.type === 'pro' ? 'blue' : 'purple'}
variant="subtle"
>
{subscriptionInfo.type === 'free' ? '免费版' : subscriptionInfo.type === 'pro' ? 'Pro版' : 'Max版'}
</Badge>
</HStack>
<Button
size="sm"
variant="ghost"
colorScheme={subscriptionInfo.type === 'free' ? 'blue' : 'purple'}
onClick={() => navigate('/home/pages/account/subscription')}
>
{subscriptionInfo.type === 'free' ? '升级' : '管理'}
</Button>
</Flex>
</CardHeader>
<CardBody pt={0}>
<VStack align="stretch" spacing={4}>
<Box p={4} borderRadius="md" bg={subscriptionInfo.type === 'free' ? 'gray.50' : subscriptionInfo.type === 'pro' ? 'blue.50' : 'purple.50'} border="1px" borderColor={subscriptionInfo.type === 'free' ? 'gray.200' : subscriptionInfo.type === 'pro' ? 'blue.200' : 'purple.200'}>
<HStack justify="space-between">
<VStack align="start" spacing={1}>
<Text fontSize="sm" fontWeight="medium" color={textColor}>
当前套餐
</Text>
<Text fontSize="lg" fontWeight="bold" color={subscriptionInfo.type === 'free' ? 'gray.600' : subscriptionInfo.type === 'pro' ? 'blue.600' : 'purple.600'}>
{subscriptionInfo.type === 'free' ? '免费版' : subscriptionInfo.type === 'pro' ? 'Pro版本' : 'Max版本'}
</Text>
</VStack>
<VStack align="end" spacing={1}>
<Text fontSize="sm" color={secondaryText}>
{subscriptionInfo.type === 'free' ? '永久免费' : subscriptionInfo.is_active ? '已激活' : '已过期'}
</Text>
{subscriptionInfo.type !== 'free' && (
<Text fontSize="xs" color={subscriptionInfo.days_left > 7 ? 'green.500' : 'orange.500'}>
剩余 {subscriptionInfo.days_left}
</Text>
)}
</VStack>
</HStack>
</Box>
{subscriptionInfo.type === 'free' ? (
<VStack spacing={2}>
<Text fontSize="sm" color={secondaryText} textAlign="center">
升级到Pro或Max版本解锁更多功能
</Text>
<HStack spacing={2}>
<Button size="xs" colorScheme="blue" variant="outline" onClick={() => navigate('/home/pages/account/subscription')}>
Pro ¥0.01/
</Button>
<Button size="xs" colorScheme="purple" variant="outline" onClick={() => navigate('/home/pages/account/subscription')}>
Max ¥0.1/
</Button>
</HStack>
</VStack>
) : (
<Box textAlign="center">
<Text fontSize="sm" color={subscriptionInfo.is_active ? 'green.600' : 'orange.600'}>
{subscriptionInfo.is_active ? '订阅服务正常' : '订阅已过期,请续费'}
</Text>
</Box>
)}
</VStack>
</CardBody>
</Card>
</VStack>
{/* 右侧:事件相关 */}
<VStack spacing={6} align="stretch">
{/* 关注事件 */}
<Card bg={cardBg} shadow="md">
<CardHeader pb={4}>
<Flex justify="space-between" align="center">
<HStack>
<Icon as={FiStar} color="yellow.500" boxSize={5} />
<Heading size="md">关注事件</Heading>
<Badge colorScheme="yellow" variant="subtle">
{followingEvents.length}
</Badge>
</HStack>
<Button
size="sm"
variant="ghost"
onClick={() => navigate('/community')}
>
查看更多
</Button>
</Flex>
</CardHeader>
<CardBody pt={0}>
{followingEvents.length === 0 ? (
<Center py={8}>
<VStack spacing={3}>
<Icon as={FiActivity} boxSize={12} color="gray.300" />
<Text color={secondaryText} fontSize="sm">
暂无关注事件
</Text>
<Button
size="sm"
variant="outline"
colorScheme="blue"
onClick={() => navigate('/community')}
>
探索事件
</Button>
</VStack>
</Center>
) : (
<VStack align="stretch" spacing={3}>
{followingEvents.slice(0, 5).map((event) => (
<LinkBox
key={event.id}
p={4}
borderRadius="lg"
border="1px"
borderColor={borderColor}
_hover={{ shadow: 'md', transform: 'translateY(-2px)' }}
transition="all 0.2s"
>
<VStack align="stretch" spacing={3}>
<LinkOverlay
as={Link}
to={`/event-detail/${event.id}`}
>
<Text fontWeight="medium" fontSize="md" noOfLines={2}>
{event.title}
</Text>
</LinkOverlay>
{/* 事件标签 */}
{event.tags && event.tags.length > 0 && (
<Wrap>
{event.tags.slice(0, 3).map((tag, idx) => (
<WrapItem key={idx}>
<Tag size="sm" variant="subtle" colorScheme="blue">
<TagLabel>{tag}</TagLabel>
</Tag>
</WrapItem>
))}
</Wrap>
)}
{/* 事件统计 */}
<HStack spacing={4} fontSize="sm" color={secondaryText}>
<HStack spacing={1}>
<Icon as={FiEye} />
<Text>{formatNumber(event.view_count || 0)}</Text>
</HStack>
<HStack spacing={1}>
<Icon as={FiMessageSquare} />
<Text>{formatNumber(event.comment_count || 0)}</Text>
</HStack>
<HStack spacing={1}>
<Icon as={FiThumbsUp} />
<Text>{formatNumber(event.upvote_count || 0)}</Text>
</HStack>
{event.heat_score && (
<Badge colorScheme={getHeatColor(event.heat_score)} variant="subtle">
热度 {event.heat_score}
</Badge>
)}
</HStack>
{/* 事件信息 */}
<Flex justify="space-between" align="center">
<HStack spacing={2} fontSize="xs" color={secondaryText}>
<Avatar
size="xs"
name={event.creator?.username || '系统'}
src={event.creator?.avatar_url}
/>
<Text>{event.creator?.username || '系统'}</Text>
<Text>·</Text>
<Text>{formatDate(event.created_at)}</Text>
</HStack>
{event.exceed_expectation_score && (
<Badge
colorScheme={event.exceed_expectation_score > 70 ? 'red' : 'orange'}
variant="solid"
fontSize="xs"
>
超预期 {event.exceed_expectation_score}
</Badge>
)}
</Flex>
</VStack>
</LinkBox>
))}
{followingEvents.length > 5 && (
<Button
size="sm"
variant="ghost"
onClick={() => navigate('/community')}
>
查看全部 ({followingEvents.length})
</Button>
)}
</VStack>
)}
</CardBody>
</Card>
{/* 移除“未来事件”板块,根据需求不再展示 */}
{/* 我的评论 */}
<Card bg={cardBg} shadow="md">
<CardHeader pb={4}>
<Flex justify="space-between" align="center">
<HStack>
<Icon as={FiMessageSquare} color="purple.500" boxSize={5} />
<Heading size="md">我的评论</Heading>
<Badge colorScheme="purple" variant="subtle">
{eventComments.length}
</Badge>
</HStack>
</Flex>
</CardHeader>
<CardBody pt={0}>
{eventComments.length === 0 ? (
<Center py={8}>
<VStack spacing={3}>
<Icon as={FiMessageSquare} boxSize={12} color="gray.300" />
<Text color={secondaryText} fontSize="sm">
暂无评论记录
</Text>
<Text color={secondaryText} fontSize="xs" textAlign="center">
参与事件讨论分享您的观点
</Text>
</VStack>
</Center>
) : (
<VStack align="stretch" spacing={3}>
{eventComments.slice(0, 5).map((comment) => (
<Box
key={comment.id}
p={3}
borderRadius="md"
border="1px"
borderColor={borderColor}
_hover={{ bg: hoverBg }}
transition="all 0.2s"
>
<VStack align="stretch" spacing={2}>
<Text fontSize="sm" noOfLines={3}>
{comment.content}
</Text>
<HStack justify="space-between" fontSize="xs" color={secondaryText}>
<HStack>
<Icon as={FiClock} />
<Text>{formatDate(comment.created_at)}</Text>
</HStack>
{comment.event_title && (
<Tooltip label={comment.event_title}>
<Badge variant="subtle" fontSize="xs">
{comment.event_title.slice(0, 20)}...
</Badge>
</Tooltip>
)}
</HStack>
</VStack>
</Box>
))}
{eventComments.length > 5 && (
<Text fontSize="sm" color={secondaryText} textAlign="center">
{eventComments.length} 条评论
</Text>
)}
</VStack>
)}
</CardBody>
</Card>
</VStack>
</Grid>
{/* 我的复盘和计划 */}
<Box mt={8}>
<InvestmentPlansAndReviews />
</Box>
</Box>
</Box>
);
}