│ │ │ │ - ❌ 移除 renderPriceChange 函数(60行) │ │ │ │ - ❌ 移除 renderCompactEvent 函数(200行) │ │ │ │ - ❌ 移除 renderDetailedEvent 函数(300行) │ │ │ │ - ❌ 移除 expandedDescriptions state │ │ │ │ - ❌ 精简 Chakra UI 导入 │ │ │ │ - ✅ 使用 EventCard 组件统一渲染 │ │ │ │ - ✅ 保留所有业务逻辑(WebSocket、通知、关注)
498 lines
21 KiB
JavaScript
498 lines
21 KiB
JavaScript
// 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; |