// 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 ( {label}: -- ); } 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 ( {label}: {isPositive ? '+' : ''}{value.toFixed(2)}% ); }; 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 ( {/* 左侧时间轴 - 动态样式 */} {/* 时间长方形卡片 */} {/* 日期 YYYY-MM-DD */} {moment(event.created_at).format('YYYY-MM-DD')} {/* 时间 HH:mm */} {moment(event.created_at).format('HH:mm')} {/* 时间轴竖线 */} {/* 右侧内容卡片 */} onEventClick(event)} mb={2} > {/* 第一行:标题(2行)+ 标签(内联)+ 按钮(右侧) */} {/* 标题区域:标题+标签(内联) */} handleTitleClick(e, event)} cursor="pointer" > {event.title} {' '} {/* 重要性标签 - 内联 */} {event.importance || 'C'}级 {' '} {/* 涨跌幅标签 - 内联 */} {event.related_avg_chg != null && ( 0 ? 'red' : 'green'} fontSize="xs" px={2} py={1} borderRadius="md" fontWeight="bold" display="inline-flex" alignItems="center" gap={1} verticalAlign="middle" > {event.related_avg_chg > 0 ? '+' : ''}{event.related_avg_chg.toFixed(2)}% )} {/* 操作按钮 - 固定右侧 */} {/* 第二行:统计数据(左) + 作者时间(右) */} {/* 左侧:统计数据 */} {event.view_count || 0} {event.post_count || 0} {followerCount} {/* 右侧:作者 + 时间(统一格式 YYYY-MM-DD HH:mm) */} @{event.creator?.username || 'Anonymous'} {moment(event.created_at).format('YYYY-MM-DD HH:mm')} ); }; // 详细模式的事件渲染(原有的渲染方式,但修复了箭头颜色) 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 ( {/* 左侧时间轴 - 动态样式 */} {/* 时间长方形卡片 */} {/* 日期 YYYY-MM-DD */} {moment(event.created_at).format('YYYY-MM-DD')} {/* 时间 HH:mm */} {moment(event.created_at).format('HH:mm')} {/* 时间轴竖线 */} {/* 事件卡片 */} onEventClick(event)} mb={3} > {/* 第一行:标题+优先级 | 统计+关注 */} {/* 左侧:标题 + 优先级标签 */} handleTitleClick(e, event)} cursor="pointer" > {event.title} 重要性等级说明 {getAllImportanceLevels().map((level) => ( {level.level}级 {level.description} ))} } placement="top" hasArrow bg="white" color="gray.800" fontSize="md" p={3} borderRadius="lg" borderWidth="1px" borderColor="gray.200" boxShadow="lg" > {event.importance || 'C'}级 {/* 右侧:统计数据 + 关注按钮 */} {/* 统计数据 */} {event.view_count || 0} {event.post_count || 0} {followerCount} {/* 关注按钮 */} {/* 第二行:价格标签 | 时间+作者 */} {/* 左侧:价格标签 */} {/* 平均涨幅 - 始终显示,无数据时显示 -- */} 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" > 平均 {event.related_avg_chg != null ? `${event.related_avg_chg > 0 ? '+' : ''}${event.related_avg_chg.toFixed(2)}%` : '--'} {/* 最大涨幅 - 始终显示,无数据时显示 -- */} 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" > 最大 {event.related_max_chg != null ? `${event.related_max_chg > 0 ? '+' : ''}${event.related_max_chg.toFixed(2)}%` : '--'} {/* 周涨幅 - 始终显示,无数据时显示 -- */} 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" > {event.related_week_chg != null && } {event.related_week_chg != null ? `${event.related_week_chg > 0 ? '+' : ''}${event.related_week_chg.toFixed(2)}%` : '--'} {/* 右侧:时间 + 作者 */} {moment(event.created_at).format('YYYY-MM-DD HH:mm')} @{event.creator?.username || 'Anonymous'} {/* 第三行:描述文字 + 展开/收起 */} {event.description && ( {event.description} {event.description.length > 120 && ( )} )} ); }; // 分页组件 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 ( {pageNumbers.map((page, index) => { if (page === '...') { return ( ... ); } return ( ); })} 共 {total} 条 ); }; return ( {/* 顶部控制栏:左空白 + 中间分页器 + 右侧控制(固定sticky) - 铺满全宽 */} {/* 左侧占位 */} {/* 中间:分页器 */} {pagination.total > 0 && localEvents.length > 0 ? ( 第 {pagination.current} / {Math.ceil(pagination.total / pagination.pageSize)} 页 共 {pagination.total} 条 ) : ( )} {/* 右侧:控制按钮 */} {/* WebSocket 连接状态 */} {isConnected ? '🟢 实时' : '🔴 离线'} {/* 桌面推送开关 */} 推送 {/* 视图切换控制 */} 精简 setIsCompactMode(e.target.checked)} colorScheme="blue" /> {/* 事件列表内容 */} {localEvents.length > 0 ? ( {localEvents.map((event, index) => ( {isCompactMode ? renderCompactEvent(event, index) : renderDetailedEvent(event) } ))} ) : (
暂无事件数据
)} {pagination.total > 0 && ( )}
); }; export default EventList;