Files
vf_react/src/views/Community/components/EventList.js
zdl 4a0194e26c feat: 重构主组件
│ │
│ │ -  移除 renderPriceChange 函数(60行)                                                                                          │ │
│ │ -  移除 renderCompactEvent 函数(200行)                                                                                        │ │
│ │ -  移除 renderDetailedEvent 函数(300行)                                                                                       │ │
│ │ -  移除 expandedDescriptions state                                                                                              │ │
│ │ -  精简 Chakra UI 导入                                                                                                          │ │
│ │ -  使用 EventCard 组件统一渲染                                                                                                  │ │
│ │ -  保留所有业务逻辑(WebSocket、通知、关注)
2025-10-30 12:15:55 +08:00

498 lines
21 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,
Flex,
Container,
useColorModeValue,
Switch,
FormControl,
FormLabel,
useToast,
Center,
} from '@chakra-ui/react';
import { InfoIcon } from '@chakra-ui/icons';
import { useNavigate } from 'react-router-dom';
// 导入工具函数和常量
import { logger } from '../../../utils/logger';
import { getApiBase } from '../../../utils/apiConfig';
import { useEventNotifications } from '../../../hooks/useEventNotifications';
import { browserNotificationService } from '../../../services/browserNotificationService';
import { useNotification } from '../../../contexts/NotificationContext';
import { getImportanceConfig } from '../../../constants/importanceLevels';
// 导入子组件
import EventCard from './EventCard';
// ========== 主组件 ==========
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); // 用于实时更新的本地事件列表
// 从 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 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',
};
};
// 分页组件
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">
<EventCard
event={event}
index={index}
isCompactMode={isCompactMode}
isFollowing={!!followingMap[event.id]}
followerCount={followCountMap[event.id] ?? (event.follower_count || 0)}
onEventClick={onEventClick}
onTitleClick={handleTitleClick}
onViewDetail={handleViewDetailClick}
onToggleFollow={toggleFollow}
timelineStyle={getTimelineBoxStyle()}
borderColor={borderColor}
/>
</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;