// 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)}%
)}
{/* 操作按钮 - 固定右侧 */}
}
onClick={(e) => {
e.stopPropagation();
toggleFollow(event.id);
}}
>
{isFollowing ? '已关注' : '关注'}
{followerCount > 0 && `(${followerCount})`}
{/* 第二行:统计数据(左) + 作者时间(右) */}
{/* 左侧:统计数据 */}
{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}
{/* 关注按钮 */}
}
onClick={(e) => {
e.stopPropagation();
toggleFollow(event.id);
}}
>
{isFollowing ? '已关注' : '关注'}
{/* 第二行:价格标签 | 时间+作者 */}
{/* 左侧:价格标签 */}
{/* 平均涨幅 - 始终显示,无数据时显示 -- */}
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;