Files
vf_react/src/views/Community/components/EventList.js

1096 lines
50 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,
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';
import { getImportanceConfig, getAllImportanceLevels } from '../../../constants/importanceLevels';
import { browserNotificationService } from '../../../services/browserNotificationService';
import { useNotification } from '../../../contexts/NotificationContext';
// 导入价格相关工具函数
import {
getPriceChangeColor,
getPriceChangeBg,
getPriceChangeBorderColor,
PriceArrow,
} from '../../../utils/priceFormatters';
// 导入动画定义
import { pulseAnimation } from '../../../constants/animations';
// ========== 主组件 ==========
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 [expandedDescriptions, setExpandedDescriptions] = useState({}); // 描述展开状态映射
// 从 NotificationContext 获取推送权限相关状态和方法
const { browserPermission, requestBrowserPermission } = useNotification();
// 实时事件推送集成
const { isConnected } = useEventNotifications({
eventType: 'all',
importance: 'all',
enabled: true,
onNewEvent: (event) => {
console.log('\n[EventList DEBUG] ========== EventList 收到新事件 ==========');
console.log('[EventList DEBUG] 事件数据:', event);
console.log('[EventList DEBUG] 事件 ID:', event?.id);
console.log('[EventList DEBUG] 事件标题:', event?.title);
logger.info('EventList', '收到新事件推送', event);
console.log('[EventList DEBUG] 准备显示 Toast 通知');
// 显示 Toast 通知 - 更明显的配置
const toastId = toast({
title: '🔔 新事件发布',
description: event.title,
status: 'success', // 改为 success更醒目
duration: 8000, // 延长显示时间到 8 秒
isClosable: true,
position: 'top', // 改为顶部居中,更显眼
variant: 'solid', // 改为 solid背景更明显
});
console.log('[EventList DEBUG] ✓ Toast 通知已调用ID:', toastId);
// 发送浏览器原生通知
console.log('[EventList DEBUG] 准备发送浏览器原生通知');
console.log('[EventList DEBUG] 通知权限状态:', browserPermission);
if (browserPermission === 'granted') {
const importance = getImportanceConfig(event.importance);
const notification = browserNotificationService.sendNotification({
title: `🔔 ${importance.label}级事件`,
body: event.title,
tag: `event_${event.id}`,
data: {
link: `/event-detail/${event.id}`,
eventId: event.id,
},
autoClose: 10000, // 10秒后自动关闭
});
if (notification) {
browserNotificationService.setupClickHandler(notification, navigate);
console.log('[EventList DEBUG] ✓ 浏览器原生通知已发送');
} else {
console.log('[EventList DEBUG] ⚠️ 浏览器原生通知发送失败');
}
} else {
console.log('[EventList DEBUG] ⚠️ 浏览器通知权限未授予,跳过原生通知');
}
console.log('[EventList DEBUG] 准备更新事件列表');
// 将新事件添加到列表顶部(防止重复)
setLocalEvents((prevEvents) => {
console.log('[EventList DEBUG] 当前事件列表数量:', prevEvents.length);
const exists = prevEvents.some(e => e.id === event.id);
console.log('[EventList DEBUG] 事件是否已存在:', exists);
if (exists) {
logger.debug('EventList', '事件已存在,跳过添加', { eventId: event.id });
console.log('[EventList DEBUG] ⚠️ 事件已存在,跳过添加');
return prevEvents;
}
logger.info('EventList', '新事件添加到列表顶部', { eventId: event.id });
console.log('[EventList DEBUG] ✓ 新事件添加到列表顶部');
// 添加到顶部,最多保留 100 个
const updatedEvents = [event, ...prevEvents].slice(0, 100);
console.log('[EventList DEBUG] 更新后事件列表数量:', updatedEvents.length);
return updatedEvents;
});
console.log('[EventList DEBUG] ✓ 事件列表更新完成');
console.log('[EventList DEBUG] ========== EventList 处理完成 ==========\n');
}
});
// 同步外部 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 handlePushToggle = async (e) => {
const isChecked = e.target.checked;
if (isChecked) {
// 用户想开启推送
logger.info('EventList', '用户请求开启推送');
const permission = await requestBrowserPermission();
if (permission === 'denied') {
// 权限被拒绝,显示设置指引
logger.warn('EventList', '用户拒绝了推送权限');
toast({
title: '推送权限被拒绝',
description: '如需开启推送,请在浏览器设置中允许通知权限',
status: 'warning',
duration: 5000,
isClosable: true,
position: 'top',
});
} else if (permission === 'granted') {
logger.info('EventList', '推送权限已授予');
}
} else {
// 用户想关闭推送 - 提示需在浏览器设置中操作
logger.info('EventList', '用户尝试关闭推送');
toast({
title: '关闭推送通知',
description: '如需关闭,请在浏览器设置中撤销通知权限',
status: 'info',
duration: 5000,
isClosable: true,
position: 'top',
});
}
};
// 专业的金融配色方案
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 getTimelineBoxStyle = () => {
return {
bg: useColorModeValue('gray.50', 'gray.700'),
borderColor: useColorModeValue('gray.400', 'gray.500'),
borderWidth: '2px',
textColor: useColorModeValue('blue.600', 'blue.400'),
boxShadow: 'sm',
};
};
// 精简模式的事件渲染优化版标题2行+标签内联+按钮右侧)
const renderCompactEvent = (event, index) => {
const importance = getImportanceConfig(event.importance);
const isFollowing = !!followingMap[event.id];
const followerCount = followCountMap[event.id] ?? (event.follower_count || 0);
const timelineStyle = getTimelineBoxStyle();
return (
<HStack align="stretch" spacing={3} w="full">
{/* 左侧时间轴 - 动态样式 */}
<VStack spacing={0} align="center" minW="90px">
{/* 时间长方形卡片 */}
<Box
{...(timelineStyle.bgGradient ? { bgGradient: timelineStyle.bgGradient } : { bg: timelineStyle.bg })}
borderWidth={timelineStyle.borderWidth}
borderColor={timelineStyle.borderColor}
borderRadius="md"
px={2}
py={2}
minW="85px"
textAlign="center"
boxShadow={timelineStyle.boxShadow}
transition="all 0.3s ease"
>
{/* 日期 YYYY-MM-DD */}
<Text
fontSize="xs"
fontWeight="bold"
color={timelineStyle.textColor}
lineHeight="1.3"
>
{moment(event.created_at).format('YYYY-MM-DD')}
</Text>
{/* 时间 HH:mm */}
<Text
fontSize="xs"
fontWeight="bold"
color={timelineStyle.textColor}
lineHeight="1.3"
mt={0.5}
>
{moment(event.created_at).format('HH:mm')}
</Text>
</Box>
{/* 时间轴竖线 */}
<Box
w="2px"
flex="1"
bg={borderColor}
minH="40px"
mt={1}
/>
</VStack>
{/* 右侧内容卡片 */}
<Card
flex="1"
bg={index % 2 === 0 ? cardBg : useColorModeValue('gray.50', 'gray.750')}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
boxShadow="sm"
_hover={{
boxShadow: 'lg',
transform: 'translateY(-2px)',
borderColor: importance.color,
}}
transition="all 0.3s ease"
cursor="pointer"
onClick={() => onEventClick(event)}
mb={2}
>
<CardBody p={3}>
<VStack align="stretch" spacing={2}>
{/* 第一行标题2行+ 标签(内联)+ 按钮(右侧) */}
<Flex align="flex-start" gap={2}>
{/* 标题区域:标题+标签(内联) */}
<Box flex="1" minW="150px">
<Text
fontSize="md"
fontWeight="bold"
color={linkColor}
lineHeight="1.4"
noOfLines={2}
display="inline"
_hover={{ textDecoration: 'underline', color: 'blue.500' }}
onClick={(e) => handleTitleClick(e, event)}
cursor="pointer"
>
{event.title}
</Text>
{' '}
{/* 重要性标签 - 内联 */}
<Badge
colorScheme={importance.color.split('.')[0]}
fontSize="xs"
px={2}
py={1}
borderRadius="md"
fontWeight="bold"
display="inline-flex"
alignItems="center"
verticalAlign="middle"
>
{event.importance || 'C'}
</Badge>
{' '}
{/* 涨跌幅标签 - 内联 */}
{event.related_avg_chg != null && (
<Tooltip label="平均" placement="top">
<Badge
colorScheme={event.related_avg_chg > 0 ? 'red' : 'green'}
fontSize="xs"
px={2}
py={1}
borderRadius="md"
fontWeight="bold"
display="inline-flex"
alignItems="center"
gap={1}
verticalAlign="middle"
>
<PriceArrow value={event.related_avg_chg} />
{event.related_avg_chg > 0 ? '+' : ''}{event.related_avg_chg.toFixed(2)}%
</Badge>
</Tooltip>
)}
</Box>
{/* 操作按钮 - 固定右侧 */}
<HStack spacing={2} flexShrink={0}>
<Button
size="xs"
variant="ghost"
colorScheme="blue"
onClick={(e) => handleViewDetailClick(e, event.id)}
>
详情
</Button>
<Button
size="xs"
variant={isFollowing ? 'solid' : 'outline'}
colorScheme="yellow"
leftIcon={<StarIcon boxSize="10px" />}
onClick={(e) => {
e.stopPropagation();
toggleFollow(event.id);
}}
>
{isFollowing ? '已关注' : '关注'}
{followerCount > 0 && `(${followerCount})`}
</Button>
</HStack>
</Flex>
{/* 第二行:统计数据(左) + 作者时间(右) */}
<Flex justify="space-between" align="center" fontSize="xs" color={mutedColor}>
{/* 左侧:统计数据 */}
<HStack spacing={3} display={{ base: 'none', md: 'flex' }}>
<Tooltip label="浏览量" placement="top">
<HStack spacing={1}>
<ViewIcon boxSize="12px" />
<Text>{event.view_count || 0}</Text>
</HStack>
</Tooltip>
<Tooltip label="帖子数" placement="top">
<HStack spacing={1}>
<ChatIcon boxSize="12px" />
<Text>{event.post_count || 0}</Text>
</HStack>
</Tooltip>
<Tooltip label="关注数" placement="top">
<HStack spacing={1}>
<StarIcon boxSize="12px" />
<Text>{followerCount}</Text>
</HStack>
</Tooltip>
</HStack>
{/* 右侧:作者 + 时间(统一格式 YYYY-MM-DD HH:mm */}
<HStack spacing={2}>
<Text>@{event.creator?.username || 'Anonymous'}</Text>
<Text></Text>
<Text fontWeight="bold" color={linkColor}>
{moment(event.created_at).format('YYYY-MM-DD HH:mm')}
</Text>
</HStack>
</Flex>
</VStack>
</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);
const timelineStyle = getTimelineBoxStyle();
return (
<HStack align="stretch" spacing={3} w="full">
{/* 左侧时间轴 - 动态样式 */}
<VStack spacing={0} align="center" minW="90px">
{/* 时间长方形卡片 */}
<Box
{...(timelineStyle.bgGradient ? { bgGradient: timelineStyle.bgGradient } : { bg: timelineStyle.bg })}
borderWidth={timelineStyle.borderWidth}
borderColor={timelineStyle.borderColor}
borderRadius="md"
px={2}
py={2}
minW="85px"
textAlign="center"
boxShadow={timelineStyle.boxShadow}
transition="all 0.3s ease"
>
{/* 日期 YYYY-MM-DD */}
<Text
fontSize="xs"
fontWeight="bold"
color={timelineStyle.textColor}
lineHeight="1.3"
>
{moment(event.created_at).format('YYYY-MM-DD')}
</Text>
{/* 时间 HH:mm */}
<Text
fontSize="xs"
fontWeight="bold"
color={timelineStyle.textColor}
lineHeight="1.3"
mt={0.5}
>
{moment(event.created_at).format('HH:mm')}
</Text>
</Box>
{/* 时间轴竖线 */}
<Box
w="2px"
flex="1"
bg={borderColor}
minH="80px"
mt={1}
/>
</VStack>
{/* 事件卡片 */}
<Card
flex="1"
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
boxShadow="sm"
_hover={{
boxShadow: 'xl',
transform: 'translateY(-3px)',
borderColor: importance.color,
}}
transition="all 0.3s ease"
cursor="pointer"
onClick={() => onEventClick(event)}
mb={3}
>
<CardBody p={4}>
<VStack align="stretch" spacing={2.5}>
{/* 第一行:标题+优先级 | 统计+关注 */}
<Flex align="center" justify="space-between" gap={3}>
{/* 左侧:标题 + 优先级标签 */}
<HStack spacing={2} flex="1" align="center">
<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>
<Tooltip
label={
<VStack align="start" spacing={1} maxW="320px">
<Text fontWeight="bold" fontSize="sm" mb={1}>
重要性等级说明
</Text>
<Divider borderColor="gray.300" />
{getAllImportanceLevels().map((level) => (
<HStack key={level.level} spacing={2} align="center" w="full" py={0.5}>
<Circle
size="8px"
bg={level.dotBg}
flexShrink={0}
/>
<Text fontSize="xs" color="gray.700" lineHeight="1.5">
<Text as="span" fontWeight="bold">{level.level}</Text>
{level.description}
</Text>
</HStack>
))}
</VStack>
}
placement="top"
hasArrow
bg="white"
color="gray.800"
fontSize="md"
p={3}
borderRadius="lg"
borderWidth="1px"
borderColor="gray.200"
boxShadow="lg"
>
<Badge
colorScheme={importance.color.split('.')[0]}
px={1.5}
py={0.5}
borderRadius="md"
fontSize="xs"
cursor="help"
display="flex"
alignItems="center"
gap={1}
flexShrink={0}
>
<InfoIcon boxSize={2.5} />
{event.importance || 'C'}
</Badge>
</Tooltip>
</HStack>
{/* 右侧:统计数据 + 关注按钮 */}
<HStack spacing={4} flexShrink={0}>
{/* 统计数据 */}
<HStack spacing={4}>
<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>
{/* 关注按钮 */}
<Button
size="sm"
colorScheme="yellow"
variant={isFollowing ? 'solid' : 'outline'}
leftIcon={<StarIcon boxSize="12px" />}
onClick={(e) => {
e.stopPropagation();
toggleFollow(event.id);
}}
>
{isFollowing ? '已关注' : '关注'}
</Button>
</HStack>
</Flex>
{/* 第二行:价格标签 | 时间+作者 */}
<Flex justify="space-between" align="center" wrap="wrap" gap={3}>
{/* 左侧:价格标签 */}
<HStack spacing={2} flexWrap="wrap">
{/* 平均涨幅 - 始终显示,无数据时显示 -- */}
<Badge
colorScheme={event.related_avg_chg != null
? (event.related_avg_chg > 0 ? 'red' : event.related_avg_chg < 0 ? 'green' : 'gray')
: 'gray'}
fontSize="xs"
px={2}
py={0.5}
borderRadius="md"
cursor="pointer"
_hover={{ transform: 'scale(1.05)', boxShadow: 'md' }}
transition="all 0.2s"
>
<HStack spacing={1}>
<Text fontSize="xs" opacity={0.8}>平均</Text>
<Text fontWeight="bold">
{event.related_avg_chg != null
? `${event.related_avg_chg > 0 ? '+' : ''}${event.related_avg_chg.toFixed(2)}%`
: '--'}
</Text>
</HStack>
</Badge>
{/* 最大涨幅 - 始终显示,无数据时显示 -- */}
<Badge
colorScheme={event.related_max_chg != null
? (event.related_max_chg > 0 ? 'red' : event.related_max_chg < 0 ? 'green' : 'gray')
: 'gray'}
fontSize="xs"
px={2}
py={0.5}
borderRadius="md"
cursor="pointer"
_hover={{ transform: 'scale(1.05)', boxShadow: 'md' }}
transition="all 0.2s"
>
<HStack spacing={1}>
<Text fontSize="xs" opacity={0.8}>最大</Text>
<Text fontWeight="bold">
{event.related_max_chg != null
? `${event.related_max_chg > 0 ? '+' : ''}${event.related_max_chg.toFixed(2)}%`
: '--'}
</Text>
</HStack>
</Badge>
{/* 周涨幅 - 始终显示,无数据时显示 -- */}
<Badge
colorScheme={event.related_week_chg != null
? (event.related_week_chg > 0 ? 'red' : event.related_week_chg < 0 ? 'green' : 'gray')
: 'gray'}
fontSize="xs"
px={2}
py={0.5}
borderRadius="md"
cursor="pointer"
_hover={{ transform: 'scale(1.05)', boxShadow: 'md' }}
transition="all 0.2s"
>
<HStack spacing={1}>
<Text fontSize="xs" opacity={0.8}></Text>
{event.related_week_chg != null && <PriceArrow value={event.related_week_chg} />}
<Text fontWeight="bold">
{event.related_week_chg != null
? `${event.related_week_chg > 0 ? '+' : ''}${event.related_week_chg.toFixed(2)}%`
: '--'}
</Text>
</HStack>
</Badge>
</HStack>
{/* 右侧:时间 + 作者 */}
<HStack spacing={2} fontSize="sm" flexShrink={0}>
<Text fontWeight="bold" color={linkColor}>
{moment(event.created_at).format('YYYY-MM-DD HH:mm')}
</Text>
<Text color={mutedColor}></Text>
<Text color={mutedColor}>@{event.creator?.username || 'Anonymous'}</Text>
</HStack>
</Flex>
{/* 第三行:描述文字 + 展开/收起 */}
{event.description && (
<Box>
<Text
color={textColor}
fontSize="sm"
lineHeight="tall"
noOfLines={expandedDescriptions[event.id] ? undefined : 3}
>
{event.description}
</Text>
{event.description.length > 120 && (
<Button
variant="link"
size="xs"
colorScheme="blue"
onClick={(e) => {
e.stopPropagation();
setExpandedDescriptions(prev => ({
...prev,
[event.id]: !prev[event.id]
}));
}}
mt={1}
>
{expandedDescriptions[event.id] ? '收起' : '...展开'}
</Button>
)}
</Box>
)}
</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" pb={8}>
{/* 顶部控制栏:左空白 + 中间分页器 + 右侧控制固定sticky - 铺满全宽 */}
<Box
position="sticky"
top={0}
zIndex={10}
bg={useColorModeValue('rgba(255, 255, 255, 0.9)', 'rgba(26, 32, 44, 0.9)')}
backdropFilter="blur(10px)"
boxShadow="sm"
mb={4}
py={2}
w="100%"
>
<Container maxW="container.xl">
<Flex justify="space-between" align="center">
{/* 左侧占位 */}
<Box flex="1" />
{/* 中间:分页器 */}
{pagination.total > 0 && localEvents.length > 0 ? (
<Flex align="center" gap={2}>
<Button
size="xs"
variant="outline"
onClick={() => onPageChange(pagination.current - 1)}
isDisabled={pagination.current === 1}
>
上一页
</Button>
<Text fontSize="xs" color={mutedColor} px={2} whiteSpace="nowrap">
{pagination.current} / {Math.ceil(pagination.total / pagination.pageSize)}
</Text>
<Button
size="xs"
variant="outline"
onClick={() => onPageChange(pagination.current + 1)}
isDisabled={pagination.current === Math.ceil(pagination.total / pagination.pageSize)}
>
下一页
</Button>
<Text fontSize="xs" color={mutedColor} ml={2} whiteSpace="nowrap">
{pagination.total}
</Text>
</Flex>
) : (
<Box flex="1" />
)}
{/* 右侧:控制按钮 */}
<Flex align="center" gap={3} flex="1" justify="flex-end">
{/* WebSocket 连接状态 */}
<Badge
colorScheme={isConnected ? 'green' : 'red'}
fontSize="xs"
px={2}
py={1}
borderRadius="full"
>
{isConnected ? '🟢 实时' : '🔴 离线'}
</Badge>
{/* 桌面推送开关 */}
<FormControl display="flex" alignItems="center" w="auto">
<FormLabel htmlFor="push-notification" mb="0" fontSize="xs" color={textColor} mr={2}>
推送
</FormLabel>
<Tooltip
label={
browserPermission === 'granted'
? '桌面推送已开启'
: browserPermission === 'denied'
? '推送权限被拒绝,请在浏览器设置中允许通知权限'
: '点击开启桌面推送通知'
}
placement="top"
>
<Switch
id="push-notification"
size="sm"
isChecked={browserPermission === 'granted'}
onChange={handlePushToggle}
colorScheme="green"
/>
</Tooltip>
</FormControl>
{/* 视图切换控制 */}
<FormControl display="flex" alignItems="center" w="auto">
<FormLabel htmlFor="compact-mode" mb="0" fontSize="xs" color={textColor} mr={2}>
精简
</FormLabel>
<Switch
id="compact-mode"
size="sm"
isChecked={isCompactMode}
onChange={(e) => setIsCompactMode(e.target.checked)}
colorScheme="blue"
/>
</FormControl>
</Flex>
</Flex>
</Container>
</Box>
{/* 事件列表内容 */}
<Container maxW="container.xl">
{localEvents.length > 0 ? (
<VStack align="stretch" spacing={0}>
{localEvents.map((event, index) => (
<Box key={event.id} position="relative">
{isCompactMode
? renderCompactEvent(event, index)
: 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;