615 lines
23 KiB
JavaScript
615 lines
23 KiB
JavaScript
// src/views/Dashboard/Center.js
|
||
import React, { useEffect, useState, useCallback } from 'react';
|
||
import { logger } from '../../utils/logger';
|
||
import { getApiBase } from '../../utils/apiConfig';
|
||
import { useDashboardEvents } from '../../hooks/useDashboardEvents';
|
||
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 InvestmentPlanningCenter from './components/InvestmentPlanningCenter';
|
||
import { getEventDetailUrl } from '@/utils/idEncoder';
|
||
|
||
export default function CenterDashboard() {
|
||
const { user } = useAuth();
|
||
const location = useLocation();
|
||
const navigate = useNavigate();
|
||
const toast = useToast();
|
||
|
||
// ⚡ 提取 userId 为独立变量
|
||
const userId = user?.id;
|
||
|
||
// 🎯 初始化Dashboard埋点Hook
|
||
const dashboardEvents = useDashboardEvents({
|
||
pageType: 'center',
|
||
navigate
|
||
});
|
||
|
||
// 颜色主题
|
||
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 [loading, setLoading] = useState(true);
|
||
const [quotesLoading, setQuotesLoading] = useState(false);
|
||
|
||
const loadData = useCallback(async () => {
|
||
try {
|
||
const base = getApiBase();
|
||
const ts = Date.now();
|
||
|
||
const [w, e, c] = await Promise.all([
|
||
fetch(base + `/api/account/watchlist?_=${ts}`, { credentials: 'include', cache: 'no-store' }),
|
||
fetch(base + `/api/account/events/following?_=${ts}`, { credentials: 'include', cache: 'no-store' }),
|
||
fetch(base + `/api/account/events/posts?_=${ts}`, { credentials: 'include', cache: 'no-store' }),
|
||
]);
|
||
|
||
const jw = await w.json();
|
||
const je = await e.json();
|
||
const jc = await c.json();
|
||
if (jw.success) {
|
||
const watchlistData = Array.isArray(jw.data) ? jw.data : [];
|
||
setWatchlist(watchlistData);
|
||
|
||
// 🎯 追踪自选股列表查看
|
||
if (watchlistData.length > 0) {
|
||
dashboardEvents.trackWatchlistViewed(watchlistData.length, true);
|
||
}
|
||
|
||
// 加载实时行情
|
||
if (jw.data && jw.data.length > 0) {
|
||
loadRealtimeQuotes();
|
||
}
|
||
}
|
||
if (je.success) {
|
||
const eventsData = Array.isArray(je.data) ? je.data : [];
|
||
setFollowingEvents(eventsData);
|
||
|
||
// 🎯 追踪关注的事件列表查看
|
||
dashboardEvents.trackFollowingEventsViewed(eventsData.length);
|
||
}
|
||
if (jc.success) {
|
||
const commentsData = Array.isArray(jc.data) ? jc.data : [];
|
||
setEventComments(commentsData);
|
||
|
||
// 🎯 追踪评论列表查看
|
||
dashboardEvents.trackCommentsViewed(commentsData.length);
|
||
}
|
||
} catch (err) {
|
||
logger.error('Center', 'loadData', err, {
|
||
userId,
|
||
timestamp: new Date().toISOString()
|
||
});
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [userId]); // ⚡ 使用 userId 而不是 user?.id
|
||
|
||
// 加载实时行情
|
||
const loadRealtimeQuotes = useCallback(async () => {
|
||
try {
|
||
setQuotesLoading(true);
|
||
const base = getApiBase();
|
||
const response = await fetch(base + '/api/account/watchlist/realtime', {
|
||
credentials: 'include',
|
||
cache: 'no-store'
|
||
});
|
||
|
||
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) {
|
||
logger.error('Center', 'loadRealtimeQuotes', error, {
|
||
userId: user?.id,
|
||
watchlistLength: watchlist.length
|
||
});
|
||
} 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';
|
||
};
|
||
|
||
// 🔧 使用 ref 跟踪是否已经加载过数据(首次加载标记)
|
||
const hasLoadedRef = React.useRef(false);
|
||
|
||
useEffect(() => {
|
||
const isOnCenterPage = location.pathname.includes('/home/center');
|
||
|
||
// 首次进入页面且有用户时加载数据
|
||
if (user && isOnCenterPage && !hasLoadedRef.current) {
|
||
console.log('[Center] 🚀 首次加载数据');
|
||
hasLoadedRef.current = true;
|
||
loadData();
|
||
}
|
||
|
||
const onVis = () => {
|
||
if (document.visibilityState === 'visible' && location.pathname.includes('/home/center')) {
|
||
console.log('[Center] 👁️ visibilitychange 触发 loadData');
|
||
loadData();
|
||
}
|
||
};
|
||
document.addEventListener('visibilitychange', onVis);
|
||
return () => document.removeEventListener('visibilitychange', onVis);
|
||
}, [userId, location.pathname, loadData, user]);
|
||
|
||
// 当用户登出再登入(userId 变化)时,重置加载标记
|
||
useEffect(() => {
|
||
if (!user) {
|
||
hasLoadedRef.current = false;
|
||
}
|
||
}, [user]);
|
||
|
||
// 定时刷新实时行情(每分钟一次)
|
||
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" overflowX="hidden">
|
||
<Box px={{ base: 3, md: 8 }} py={{ base: 4, md: 6 }} maxW="1400px" mx="auto">
|
||
{/* 主要内容区域 */}
|
||
<Grid templateColumns={{ base: '1fr', lg: 'repeat(3, 1fr)' }} gap={6} mb={8}>
|
||
{/* 左列:自选股票 */}
|
||
<VStack spacing={6} align="stretch" minW={0}>
|
||
<Card bg={cardBg} shadow="md" maxW="100%" height={{ base: 'auto', md: '600px' }} minH={{ base: '300px', md: '600px' }} display="flex" flexDirection="column">
|
||
<CardHeader pb={{ base: 2, md: 4 }}>
|
||
<Flex justify="space-between" align="center">
|
||
<HStack>
|
||
<Icon as={FiBarChart2} color="blue.500" boxSize={{ base: 4, md: 5 }} />
|
||
<Heading size={{ base: 'sm', md: 'md' }}>自选股票</Heading>
|
||
<Badge colorScheme="blue" variant="subtle">
|
||
{watchlist.length}
|
||
</Badge>
|
||
{quotesLoading && <Spinner size="sm" color="blue.500" />}
|
||
</HStack>
|
||
<IconButton
|
||
icon={<FiPlus />}
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => navigate('/stocks')}
|
||
aria-label="添加自选股"
|
||
/>
|
||
</Flex>
|
||
</CardHeader>
|
||
<CardBody pt={0} flex="1" overflowY="auto">
|
||
{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('/stocks')}
|
||
>
|
||
添加自选股
|
||
</Button>
|
||
</VStack>
|
||
</Center>
|
||
) : (
|
||
<VStack align="stretch" spacing={2}>
|
||
{watchlist.slice(0, 10).map((stock) => (
|
||
<LinkBox
|
||
key={stock.stock_code}
|
||
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={`/company/${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('/stocks')}
|
||
>
|
||
查看全部 ({watchlist.length})
|
||
</Button>
|
||
)}
|
||
</VStack>
|
||
)}
|
||
</CardBody>
|
||
</Card>
|
||
</VStack>
|
||
|
||
{/* 中列:关注事件 */}
|
||
<VStack spacing={6} align="stretch" minW={0}>
|
||
{/* 关注事件 */}
|
||
<Card bg={cardBg} shadow="md" maxW="100%" height={{ base: 'auto', md: '600px' }} minH={{ base: '300px', md: '600px' }} display="flex" flexDirection="column">
|
||
<CardHeader pb={{ base: 2, md: 4 }}>
|
||
<Flex justify="space-between" align="center">
|
||
<HStack>
|
||
<Icon as={FiStar} color="yellow.500" boxSize={{ base: 4, md: 5 }} />
|
||
<Heading size={{ base: 'sm', md: 'md' }}>关注事件</Heading>
|
||
<Badge colorScheme="yellow" variant="subtle">
|
||
{followingEvents.length}
|
||
</Badge>
|
||
</HStack>
|
||
<IconButton
|
||
icon={<FiPlus />}
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => navigate('/community')}
|
||
aria-label="添加关注事件"
|
||
/>
|
||
</Flex>
|
||
</CardHeader>
|
||
<CardBody pt={0} flex="1" overflowY="auto">
|
||
{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={getEventDetailUrl(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>
|
||
|
||
</VStack>
|
||
|
||
{/* 右列:我的评论 */}
|
||
<VStack spacing={6} align="stretch" minW={0}>
|
||
{/* 我的评论 */}
|
||
<Card bg={cardBg} shadow="md" maxW="100%" height={{ base: 'auto', md: '600px' }} minH={{ base: '300px', md: '600px' }} display="flex" flexDirection="column">
|
||
<CardHeader pb={{ base: 2, md: 4 }}>
|
||
<Flex justify="space-between" align="center">
|
||
<HStack>
|
||
<Icon as={FiMessageSquare} color="purple.500" boxSize={{ base: 4, md: 5 }} />
|
||
<Heading size={{ base: 'sm', md: 'md' }}>我的评论</Heading>
|
||
<Badge colorScheme="purple" variant="subtle">
|
||
{eventComments.length}
|
||
</Badge>
|
||
</HStack>
|
||
</Flex>
|
||
</CardHeader>
|
||
<CardBody pt={0} flex="1" overflowY="auto">
|
||
{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} spacing={2}>
|
||
<HStack flexShrink={0}>
|
||
<Icon as={FiClock} />
|
||
<Text>{formatDate(comment.created_at)}</Text>
|
||
</HStack>
|
||
{comment.event_title && (
|
||
<Tooltip label={comment.event_title}>
|
||
<Badge
|
||
variant="subtle"
|
||
fontSize="xs"
|
||
maxW={{ base: '120px', md: '180px' }}
|
||
overflow="hidden"
|
||
textOverflow="ellipsis"
|
||
whiteSpace="nowrap"
|
||
>
|
||
{comment.event_title}
|
||
</Badge>
|
||
</Tooltip>
|
||
)}
|
||
</HStack>
|
||
</VStack>
|
||
</Box>
|
||
))}
|
||
{eventComments.length > 5 && (
|
||
<Text fontSize="sm" color={secondaryText} textAlign="center">
|
||
共 {eventComments.length} 条评论
|
||
</Text>
|
||
)}
|
||
</VStack>
|
||
)}
|
||
</CardBody>
|
||
</Card>
|
||
</VStack>
|
||
</Grid>
|
||
|
||
{/* 投资规划中心(整合了日历、计划、复盘) */}
|
||
<Box>
|
||
<InvestmentPlanningCenter />
|
||
</Box>
|
||
</Box>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
|