918 lines
38 KiB
JavaScript
918 lines
38 KiB
JavaScript
// 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; |