Files
vf_react/src/views/Community/components/EventList.js
2025-10-23 11:22:07 +08:00

918 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// src/views/Community/components/EventList.js
import React, { useState, useEffect } from 'react';
import {
Box,
VStack,
HStack,
Text,
Button,
Badge,
Tag,
TagLabel,
TagLeftIcon,
Flex,
Avatar,
Tooltip,
IconButton,
Divider,
Container,
useColorModeValue,
Circle,
Stat,
StatNumber,
StatHelpText,
StatArrow,
ButtonGroup,
Heading,
SimpleGrid,
Card,
CardBody,
Center,
Link,
Spacer,
Switch,
FormControl,
FormLabel,
useToast,
} from '@chakra-ui/react';
import {
ViewIcon,
ChatIcon,
StarIcon,
TimeIcon,
InfoIcon,
WarningIcon,
WarningTwoIcon,
CheckCircleIcon,
TriangleUpIcon,
TriangleDownIcon,
ArrowForwardIcon,
ExternalLinkIcon,
ViewOffIcon,
} from '@chakra-ui/icons';
import { useNavigate } from 'react-router-dom';
import moment from 'moment';
import { logger } from '../../../utils/logger';
import { getApiBase } from '../../../utils/apiConfig';
import { useEventNotifications } from '../../../hooks/useEventNotifications';
// ========== 工具函数定义在组件外部 ==========
// 涨跌颜色配置中国A股配色红涨绿跌- 分档次显示
const getPriceChangeColor = (value) => {
if (value === null || value === undefined) return 'gray.500';
const absValue = Math.abs(value);
if (value > 0) {
// 上涨用红色,根据涨幅大小使用不同深浅
if (absValue >= 3) return 'red.600'; // 深红色3%以上
if (absValue >= 1) return 'red.500'; // 中红色1-3%
return 'red.400'; // 浅红色0-1%
} else if (value < 0) {
// 下跌用绿色,根据跌幅大小使用不同深浅
if (absValue >= 3) return 'green.600'; // 深绿色3%以上
if (absValue >= 1) return 'green.500'; // 中绿色1-3%
return 'green.400'; // 浅绿色0-1%
}
return 'gray.500';
};
const getPriceChangeBg = (value) => {
if (value === null || value === undefined) return 'gray.50';
const absValue = Math.abs(value);
if (value > 0) {
// 上涨背景色
if (absValue >= 3) return 'red.100'; // 深色背景3%以上
if (absValue >= 1) return 'red.50'; // 中色背景1-3%
return 'red.50'; // 浅色背景0-1%
} else if (value < 0) {
// 下跌背景色
if (absValue >= 3) return 'green.100'; // 深色背景3%以上
if (absValue >= 1) return 'green.50'; // 中色背景1-3%
return 'green.50'; // 浅色背景0-1%
}
return 'gray.50';
};
const getPriceChangeBorderColor = (value) => {
if (value === null || value === undefined) return 'gray.300';
const absValue = Math.abs(value);
if (value > 0) {
// 上涨边框色
if (absValue >= 3) return 'red.500'; // 深边框3%以上
if (absValue >= 1) return 'red.400'; // 中边框1-3%
return 'red.300'; // 浅边框0-1%
} else if (value < 0) {
// 下跌边框色
if (absValue >= 3) return 'green.500'; // 深边框3%以上
if (absValue >= 1) return 'green.400'; // 中边框1-3%
return 'green.300'; // 浅边框0-1%
}
return 'gray.300';
};
// 重要性等级配置 - 金融配色方案
const importanceLevels = {
'S': {
color: 'purple.600',
bgColor: 'purple.50',
borderColor: 'purple.200',
icon: WarningIcon,
label: '极高',
dotBg: 'purple.500',
},
'A': {
color: 'red.600',
bgColor: 'red.50',
borderColor: 'red.200',
icon: WarningTwoIcon,
label: '高',
dotBg: 'red.500',
},
'B': {
color: 'orange.600',
bgColor: 'orange.50',
borderColor: 'orange.200',
icon: InfoIcon,
label: '中',
dotBg: 'orange.500',
},
'C': {
color: 'green.600',
bgColor: 'green.50',
borderColor: 'green.200',
icon: CheckCircleIcon,
label: '低',
dotBg: 'green.500',
}
};
const getImportanceConfig = (importance) => {
return importanceLevels[importance] || importanceLevels['C'];
};
// 自定义的涨跌箭头组件(修复颜色问题)
const PriceArrow = ({ value }) => {
if (value === null || value === undefined) return null;
const Icon = value > 0 ? TriangleUpIcon : TriangleDownIcon;
const color = value > 0 ? 'red.500' : 'green.500';
return <Icon color={color} boxSize="16px" />;
};
// ========== 主组件 ==========
const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetail }) => {
const navigate = useNavigate();
const toast = useToast();
const [isCompactMode, setIsCompactMode] = useState(false); // 新增:紧凑模式状态
const [followingMap, setFollowingMap] = useState({});
const [followCountMap, setFollowCountMap] = useState({});
const [localEvents, setLocalEvents] = useState(events); // 用于实时更新的本地事件列表
// 实时事件推送集成
const { isConnected } = useEventNotifications({
eventType: 'all',
importance: 'all',
enabled: true,
onNewEvent: (event) => {
logger.info('EventList', '收到新事件推送', event);
// 显示 Toast 通知
toast({
title: '新事件发布',
description: event.title,
status: 'info',
duration: 5000,
isClosable: true,
position: 'top-right',
variant: 'left-accent',
});
// 将新事件添加到列表顶部(防止重复)
setLocalEvents((prevEvents) => {
const exists = prevEvents.some(e => e.id === event.id);
if (exists) {
logger.debug('EventList', '事件已存在,跳过添加', { eventId: event.id });
return prevEvents;
}
logger.info('EventList', '新事件添加到列表顶部', { eventId: event.id });
// 添加到顶部,最多保留 100 个
return [event, ...prevEvents].slice(0, 100);
});
}
});
// 同步外部 events 到 localEvents
useEffect(() => {
setLocalEvents(events);
}, [events]);
// 初始化关注状态与计数
useEffect(() => {
// 初始化计数映射
const initCounts = {};
localEvents.forEach(ev => {
initCounts[ev.id] = ev.follower_count || 0;
});
setFollowCountMap(initCounts);
const loadFollowing = async () => {
try {
const base = getApiBase();
const res = await fetch(base + '/api/account/events/following', { credentials: 'include' });
const data = await res.json();
if (res.ok && data.success) {
const map = {};
(data.data || []).forEach(ev => { map[ev.id] = true; });
setFollowingMap(map);
logger.debug('EventList', '关注状态加载成功', {
followingCount: Object.keys(map).length
});
}
} catch (e) {
logger.warn('EventList', '加载关注状态失败', { error: e.message });
}
};
loadFollowing();
// 仅在 localEvents 更新时重跑
}, [localEvents]);
const toggleFollow = async (eventId) => {
try {
const base = getApiBase();
const res = await fetch(base + `/api/events/${eventId}/follow`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
});
const data = await res.json();
if (!res.ok || !data.success) throw new Error(data.error || '操作失败');
const isFollowing = data.data?.is_following;
const count = data.data?.follower_count ?? 0;
setFollowingMap(prev => ({ ...prev, [eventId]: isFollowing }));
setFollowCountMap(prev => ({ ...prev, [eventId]: count }));
logger.debug('EventList', '关注状态切换成功', {
eventId,
isFollowing,
followerCount: count
});
} catch (e) {
logger.warn('EventList', '关注操作失败', {
eventId,
error: e.message
});
}
};
// 专业的金融配色方案
const bgColor = useColorModeValue('gray.50', 'gray.900');
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textColor = useColorModeValue('gray.700', 'gray.200');
const mutedColor = useColorModeValue('gray.500', 'gray.400');
const linkColor = useColorModeValue('blue.600', 'blue.400');
const hoverBg = useColorModeValue('gray.50', 'gray.700');
const renderPriceChange = (value, label) => {
if (value === null || value === undefined) {
return (
<Tag size="lg" colorScheme="gray" borderRadius="full" variant="subtle">
<TagLabel fontSize="sm" fontWeight="medium">{label}: --</TagLabel>
</Tag>
);
}
const absValue = Math.abs(value);
const isPositive = value > 0;
// 根据涨跌幅大小选择不同的颜色深浅
let colorScheme = 'gray';
let variant = 'solid';
if (isPositive) {
// 上涨用红色系
if (absValue >= 3) {
colorScheme = 'red';
variant = 'solid'; // 深色
} else if (absValue >= 1) {
colorScheme = 'red';
variant = 'subtle'; // 中等
} else {
colorScheme = 'red';
variant = 'outline'; // 浅色
}
} else {
// 下跌用绿色系
if (absValue >= 3) {
colorScheme = 'green';
variant = 'solid'; // 深色
} else if (absValue >= 1) {
colorScheme = 'green';
variant = 'subtle'; // 中等
} else {
colorScheme = 'green';
variant = 'outline'; // 浅色
}
}
const Icon = isPositive ? TriangleUpIcon : TriangleDownIcon;
return (
<Tag
size="lg"
colorScheme={colorScheme}
borderRadius="full"
variant={variant}
boxShadow="sm"
_hover={{ transform: 'scale(1.05)', boxShadow: 'md' }}
transition="all 0.2s"
>
<TagLeftIcon as={Icon} boxSize="16px" />
<TagLabel fontSize="sm" fontWeight="bold">
{label}: {isPositive ? '+' : ''}{value.toFixed(2)}%
</TagLabel>
</Tag>
);
};
const handleTitleClick = (e, event) => {
e.preventDefault();
e.stopPropagation();
onEventClick(event);
};
const handleViewDetailClick = (e, eventId) => {
e.stopPropagation();
navigate(`/event-detail/${eventId}`);
};
// 精简模式的事件渲染
const renderCompactEvent = (event) => {
const importance = getImportanceConfig(event.importance);
const isFollowing = !!followingMap[event.id];
const followerCount = followCountMap[event.id] ?? (event.follower_count || 0);
return (
<HStack align="stretch" spacing={4} w="full">
{/* 时间线和重要性标记 */}
<VStack spacing={0} align="center">
<Circle
size="32px"
bg={importance.dotBg}
color="white"
fontWeight="bold"
fontSize="sm"
boxShadow="sm"
border="2px solid"
borderColor={cardBg}
>
{event.importance || 'C'}
</Circle>
<Box
w="2px"
flex="1"
bg={borderColor}
minH="60px"
/>
</VStack>
{/* 精简事件卡片 */}
<Card
flex="1"
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="lg"
boxShadow="sm"
_hover={{
boxShadow: 'md',
transform: 'translateY(-1px)',
borderColor: importance.color,
}}
transition="all 0.2s"
cursor="pointer"
onClick={() => onEventClick(event)}
mb={3}
>
<CardBody p={4}>
<Flex align="center" justify="space-between" wrap="wrap" gap={3}>
{/* 左侧:标题和时间 */}
<VStack align="start" spacing={2} flex="1" minW="200px">
<Heading
size="sm"
color={linkColor}
_hover={{ textDecoration: 'underline' }}
onClick={(e) => handleTitleClick(e, event)}
cursor="pointer"
noOfLines={1}
>
{event.title}
</Heading>
<HStack spacing={2} fontSize="xs" color={mutedColor}>
<TimeIcon />
<Text>{moment(event.created_at).format('MM-DD HH:mm')}</Text>
<Text></Text>
<Text>{event.creator?.username || 'Anonymous'}</Text>
</HStack>
</VStack>
{/* 右侧:涨跌幅指标 */}
<HStack spacing={3}>
<Tooltip label="平均涨幅" placement="top">
<Box
px={3}
py={1}
borderRadius="md"
bg={getPriceChangeBg(event.related_avg_chg)}
borderWidth="1px"
borderColor={getPriceChangeBorderColor(event.related_avg_chg)}
>
<HStack spacing={1}>
<PriceArrow value={event.related_avg_chg} />
<Text
fontSize="sm"
fontWeight="bold"
color={getPriceChangeColor(event.related_avg_chg)}
>
{event.related_avg_chg != null
? `${event.related_avg_chg > 0 ? '+' : ''}${event.related_avg_chg.toFixed(2)}%`
: '--'}
</Text>
</HStack>
</Box>
</Tooltip>
<Button
size="sm"
variant="ghost"
colorScheme="blue"
onClick={(e) => handleViewDetailClick(e, event.id)}
>
详情
</Button>
<Button
size="sm"
variant={isFollowing ? 'solid' : 'outline'}
colorScheme="yellow"
leftIcon={<StarIcon />}
onClick={(e) => {
e.stopPropagation();
toggleFollow(event.id);
}}
>
{isFollowing ? '已关注' : '关注'} {followerCount ? `(${followerCount})` : ''}
</Button>
</HStack>
</Flex>
</CardBody>
</Card>
</HStack>
);
};
// 详细模式的事件渲染(原有的渲染方式,但修复了箭头颜色)
const renderDetailedEvent = (event) => {
const importance = getImportanceConfig(event.importance);
const isFollowing = !!followingMap[event.id];
const followerCount = followCountMap[event.id] ?? (event.follower_count || 0);
return (
<HStack align="stretch" spacing={4} w="full">
{/* 时间线和重要性标记 */}
<VStack spacing={0} align="center">
<Circle
size="40px"
bg={importance.dotBg}
color="white"
fontWeight="bold"
fontSize="lg"
boxShadow="md"
border="3px solid"
borderColor={cardBg}
>
{event.importance || 'C'}
</Circle>
<Box
w="2px"
flex="1"
bg={borderColor}
minH="100px"
/>
</VStack>
{/* 事件卡片 */}
<Card
flex="1"
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="lg"
boxShadow="sm"
_hover={{
boxShadow: 'md',
transform: 'translateY(-2px)',
borderColor: importance.color,
}}
transition="all 0.2s"
cursor="pointer"
onClick={() => onEventClick(event)}
mb={4}
>
<CardBody p={5}>
<VStack align="stretch" spacing={3}>
{/* 标题和重要性标签 */}
<Flex align="center" justify="space-between">
<Tooltip
label="点击查看事件详情"
placement="top"
hasArrow
openDelay={500}
>
<Heading
size="md"
color={linkColor}
_hover={{ textDecoration: 'underline', color: 'blue.500' }}
onClick={(e) => handleTitleClick(e, event)}
cursor="pointer"
>
{event.title}
</Heading>
</Tooltip>
<Badge
colorScheme={importance.color.split('.')[0]}
px={3}
py={1}
borderRadius="full"
fontSize="sm"
>
{importance.label}优先级
</Badge>
</Flex>
{/* 元信息 */}
<HStack spacing={4} fontSize="sm">
<HStack
bg="blue.50"
px={3}
py={1}
borderRadius="full"
color="blue.700"
fontWeight="medium"
>
<TimeIcon />
<Text>{moment(event.created_at).format('YYYY-MM-DD HH:mm')}</Text>
</HStack>
<Text color={mutedColor}></Text>
<Text color={mutedColor}>{event.creator?.username || 'Anonymous'}</Text>
</HStack>
{/* 描述 */}
<Text color={textColor} fontSize="sm" lineHeight="tall" noOfLines={3}>
{event.description}
</Text>
{/* 价格变化指标 */}
<Box
bg={useColorModeValue('gradient.subtle', 'gray.700')}
bgGradient="linear(to-r, gray.50, white)"
p={4}
borderRadius="lg"
borderWidth="1px"
borderColor={borderColor}
boxShadow="sm"
>
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={3}>
<Tooltip label="点击查看相关股票" placement="top" hasArrow>
<Box
cursor="pointer"
p={2}
borderRadius="md"
bg={getPriceChangeBg(event.related_avg_chg)}
borderWidth="2px"
borderColor={getPriceChangeBorderColor(event.related_avg_chg)}
_hover={{ transform: 'scale(1.02)', boxShadow: 'md' }}
transition="all 0.2s"
>
<Stat size="sm">
<StatHelpText mb={1} fontWeight="semibold" color="gray.600" fontSize="xs">
平均涨幅
</StatHelpText>
<StatNumber fontSize="xl" color={getPriceChangeColor(event.related_avg_chg)}>
{event.related_avg_chg != null ? (
<HStack spacing={1}>
<PriceArrow value={event.related_avg_chg} />
<Text fontWeight="bold">
{event.related_avg_chg > 0 ? '+' : ''}{event.related_avg_chg.toFixed(2)}%
</Text>
</HStack>
) : (
<Text color="gray.400">--</Text>
)}
</StatNumber>
</Stat>
</Box>
</Tooltip>
<Tooltip label="点击查看相关股票" placement="top" hasArrow>
<Box
cursor="pointer"
p={2}
borderRadius="md"
bg={getPriceChangeBg(event.related_max_chg)}
borderWidth="2px"
borderColor={getPriceChangeBorderColor(event.related_max_chg)}
_hover={{ transform: 'scale(1.02)', boxShadow: 'md' }}
transition="all 0.2s"
>
<Stat size="sm">
<StatHelpText mb={1} fontWeight="semibold" color="gray.600" fontSize="xs">
最大涨幅
</StatHelpText>
<StatNumber fontSize="xl" color={getPriceChangeColor(event.related_max_chg)}>
{event.related_max_chg != null ? (
<HStack spacing={1}>
<PriceArrow value={event.related_max_chg} />
<Text fontWeight="bold">
{event.related_max_chg > 0 ? '+' : ''}{event.related_max_chg.toFixed(2)}%
</Text>
</HStack>
) : (
<Text color="gray.400">--</Text>
)}
</StatNumber>
</Stat>
</Box>
</Tooltip>
<Tooltip label="点击查看相关股票" placement="top" hasArrow>
<Box
cursor="pointer"
p={2}
borderRadius="md"
bg={getPriceChangeBg(event.related_week_chg)}
borderWidth="2px"
borderColor={getPriceChangeBorderColor(event.related_week_chg)}
_hover={{ transform: 'scale(1.02)', boxShadow: 'md' }}
transition="all 0.2s"
>
<Stat size="sm">
<StatHelpText mb={1} fontWeight="semibold" color="gray.600" fontSize="xs">
周涨幅
</StatHelpText>
<StatNumber fontSize="xl" color={getPriceChangeColor(event.related_week_chg)}>
{event.related_week_chg != null ? (
<HStack spacing={1}>
<PriceArrow value={event.related_week_chg} />
<Text fontWeight="bold">
{event.related_week_chg > 0 ? '+' : ''}{event.related_week_chg.toFixed(2)}%
</Text>
</HStack>
) : (
<Text color="gray.400">--</Text>
)}
</StatNumber>
</Stat>
</Box>
</Tooltip>
</SimpleGrid>
</Box>
<Divider />
{/* 统计信息和操作按钮 */}
<Flex justify="space-between" align="center" wrap="wrap" gap={3}>
<HStack spacing={6}>
<Tooltip label="浏览量" placement="top">
<HStack spacing={1} color={mutedColor}>
<ViewIcon />
<Text fontSize="sm">{event.view_count || 0}</Text>
</HStack>
</Tooltip>
<Tooltip label="帖子数" placement="top">
<HStack spacing={1} color={mutedColor}>
<ChatIcon />
<Text fontSize="sm">{event.post_count || 0}</Text>
</HStack>
</Tooltip>
<Tooltip label="关注数" placement="top">
<HStack spacing={1} color={mutedColor}>
<StarIcon />
<Text fontSize="sm">{followerCount}</Text>
</HStack>
</Tooltip>
</HStack>
<ButtonGroup size="sm" spacing={2}>
<Button
variant="outline"
colorScheme="gray"
leftIcon={<ViewIcon />}
onClick={(e) => {
e.stopPropagation();
onEventClick(event);
}}
>
快速查看
</Button>
<Button
colorScheme="blue"
leftIcon={<ExternalLinkIcon />}
onClick={(e) => handleViewDetailClick(e, event.id)}
>
详细信息
</Button>
<Button
colorScheme="yellow"
variant={isFollowing ? 'solid' : 'outline'}
leftIcon={<StarIcon />}
onClick={(e) => {
e.stopPropagation();
toggleFollow(event.id);
}}
>
{isFollowing ? '已关注' : '关注'}
</Button>
</ButtonGroup>
</Flex>
</VStack>
</CardBody>
</Card>
</HStack>
);
};
// 分页组件
const Pagination = ({ current, total, pageSize, onChange }) => {
const totalPages = Math.ceil(total / pageSize);
// 计算要显示的页码数组(智能分页)
const getPageNumbers = () => {
const delta = 2; // 当前页左右各显示2个页码
const range = [];
const rangeWithDots = [];
// 始终显示第1页
range.push(1);
// 显示当前页附近的页码
for (let i = current - delta; i <= current + delta; i++) {
if (i > 1 && i < totalPages) {
range.push(i);
}
}
// 始终显示最后一页(如果总页数>1
if (totalPages > 1) {
range.push(totalPages);
}
// 去重并排序
const uniqueRange = [...new Set(range)].sort((a, b) => a - b);
// 添加省略号
let prev = 0;
for (const page of uniqueRange) {
if (page - prev === 2) {
// 如果只差一个页码,直接显示
rangeWithDots.push(prev + 1);
} else if (page - prev > 2) {
// 如果差距大于2显示省略号
rangeWithDots.push('...');
}
rangeWithDots.push(page);
prev = page;
}
return rangeWithDots;
};
const pageNumbers = getPageNumbers();
return (
<Flex justify="center" align="center" mt={8} gap={2}>
<Button
size="sm"
variant="outline"
onClick={() => onChange(current - 1)}
isDisabled={current === 1}
>
上一页
</Button>
<HStack spacing={1}>
{pageNumbers.map((page, index) => {
if (page === '...') {
return (
<Text key={`ellipsis-${index}`} px={2} color="gray.500">
...
</Text>
);
}
return (
<Button
key={page}
size="sm"
variant={current === page ? 'solid' : 'ghost'}
colorScheme={current === page ? 'blue' : 'gray'}
onClick={() => onChange(page)}
>
{page}
</Button>
);
})}
</HStack>
<Button
size="sm"
variant="outline"
onClick={() => onChange(current + 1)}
isDisabled={current === totalPages}
>
下一页
</Button>
<Text fontSize="sm" color={mutedColor} ml={4}>
{total}
</Text>
</Flex>
);
};
return (
<Box bg={bgColor} minH="100vh" py={8}>
<Container maxW="container.xl">
{/* 顶部控制栏:连接状态 + 视图切换 */}
<Flex justify="space-between" align="center" mb={6}>
{/* WebSocket 连接状态指示器 */}
<HStack spacing={2}>
<Badge
colorScheme={isConnected ? 'green' : 'red'}
fontSize="sm"
px={3}
py={1}
borderRadius="full"
>
{isConnected ? '🟢 实时推送已开启' : '🔴 实时推送未连接'}
</Badge>
{isConnected && (
<Text fontSize="xs" color={mutedColor}>
新事件将自动推送
</Text>
)}
</HStack>
{/* 视图切换控制 */}
<FormControl display="flex" alignItems="center" w="auto">
<FormLabel htmlFor="compact-mode" mb="0" fontSize="sm" color={textColor}>
精简模式
</FormLabel>
<Switch
id="compact-mode"
isChecked={isCompactMode}
onChange={(e) => setIsCompactMode(e.target.checked)}
colorScheme="blue"
/>
</FormControl>
</Flex>
{localEvents.length > 0 ? (
<VStack align="stretch" spacing={0}>
{localEvents.map((event, index) => (
<Box key={event.id} position="relative">
{isCompactMode
? renderCompactEvent(event)
: renderDetailedEvent(event)
}
</Box>
))}
</VStack>
) : (
<Center h="300px">
<VStack spacing={4}>
<InfoIcon boxSize={12} color={mutedColor} />
<Text color={mutedColor} fontSize="lg">
暂无事件数据
</Text>
</VStack>
</Center>
)}
{pagination.total > 0 && (
<Pagination
current={pagination.current}
total={pagination.total}
pageSize={pagination.pageSize}
onChange={onPageChange}
/>
)}
</Container>
</Box>
);
};
export default EventList;