feat: sockt 弹窗功能添加

This commit is contained in:
zdl
2025-10-21 17:50:21 +08:00
parent c93f689954
commit 09c9273190
17 changed files with 3739 additions and 161 deletions

View File

@@ -43,22 +43,49 @@ const TradingSimulation = React.lazy(() => import("views/TradingSimulation"));
// Contexts
import { AuthProvider } from "contexts/AuthContext";
import { AuthModalProvider } from "contexts/AuthModalContext";
import { NotificationProvider } from "contexts/NotificationContext";
import { NotificationProvider, useNotification } from "contexts/NotificationContext";
// Components
import ProtectedRoute from "components/ProtectedRoute";
import ErrorBoundary from "components/ErrorBoundary";
import AuthModalManager from "components/Auth/AuthModalManager";
import NotificationContainer from "components/NotificationContainer";
import ConnectionStatusBar from "components/ConnectionStatusBar";
import NotificationTestTool from "components/NotificationTestTool";
import ScrollToTop from "components/ScrollToTop";
import { logger } from "utils/logger";
/**
* ConnectionStatusBar 包装组件
* 需要在 NotificationProvider 内部使用,所以单独提取
*/
function ConnectionStatusBarWrapper() {
const { connectionStatus, reconnectAttempt, retryConnection } = useNotification();
const handleClose = () => {
// 关闭状态条(可选,当前不实现)
// 用户可以通过刷新页面来重新显示
};
return (
<ConnectionStatusBar
status={connectionStatus}
reconnectAttempt={reconnectAttempt}
maxReconnectAttempts={5}
onRetry={retryConnection}
onClose={handleClose}
/>
);
}
function AppContent() {
const { colorMode } = useColorMode();
return (
<Box minH="100vh" bg={colorMode === 'dark' ? 'gray.800' : 'white'}>
{/* Socket 连接状态条 */}
<ConnectionStatusBarWrapper />
{/* 路由切换时自动滚动到顶部 */}
<ScrollToTop />
<Routes>

View File

@@ -0,0 +1,132 @@
// src/components/ConnectionStatusBar/index.js
/**
* Socket 连接状态栏组件
* 显示 Socket 连接状态并提供重试功能
*/
import React from 'react';
import {
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
Button,
CloseButton,
Box,
HStack,
useColorModeValue,
Slide,
} from '@chakra-ui/react';
import { MdRefresh } from 'react-icons/md';
/**
* 连接状态枚举
*/
export const CONNECTION_STATUS = {
CONNECTED: 'connected', // 已连接
DISCONNECTED: 'disconnected', // 已断开
RECONNECTING: 'reconnecting', // 重连中
FAILED: 'failed', // 连接失败
};
/**
* 连接状态栏组件
*/
const ConnectionStatusBar = ({
status = CONNECTION_STATUS.CONNECTED,
reconnectAttempt = 0,
maxReconnectAttempts = 5,
onRetry,
onClose,
}) => {
// 仅在非正常状态时显示
const shouldShow = status !== CONNECTION_STATUS.CONNECTED;
// 状态配置
const statusConfig = {
[CONNECTION_STATUS.DISCONNECTED]: {
status: 'warning',
title: '连接已断开',
description: '正在尝试重新连接...',
},
[CONNECTION_STATUS.RECONNECTING]: {
status: 'warning',
title: '正在重新连接',
description: `尝试重连中 (第 ${reconnectAttempt}/${maxReconnectAttempts} 次)`,
},
[CONNECTION_STATUS.FAILED]: {
status: 'error',
title: '连接失败',
description: '无法连接到服务器,请检查网络连接',
},
};
const config = statusConfig[status] || statusConfig[CONNECTION_STATUS.DISCONNECTED];
// 颜色配置
const bg = useColorModeValue(
{
warning: 'orange.50',
error: 'red.50',
}[config.status],
{
warning: 'orange.900',
error: 'red.900',
}[config.status]
);
return (
<Slide
direction="top"
in={shouldShow}
style={{ zIndex: 10000 }}
>
<Alert
status={config.status}
variant="subtle"
bg={bg}
borderBottom="1px solid"
borderColor={useColorModeValue('gray.200', 'gray.700')}
py={3}
px={{ base: 4, md: 6 }}
>
<AlertIcon />
<Box flex="1">
<HStack spacing={2} align="center" flexWrap="wrap">
<AlertTitle fontSize="sm" fontWeight="bold" mb={0}>
{config.title}
</AlertTitle>
<AlertDescription fontSize="sm" mb={0}>
{config.description}
</AlertDescription>
</HStack>
</Box>
{/* 重试按钮(仅失败状态显示) */}
{status === CONNECTION_STATUS.FAILED && onRetry && (
<Button
size="sm"
colorScheme="red"
leftIcon={<MdRefresh />}
onClick={onRetry}
mr={2}
flexShrink={0}
>
立即重试
</Button>
)}
{/* 关闭按钮(仅失败状态显示) */}
{status === CONNECTION_STATUS.FAILED && onClose && (
<CloseButton
onClick={onClose}
size="sm"
flexShrink={0}
/>
)}
</Alert>
</Slide>
);
};
export default ConnectionStatusBar;

View File

@@ -3,7 +3,7 @@
* 金融资讯通知容器组件 - 右下角层叠显示实时通知
*/
import React, { useState } from 'react';
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
@@ -14,25 +14,249 @@ import {
Icon,
Badge,
Button,
Spinner,
useColorModeValue,
Slide,
ScaleFade,
} from '@chakra-ui/react';
import { MdClose, MdOpenInNew, MdSchedule, MdExpandMore, MdExpandLess } from 'react-icons/md';
import { keyframes } from '@emotion/react';
import { motion, AnimatePresence } from 'framer-motion';
import { MdClose, MdOpenInNew, MdSchedule, MdExpandMore, MdExpandLess, MdPerson, MdAccessTime } from 'react-icons/md';
import { useNotification } from '../../contexts/NotificationContext';
import {
NOTIFICATION_TYPE_CONFIGS,
NOTIFICATION_TYPES,
PRIORITY_CONFIGS,
PRIORITY_LEVELS,
NOTIFICATION_CONFIG,
formatNotificationTime,
getPriorityBgOpacity,
getPriorityBorderWidth,
} from '../../constants/notificationTypes';
/**
* 单个通知项组件
* 自定义 Hook带过期时间的 localStorage 持久化状态
* @param {string} key - localStorage 的 key
* @param {*} initialValue - 初始值
* @param {number} expiryMs - 过期时间毫秒0 表示不过期
* @returns {[*, Function]} - [状态值, 设置函数]
*/
const NotificationItem = ({ notification, onClose, isNewest = false }) => {
const useLocalStorageWithExpiry = (key, initialValue, expiryMs = 0) => {
// 从 localStorage 读取带过期时间的值
const readValue = () => {
try {
const item = window.localStorage.getItem(key);
if (!item) {
return initialValue;
}
const { value, timestamp } = JSON.parse(item);
// 检查是否过期(仅当设置了过期时间时)
if (expiryMs > 0 && timestamp) {
const now = Date.now();
const elapsed = now - timestamp;
if (elapsed > expiryMs) {
// 已过期,删除并返回初始值
window.localStorage.removeItem(key);
return initialValue;
}
}
return value;
} catch (error) {
console.error(`Error reading ${key} from localStorage:`, error);
return initialValue;
}
};
const [storedValue, setStoredValue] = useState(readValue);
// 保存值到 localStorage带时间戳
const setValue = (value) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
const item = {
value: valueToStore,
timestamp: Date.now(), // 保存时间戳
};
window.localStorage.setItem(key, JSON.stringify(item));
} catch (error) {
console.error(`Error saving ${key} to localStorage:`, error);
}
};
// 监听 storage 事件(跨标签页同步)
useEffect(() => {
const handleStorageChange = (e) => {
if (e.key === key && e.newValue !== null) {
try {
const { value, timestamp } = JSON.parse(e.newValue);
// 检查是否过期
if (expiryMs > 0 && timestamp) {
const now = Date.now();
const elapsed = now - timestamp;
if (elapsed > expiryMs) {
// 过期,设置为初始值
setStoredValue(initialValue);
return;
}
}
// 更新状态
setStoredValue(value);
} catch (error) {
console.error(`Error parsing storage event for ${key}:`, error);
}
} else if (e.key === key && e.newValue === null) {
// 其他标签页删除了该值
setStoredValue(initialValue);
}
};
// 添加事件监听
window.addEventListener('storage', handleStorageChange);
// 清理函数
return () => {
window.removeEventListener('storage', handleStorageChange);
};
}, [key, expiryMs, initialValue]);
// 定时检查过期(可选,更精确的过期控制)
useEffect(() => {
if (expiryMs <= 0) return; // 不需要过期检查
const checkExpiry = () => {
try {
const item = window.localStorage.getItem(key);
if (!item) return;
const { value, timestamp } = JSON.parse(item);
const now = Date.now();
const elapsed = now - timestamp;
if (elapsed > expiryMs) {
// 已过期,重置状态
setStoredValue(initialValue);
window.localStorage.removeItem(key);
}
} catch (error) {
console.error(`Error checking expiry for ${key}:`, error);
}
};
// 每10秒检查一次过期
const intervalId = setInterval(checkExpiry, 10000);
// 立即执行一次检查
checkExpiry();
return () => clearInterval(intervalId);
}, [key, expiryMs, initialValue]);
return [storedValue, setValue];
};
/**
* 辅助函数:生成通知的完整无障碍描述
* @param {object} notification - 通知对象
* @returns {string} - ARIA 描述文本
*/
const getNotificationDescription = (notification) => {
const { type, priority, title, content, isAIGenerated, publishTime, pushTime, extra } = notification;
// 获取配置
const typeConfig = NOTIFICATION_TYPE_CONFIGS[type] || NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.EVENT_ALERT];
const priorityConfig = PRIORITY_CONFIGS[priority];
// 构建描述片段
const parts = [];
// 优先级(如果需要显示)
if (priorityConfig?.show) {
parts.push(`${priorityConfig.label}通知`);
}
// 类型
parts.push(typeConfig.name);
// 标题
parts.push(title);
// 内容
if (content) {
parts.push(content);
}
// AI 生成标识
if (isAIGenerated) {
parts.push('由AI生成');
}
// 预测标识
if (extra?.isPrediction) {
parts.push('预测状态');
if (extra?.statusHint) {
parts.push(extra.statusHint);
}
}
// 时间信息
const time = publishTime || pushTime;
if (time) {
parts.push(`时间:${formatNotificationTime(time)}`);
}
// 操作提示
if (notification.clickable && notification.link) {
parts.push('按回车键或空格键查看详情');
}
return parts.join('');
};
/**
* 辅助函数处理键盘按键事件Enter / Space
* @param {KeyboardEvent} event - 键盘事件
* @param {Function} callback - 回调函数
*/
const handleKeyPress = (event, callback) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
callback();
}
};
/**
* 紧急通知脉冲动画 - 边框颜色脉冲效果
*/
const pulseAnimation = keyframes`
0%, 100% {
border-left-color: currentColor;
box-shadow: 0 0 0 0 currentColor;
}
50% {
border-left-color: currentColor;
box-shadow: -4px 0 12px 0 currentColor;
}
`;
/**
* 单个通知项组件
* 使用 React.memo 优化,避免不必要的重渲染
*/
const NotificationItem = React.memo(({ notification, onClose, isNewest = false }) => {
const navigate = useNavigate();
const { trackNotificationClick } = useNotification();
// 加载状态管理 - 点击跳转时显示 loading
const [isNavigating, setIsNavigating] = useState(false);
const { id, type, priority, title, content, isAIGenerated, clickable, link, author, publishTime, pushTime, extra } = notification;
// 严格判断可点击性:只有 clickable=true 且 link 存在才可点击
@@ -51,77 +275,177 @@ const NotificationItem = ({ notification, onClose, isNewest = false }) => {
...typeConfig,
icon: typeConfig.getIcon(priceChange),
colorScheme: typeConfig.getColorScheme(priceChange),
// 亮色模式
bg: typeConfig.getBg(priceChange),
borderColor: typeConfig.getBorderColor(priceChange),
iconColor: typeConfig.getIconColor(priceChange),
hoverBg: typeConfig.getHoverBg(priceChange),
// 暗色模式
darkBg: typeConfig.getDarkBg(priceChange),
darkBorderColor: typeConfig.getDarkBorderColor(priceChange),
darkIconColor: typeConfig.getDarkIconColor(priceChange),
darkHoverBg: typeConfig.getDarkHoverBg(priceChange),
};
}
// 获取优先级配置
const priorityConfig = PRIORITY_CONFIGS[priority] || PRIORITY_CONFIGS.normal;
const bgColor = useColorModeValue(typeConfig.bg, `${typeConfig.colorScheme}.900`);
const borderColor = useColorModeValue(typeConfig.borderColor, `${typeConfig.colorScheme}.500`);
const textColor = useColorModeValue('gray.800', 'white');
const subTextColor = useColorModeValue('gray.600', 'gray.300');
const metaTextColor = useColorModeValue('gray.500', 'gray.400');
const hoverBg = typeConfig.hoverBg;
const closeButtonHoverBg = useColorModeValue(`${typeConfig.colorScheme}.200`, `${typeConfig.colorScheme}.700`);
// 判断是否显示图标(仅紧急和重要通知)
const shouldShowIcon = priority === PRIORITY_LEVELS.URGENT || priority === PRIORITY_LEVELS.IMPORTANT;
// 点击处理(只有真正可点击时才执行)
const handleClick = () => {
if (isActuallyClickable) {
navigate(link);
// 获取优先级样式
const priorityBorderWidth = getPriorityBorderWidth(priority);
const isDark = useColorModeValue(false, true);
const priorityBgOpacity = getPriorityBgOpacity(priority, isDark);
// 根据优先级调整背景色深度
const getPriorityBgColor = () => {
const colorScheme = typeConfig.colorScheme;
// 亮色模式:根据优先级使用不同深度的颜色
if (!isDark) {
if (priority === PRIORITY_LEVELS.URGENT) {
return `${colorScheme}.200`; // 紧急:深色背景 + 脉冲动画
} else if (priority === PRIORITY_LEVELS.IMPORTANT) {
return `${colorScheme}.100`; // 重要:中色背景
} else {
// 普通:极淡背景(使用 white 或 gray.50,降低视觉干扰)
return 'white';
}
} else {
// 暗色模式:使用 typeConfig 的 darkBg 或回退
if (priority === PRIORITY_LEVELS.URGENT) {
return typeConfig.darkBg || `${colorScheme}.800`;
} else if (priority === PRIORITY_LEVELS.IMPORTANT) {
return typeConfig.darkBg || `${colorScheme}.800`;
} else {
// 普通通知在暗色模式下使用更暗的灰色背景
return 'gray.800';
}
}
};
// 颜色配置 - 支持亮色/暗色模式(使用 useMemo 优化)
const colors = useMemo(() => ({
bg: getPriorityBgColor(),
border: useColorModeValue(
typeConfig.borderColor,
typeConfig.darkBorderColor || `${typeConfig.colorScheme}.400`
),
icon: useColorModeValue(
typeConfig.iconColor,
typeConfig.darkIconColor || `${typeConfig.colorScheme}.300`
),
text: useColorModeValue('gray.800', 'gray.100'),
subText: useColorModeValue('gray.600', 'gray.300'),
metaText: useColorModeValue('gray.500', 'gray.500'),
hoverBg: useColorModeValue(
typeConfig.hoverBg,
typeConfig.darkHoverBg || `${typeConfig.colorScheme}.700`
),
closeButtonHoverBg: useColorModeValue(
`${typeConfig.colorScheme}.200`,
`${typeConfig.colorScheme}.700`
),
}), [isDark, priority, typeConfig]);
// 点击处理(只有真正可点击时才执行)- 使用 useCallback 优化
const handleClick = useCallback(() => {
if (isActuallyClickable && !isNavigating) {
// 设置加载状态
setIsNavigating(true);
// 追踪点击(监控埋点)
trackNotificationClick(id);
// 导航到目标页面
navigate(link);
// 延迟关闭通知(给用户足够的视觉反馈 - 300ms
setTimeout(() => {
onClose(id, true);
}, 300);
}
}, [id, link, isActuallyClickable, isNavigating, trackNotificationClick, navigate, onClose]);
// 生成完整的无障碍描述
const ariaDescription = getNotificationDescription(notification);
return (
<ScaleFade initialScale={0.9} in={true}>
<Box
bg={bgColor}
borderLeft="4px solid"
borderColor={borderColor}
borderRadius="md"
boxShadow={isNewest ? '2xl' : 'lg'}
p={4}
w="400px" // 统一宽度
position="relative"
cursor={isActuallyClickable ? 'pointer' : 'default'} // 严格判断
onClick={isActuallyClickable ? handleClick : undefined} // 严格判断
_hover={isActuallyClickable ? {
boxShadow: 'xl',
transform: 'translateY(-2px)',
bg: hoverBg,
} : {}} // 不可点击时无 hover 效果
transition="all 0.2s"
{...(isNewest && {
borderRight: '1px solid',
borderRightColor: borderColor,
borderTop: '1px solid',
borderTopColor: useColorModeValue(`${typeConfig.colorScheme}.100`, `${typeConfig.colorScheme}.700`),
})}
>
{/* 头部区域:图标 + 标题 + 优先级 + AI标识 */}
<Box
// 无障碍属性
role={priority === 'urgent' ? 'alert' : 'status'}
aria-live={priority === 'urgent' ? 'assertive' : 'polite'}
aria-atomic="true"
aria-label={ariaDescription}
tabIndex={isActuallyClickable ? 0 : -1}
onKeyDown={(e) => isActuallyClickable && handleKeyPress(e, handleClick)}
// 样式属性
bg={colors.bg}
borderLeft={`${priorityBorderWidth} solid`}
borderColor={colors.border}
// 可点击的通知添加完整边框提示
{...(isActuallyClickable && {
border: '1px solid',
borderLeftWidth: priorityBorderWidth, // 保持左侧优先级边框
})}
borderRadius="md"
// 可点击的通知使用更明显的阴影(悬浮感)
boxShadow={isActuallyClickable
? (isNewest ? '2xl' : 'md')
: (isNewest ? 'xl' : 'sm')}
// 紧急通知添加脉冲动画
animation={priority === PRIORITY_LEVELS.URGENT ? `${pulseAnimation} 2s ease-in-out infinite` : undefined}
p={{ base: 3, md: 4 }}
w={{ base: "calc(100vw - 32px)", sm: "360px", md: "380px", lg: "400px" }}
maxW="400px"
position="relative"
cursor={isActuallyClickable ? (isNavigating ? 'wait' : 'pointer') : 'default'}
onClick={isActuallyClickable && !isNavigating ? handleClick : undefined}
opacity={isNavigating ? 0.7 : 1}
pointerEvents={isNavigating ? 'none' : 'auto'}
_hover={isActuallyClickable && !isNavigating ? {
boxShadow: 'xl',
transform: 'translateY(-2px)',
bg: colors.hoverBg,
} : {}} // 不可点击时无 hover 效果
_focus={{
outline: '2px solid',
outlineColor: 'blue.500',
outlineOffset: '2px',
}}
transition="all 0.2s"
willChange="transform, opacity" // 性能优化GPU 加速
{...(isNewest && {
borderRight: '1px solid',
borderRightColor: colors.border,
borderTop: '1px solid',
borderTopColor: useColorModeValue(`${typeConfig.colorScheme}.100`, `${typeConfig.colorScheme}.700`),
})}
>
{/* 头部区域:标题 + 可选标识 */}
<HStack spacing={2} align="start" mb={2}>
{/* 类型图标 */}
<Icon
as={typeConfig.icon}
w={5}
h={5}
color={typeConfig.iconColor}
mt={0.5}
flexShrink={0}
/>
{/* 类型图标 - 仅紧急和重要通知显示 */}
{shouldShowIcon && (
<Icon
as={typeConfig.icon}
w={5}
h={5}
color={colors.icon} // 使用响应式颜色
mt={0.5}
flexShrink={0}
/>
)}
{/* 标题 */}
<Text
fontSize="sm"
fontWeight="bold"
color={textColor}
color={colors.text}
lineHeight="short"
flex={1}
noOfLines={2}
noOfLines={{ base: 1, md: 2 }}
pl={shouldShowIcon ? 0 : 0}
>
{title}
</Text>
@@ -137,42 +461,27 @@ const NotificationItem = ({ notification, onClose, isNewest = false }) => {
</Badge>
)}
{/* 预测标识 */}
{isPrediction && (
<Badge
colorScheme="gray"
size="sm"
flexShrink={0}
>
预测
</Badge>
)}
{/* AI 生成标识 */}
{isAIGenerated && (
<Badge
colorScheme="purple"
size="sm"
flexShrink={0}
>
AI
</Badge>
)}
{/* 关闭按钮 */}
<IconButton
icon={<MdClose />}
size="xs"
variant="ghost"
colorScheme={typeConfig.colorScheme}
aria-label="关闭通知"
aria-label={`关闭通知:${title}`}
onClick={(e) => {
e.stopPropagation();
onClose(id);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onClose(id);
}
}}
flexShrink={0}
_hover={{
bg: closeButtonHoverBg,
bg: colors.closeButtonHoverBg,
}}
/>
</HStack>
@@ -180,11 +489,11 @@ const NotificationItem = ({ notification, onClose, isNewest = false }) => {
{/* 内容区域 */}
<Text
fontSize="sm"
color={subTextColor}
color={colors.subText}
lineHeight="short"
noOfLines={3}
noOfLines={{ base: 2, md: 3 }}
mb={3}
pl={7} // 与图标对齐
pl={shouldShowIcon ? 7 : 0} // 有图标时与图标对齐
>
{content}
</Text>
@@ -193,61 +502,139 @@ const NotificationItem = ({ notification, onClose, isNewest = false }) => {
<HStack
spacing={2}
fontSize="xs"
color={metaTextColor}
pl={7} // 与图标对齐
color={colors.metaText}
pl={shouldShowIcon ? 7 : 0} // 有图标时与图标对齐
flexWrap="wrap"
>
{/* 作者信息(仅分析报告) */}
{author && (
<HStack spacing={1}>
<Text>👤</Text>
<Text>{author.name} - {author.organization}</Text>
<Text>|</Text>
</HStack>
)}
{/* 时间信息 */}
<HStack spacing={1}>
<Text>📅</Text>
<Icon as={MdAccessTime} w={3} h={3} />
<Text>
{publishTime && formatNotificationTime(publishTime)}
{!publishTime && pushTime && formatNotificationTime(pushTime)}
</Text>
</HStack>
{/* 状态提示(仅预测通知) */}
{extra?.statusHint && (
{/* AI 标识 - 小徽章(小屏及以上显示)*/}
{isAIGenerated && (
<>
<Text>|</Text>
<HStack spacing={1} color="gray.400">
<Icon as={MdSchedule} w={3} h={3} />
<Text>{extra.statusHint}</Text>
<Text display={{ base: "none", sm: "inline" }}>|</Text>
<Badge
colorScheme="purple"
size="xs"
display={{ base: "none", sm: "inline-flex" }}
>
AI
</Badge>
</>
)}
{/* 预测标识 + 状态提示 - 合并显示(小屏及以上)*/}
{isPrediction && (
<>
<Text display={{ base: "none", sm: "inline" }}>|</Text>
<HStack spacing={1} display={{ base: "none", sm: "flex" }}>
<Badge colorScheme="gray" size="xs">预测</Badge>
{extra?.statusHint && (
<>
<Icon as={MdSchedule} w={3} h={3} color="gray.400" />
<Text color="gray.400">{extra.statusHint}</Text>
</>
)}
</HStack>
</>
)}
{/* 可点击提示(仅真正可点击的通知) */}
{/* 作者信息 - 仅当数据存在时显示(平板及以上)*/}
{author && (
<>
<Text display={{ base: "none", md: "inline" }}>|</Text>
<HStack spacing={1} display={{ base: "none", md: "flex" }}>
<Icon as={MdPerson} w={3} h={3} />
<Text>{author.name} - {author.organization}</Text>
</HStack>
</>
)}
{/* 可点击提示(仅真正可点击的通知)*/}
{isActuallyClickable && (
<>
<Text>|</Text>
<HStack spacing={1}>
<Icon as={MdOpenInNew} w={3} h={3} />
<Text>查看详情</Text>
{/* Loading 时显示 Spinner否则显示图标 */}
{isNavigating ? (
<Spinner size="xs" />
) : (
<Icon as={MdOpenInNew} w={3} h={3} />
)}
{/* Loading 时显示"跳转中...",否则显示"查看详情" */}
<Text color={isNavigating ? 'blue.500' : undefined}>
{isNavigating ? '跳转中...' : '查看详情'}
</Text>
</HStack>
</>
)}
</HStack>
</Box>
</ScaleFade>
</Box>
);
};
}, (prevProps, nextProps) => {
// 自定义比较函数:只在 id 或 isNewest 变化时重渲染
return (
prevProps.notification.id === nextProps.notification.id &&
prevProps.isNewest === nextProps.isNewest
);
});
/**
* 通知容器组件 - 主组件
*/
const NotificationContainer = () => {
const { notifications, removeNotification } = useNotification();
const [isExpanded, setIsExpanded] = useState(false);
// 使用带过期时间的 localStorage2分钟 = 120000 毫秒)
const [isExpanded, setIsExpanded] = useLocalStorageWithExpiry(
'notification-expanded-state',
false,
120000
);
// 追踪新通知(性能优化:只对新通知做动画)
const prevNotificationIdsRef = useRef(new Set());
const isFirstRenderRef = useRef(true);
const [newNotificationIds, setNewNotificationIds] = useState(new Set());
useEffect(() => {
// 首次渲染跳过动画检测
if (isFirstRenderRef.current) {
isFirstRenderRef.current = false;
const currentIds = new Set(notifications.map(n => n.id));
prevNotificationIdsRef.current = currentIds;
return;
}
const currentIds = new Set(notifications.map(n => n.id));
const prevIds = prevNotificationIdsRef.current;
// 找出新增的通知 ID
const newIds = new Set();
currentIds.forEach(id => {
if (!prevIds.has(id)) {
newIds.add(id);
}
});
setNewNotificationIds(newIds);
// 更新引用
prevNotificationIdsRef.current = currentIds;
// 1秒后清除新通知标记动画完成
if (newIds.size > 0) {
const timer = setTimeout(() => {
setNewNotificationIds(new Set());
}, 1000);
return () => clearTimeout(timer);
}
}, [notifications]);
// 如果没有通知,不渲染
if (notifications.length === 0) {
@@ -265,11 +652,18 @@ const NotificationContainer = () => {
const collapseHoverBg = useColorModeValue('gray.200', 'gray.600');
const collapseTextColor = useColorModeValue('gray.700', 'gray.200');
// 构建无障碍描述
const containerAriaLabel = hasMore
? `通知中心,共有 ${notifications.length} 条通知,当前显示 ${visibleNotifications.length} 条,${isExpanded ? '已展开全部' : `还有 ${hiddenCount} 条折叠`}。使用Tab键导航Enter键或空格键查看详情。`
: `通知中心,共有 ${notifications.length} 条通知。使用Tab键导航Enter键或空格键查看详情。`;
return (
<Box
role="region"
aria-label={containerAriaLabel}
position="fixed"
bottom={6}
right={6}
bottom={{ base: 3, md: 28 }}
right={{ base: 3, md: 6 }}
zIndex={9999}
pointerEvents="none"
>
@@ -278,35 +672,56 @@ const NotificationContainer = () => {
align="flex-end"
pointerEvents="auto"
>
{visibleNotifications.map((notification, index) => (
<Slide
key={notification.id}
direction="bottom"
in={true}
style={{
position: 'relative',
zIndex: 9999 - index, // 最新消息index=0z-index最高
}}
>
<NotificationItem
notification={notification}
onClose={removeNotification}
isNewest={index === 0} // 第一条消息是最新的
/>
</Slide>
))}
<AnimatePresence mode="popLayout">
{visibleNotifications.map((notification, index) => (
<motion.div
key={notification.id}
layout // 自动处理位置变化(流畅重排)
initial={{ opacity: 0, y: 50, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, x: 300, scale: 0.9 }}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
mass: 0.8,
}}
style={{
position: 'relative',
zIndex: 9999 - index,
}}
>
<NotificationItem
notification={notification}
onClose={removeNotification}
isNewest={index === 0}
/>
</motion.div>
))}
</AnimatePresence>
{/* 折叠/展开按钮 */}
{hasMore && (
<ScaleFade initialScale={0.9} in={true}>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.2 }}
>
<Button
size="sm"
size={{ base: "xs", md: "sm" }}
variant="solid"
bg={collapseBg}
color={collapseTextColor}
_hover={{ bg: collapseHoverBg }}
_focus={{
outline: '2px solid',
outlineColor: 'blue.500',
outlineOffset: '2px',
}}
leftIcon={<Icon as={isExpanded ? MdExpandLess : MdExpandMore} />}
onClick={() => setIsExpanded(!isExpanded)}
aria-expanded={isExpanded}
aria-label={isExpanded ? '收起通知' : `展开查看还有 ${hiddenCount} 条通知`}
boxShadow="md"
borderRadius="md"
>
@@ -315,7 +730,7 @@ const NotificationContainer = () => {
: NOTIFICATION_CONFIG.collapse.textTemplate.replace('{count}', hiddenCount)
}
</Button>
</ScaleFade>
</motion.div>
)}
</VStack>
</Box>

View File

@@ -58,30 +58,66 @@ export const PRIORITY_CONFIGS = {
[PRIORITY_LEVELS.URGENT]: {
label: '紧急',
colorScheme: 'red',
show: true,
show: false, // 不再显示标签,改用边框+背景色表示
borderWidth: '6px', // 紧急:粗边框
bgOpacity: 0.25, // 紧急:深色背景
darkBgOpacity: 0.30, // 暗色模式下更明显
},
[PRIORITY_LEVELS.IMPORTANT]: {
label: '重要',
colorScheme: 'orange',
show: true,
show: false, // 不再显示标签,改用边框+背景色表示
borderWidth: '4px', // 重要:中等边框
bgOpacity: 0.15, // 重要:中色背景
darkBgOpacity: 0.20, // 暗色模式
},
[PRIORITY_LEVELS.NORMAL]: {
label: '',
colorScheme: 'gray',
show: false, // 普通优先级不显示标签
borderWidth: '2px', // 普通:细边框
bgOpacity: 0.08, // 普通:浅色背景
darkBgOpacity: 0.12, // 暗色模式
},
};
/**
* 根据优先级获取背景色透明度
* @param {string} priority - 优先级
* @param {boolean} isDark - 是否暗色模式
* @returns {number} - 透明度值 (0-1)
*/
export const getPriorityBgOpacity = (priority, isDark = false) => {
const config = PRIORITY_CONFIGS[priority] || PRIORITY_CONFIGS[PRIORITY_LEVELS.NORMAL];
return isDark ? config.darkBgOpacity : config.bgOpacity;
};
/**
* 根据优先级获取边框宽度
* @param {string} priority - 优先级
* @returns {string} - 边框宽度
*/
export const getPriorityBorderWidth = (priority) => {
const config = PRIORITY_CONFIGS[priority] || PRIORITY_CONFIGS[PRIORITY_LEVELS.NORMAL];
return config.borderWidth;
};
// 通知类型样式配置
export const NOTIFICATION_TYPE_CONFIGS = {
[NOTIFICATION_TYPES.ANNOUNCEMENT]: {
name: '公告通知',
icon: MdCampaign,
colorScheme: 'blue',
// 亮色模式
bg: 'blue.50',
borderColor: 'blue.400',
iconColor: 'blue.500',
hoverBg: 'blue.100',
// 暗色模式
darkBg: 'rgba(59, 130, 246, 0.15)', // blue.500 + 15% 透明度
darkBorderColor: 'blue.400',
darkIconColor: 'blue.300',
darkHoverBg: 'rgba(59, 130, 246, 0.25)', // Hover 时 25% 透明度
},
[NOTIFICATION_TYPES.STOCK_ALERT]: {
name: '股票动向',
@@ -95,6 +131,7 @@ export const NOTIFICATION_TYPE_CONFIGS = {
if (!priceChange) return 'red';
return priceChange.startsWith('+') ? 'red' : 'green';
},
// 亮色模式
getBg: (priceChange) => {
const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
return `${scheme}.50`;
@@ -111,24 +148,58 @@ export const NOTIFICATION_TYPE_CONFIGS = {
const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
return `${scheme}.100`;
},
// 暗色模式
getDarkBg: (priceChange) => {
const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
// red (上涨): rgba(239, 68, 68, 0.15), green (下跌): rgba(34, 197, 94, 0.15)
return scheme === 'red'
? 'rgba(239, 68, 68, 0.15)'
: 'rgba(34, 197, 94, 0.15)';
},
getDarkBorderColor: (priceChange) => {
const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
return `${scheme}.400`;
},
getDarkIconColor: (priceChange) => {
const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
return `${scheme}.300`;
},
getDarkHoverBg: (priceChange) => {
const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
return scheme === 'red'
? 'rgba(239, 68, 68, 0.25)'
: 'rgba(34, 197, 94, 0.25)';
},
},
[NOTIFICATION_TYPES.EVENT_ALERT]: {
name: '事件动向',
icon: MdArticle,
colorScheme: 'orange',
// 亮色模式
bg: 'orange.50',
borderColor: 'orange.400',
iconColor: 'orange.500',
hoverBg: 'orange.100',
// 暗色模式
darkBg: 'rgba(249, 115, 22, 0.15)', // orange.500 + 15% 透明度
darkBorderColor: 'orange.400',
darkIconColor: 'orange.300',
darkHoverBg: 'rgba(249, 115, 22, 0.25)',
},
[NOTIFICATION_TYPES.ANALYSIS_REPORT]: {
name: '分析报告',
icon: MdAssessment,
colorScheme: 'purple',
// 亮色模式
bg: 'purple.50',
borderColor: 'purple.400',
iconColor: 'purple.500',
hoverBg: 'purple.100',
// 暗色模式
darkBg: 'rgba(168, 85, 247, 0.15)', // purple.500 + 15% 透明度
darkBorderColor: 'purple.400',
darkIconColor: 'purple.300',
darkHoverBg: 'rgba(168, 85, 247, 0.25)',
},
};
@@ -178,4 +249,6 @@ export default {
PRIORITY_CONFIGS,
NOTIFICATION_TYPE_CONFIGS,
formatNotificationTime,
getPriorityBgOpacity,
getPriorityBorderWidth,
};

View File

@@ -4,11 +4,22 @@
*/
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
import { useToast, Box, HStack, Text, Button, CloseButton } from '@chakra-ui/react';
import { logger } from '../utils/logger';
import socket, { SOCKET_TYPE } from '../services/socket';
import notificationSound from '../assets/sounds/notification.wav';
import { browserNotificationService } from '../services/browserNotificationService';
import { PRIORITY_LEVELS, NOTIFICATION_CONFIG } from '../constants/notificationTypes';
import { notificationMetricsService } from '../services/notificationMetricsService';
import { notificationHistoryService } from '../services/notificationHistoryService';
import { PRIORITY_LEVELS, NOTIFICATION_CONFIG, NOTIFICATION_TYPES } from '../constants/notificationTypes';
// 连接状态枚举
const CONNECTION_STATUS = {
CONNECTED: 'connected',
DISCONNECTED: 'disconnected',
RECONNECTING: 'reconnecting',
FAILED: 'failed',
};
// 创建通知上下文
const NotificationContext = createContext();
@@ -24,10 +35,17 @@ export const useNotification = () => {
// 通知提供者组件
export const NotificationProvider = ({ children }) => {
const toast = useToast();
const [notifications, setNotifications] = useState([]);
const [isConnected, setIsConnected] = useState(false);
const [soundEnabled, setSoundEnabled] = useState(true);
const [browserPermission, setBrowserPermission] = useState(browserNotificationService.getPermissionStatus());
const [hasRequestedPermission, setHasRequestedPermission] = useState(() => {
// 从 localStorage 读取是否已请求过权限
return localStorage.getItem('browser_notification_requested') === 'true';
});
const [connectionStatus, setConnectionStatus] = useState(CONNECTION_STATUS.CONNECTED);
const [reconnectAttempt, setReconnectAttempt] = useState(0);
const audioRef = useRef(null);
// 初始化音频
@@ -63,10 +81,19 @@ export const NotificationProvider = ({ children }) => {
/**
* 移除通知
* @param {string} id - 通知ID
* @param {boolean} wasClicked - 是否是因为点击而关闭
*/
const removeNotification = useCallback((id) => {
logger.info('NotificationContext', 'Removing notification', { id });
setNotifications(prev => prev.filter(notif => notif.id !== id));
const removeNotification = useCallback((id, wasClicked = false) => {
logger.info('NotificationContext', 'Removing notification', { id, wasClicked });
// 监控埋点:追踪关闭(非点击的情况)
setNotifications(prev => {
const notification = prev.find(n => n.id === id);
if (notification && !wasClicked) {
notificationMetricsService.trackDismissed(notification);
}
return prev.filter(notif => notif.id !== id);
});
}, []);
/**
@@ -95,8 +122,32 @@ export const NotificationProvider = ({ children }) => {
logger.info('NotificationContext', 'Requesting browser notification permission');
const permission = await browserNotificationService.requestPermission();
setBrowserPermission(permission);
// 记录已请求过权限
setHasRequestedPermission(true);
localStorage.setItem('browser_notification_requested', 'true');
// 根据权限结果显示 Toast 提示
if (permission === 'granted') {
toast({
title: '桌面通知已开启',
description: '您现在可以在后台接收重要通知',
status: 'success',
duration: 3000,
isClosable: true,
});
} else if (permission === 'denied') {
toast({
title: '桌面通知已关闭',
description: '您将继续在网页内收到通知',
status: 'info',
duration: 5000,
isClosable: true,
});
}
return permission;
}, []);
}, [toast]);
/**
* 发送浏览器通知
@@ -138,10 +189,82 @@ export const NotificationProvider = ({ children }) => {
logger.info('NotificationContext', 'Browser notification sent', { title, tag });
}, [browserPermission]);
/**
* 事件数据适配器 - 将后端事件格式转换为前端通知格式
* @param {object} event - 后端事件对象
* @returns {object} - 前端通知对象
*/
const adaptEventToNotification = useCallback((event) => {
// 检测数据格式:如果已经是前端格式(包含 priority直接返回
if (event.priority || event.type === NOTIFICATION_TYPES.ANNOUNCEMENT || event.type === NOTIFICATION_TYPES.STOCK_ALERT) {
logger.debug('NotificationContext', 'Event is already in notification format', { id: event.id });
return event;
}
// 转换后端事件格式到前端通知格式
logger.debug('NotificationContext', 'Converting backend event to notification format', {
eventId: event.id,
eventType: event.event_type,
importance: event.importance
});
// 重要性映射S/A → urgent/important, B/C → normal
let priority = PRIORITY_LEVELS.NORMAL;
if (event.importance === 'S') {
priority = PRIORITY_LEVELS.URGENT;
} else if (event.importance === 'A') {
priority = PRIORITY_LEVELS.IMPORTANT;
}
// 获取自动关闭时长
const autoClose = NOTIFICATION_CONFIG.autoCloseDuration[priority];
// 构建通知对象
const notification = {
id: event.id || `event_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
type: NOTIFICATION_TYPES.EVENT_ALERT, // 统一使用"事件动向"类型
priority: priority,
title: event.title || '新事件',
content: event.description || event.content || '',
publishTime: event.created_at ? new Date(event.created_at).getTime() : Date.now(),
pushTime: Date.now(),
timestamp: Date.now(),
isAIGenerated: event.is_ai_generated || false,
clickable: true,
link: `/event-detail/${event.id}`,
autoClose: autoClose,
extra: {
eventId: event.id,
eventType: event.event_type,
importance: event.importance,
status: event.status,
hotScore: event.hot_score,
viewCount: event.view_count,
relatedAvgChg: event.related_avg_chg,
relatedMaxChg: event.related_max_chg,
keywords: event.keywords || [],
},
};
logger.info('NotificationContext', 'Event converted to notification', {
eventId: event.id,
notificationId: notification.id,
priority: notification.priority,
});
return notification;
}, []);
/**
* 添加网页通知(内部方法)
*/
const addWebNotification = useCallback((newNotification) => {
// 监控埋点:追踪通知接收
notificationMetricsService.trackReceived(newNotification);
// 保存到历史记录
notificationHistoryService.saveNotification(newNotification);
// 新消息插入到数组开头,最多保留 maxHistory 条
setNotifications(prev => {
const updated = [newNotification, ...prev];
@@ -174,7 +297,7 @@ export const NotificationProvider = ({ children }) => {
* 添加通知到队列
* @param {object} notification - 通知对象
*/
const addNotification = useCallback((notification) => {
const addNotification = useCallback(async (notification) => {
// 根据优先级获取自动关闭时长
const priority = notification.priority || PRIORITY_LEVELS.NORMAL;
const defaultAutoClose = NOTIFICATION_CONFIG.autoCloseDuration[priority];
@@ -193,6 +316,62 @@ export const NotificationProvider = ({ children }) => {
logger.info('NotificationContext', 'Adding notification', newNotification);
// ========== 智能权限请求策略 ==========
// 首次收到重要/紧急通知时,自动请求桌面通知权限
if (priority === PRIORITY_LEVELS.URGENT || priority === PRIORITY_LEVELS.IMPORTANT) {
if (browserPermission === 'default' && !hasRequestedPermission) {
logger.info('NotificationContext', 'First important notification, requesting browser permission');
await requestBrowserPermission();
}
// 如果权限被拒绝,提示用户可以开启
else if (browserPermission === 'denied' && hasRequestedPermission) {
// 显示带"开启"按钮的 Toast仅重要/紧急通知)
const toastId = 'enable-notification-toast';
if (!toast.isActive(toastId)) {
toast({
id: toastId,
title: newNotification.title,
description: '💡 开启桌面通知以便后台接收',
status: 'warning',
duration: 10000,
isClosable: true,
position: 'top',
render: ({ onClose }) => (
<Box
p={4}
bg="orange.500"
color="white"
borderRadius="md"
boxShadow="lg"
>
<HStack spacing={3} align="start">
<Box flex={1}>
<Text fontWeight="bold" mb={1}>
{newNotification.title}
</Text>
<Text fontSize="sm" opacity={0.9}>
💡 开启桌面通知以便后台接收
</Text>
</Box>
<Button
size="sm"
colorScheme="whiteAlpha"
onClick={() => {
requestBrowserPermission();
onClose();
}}
>
开启
</Button>
<CloseButton onClick={onClose} />
</HStack>
</Box>
),
});
}
}
}
const isPageHidden = document.hidden; // 页面是否在后台
// ========== 智能分发策略 ==========
@@ -224,7 +403,7 @@ export const NotificationProvider = ({ children }) => {
}
return newNotification.id;
}, [sendBrowserNotification, addWebNotification]);
}, [sendBrowserNotification, addWebNotification, browserPermission, hasRequestedPermission, requestBrowserPermission]);
// 连接到 Socket 服务
useEffect(() => {
@@ -236,8 +415,20 @@ export const NotificationProvider = ({ children }) => {
// 监听连接状态
socket.on('connect', () => {
setIsConnected(true);
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
setReconnectAttempt(0);
logger.info('NotificationContext', 'Socket connected');
// 显示重连成功提示(如果之前断开过)
if (connectionStatus !== CONNECTION_STATUS.CONNECTED) {
toast({
title: '已重新连接',
status: 'success',
duration: 2000,
isClosable: true,
});
}
// 如果使用 mock可以启动定期推送
if (SOCKET_TYPE === 'MOCK') {
// 启动模拟推送:使用配置的间隔和数量
@@ -247,18 +438,48 @@ export const NotificationProvider = ({ children }) => {
}
});
socket.on('disconnect', () => {
socket.on('disconnect', (reason) => {
setIsConnected(false);
logger.warn('NotificationContext', 'Socket disconnected');
setConnectionStatus(CONNECTION_STATUS.DISCONNECTED);
logger.warn('NotificationContext', 'Socket disconnected', { reason });
});
// 监听交易通知
socket.on('trade_notification', (data) => {
logger.info('NotificationContext', 'Received trade notification', data);
addNotification(data);
// 监听连接错误
socket.on('connect_error', (error) => {
logger.error('NotificationContext', 'Socket connect_error', error);
setConnectionStatus(CONNECTION_STATUS.RECONNECTING);
// 获取重连次数(仅 Real Socket 有)
if (SOCKET_TYPE === 'REAL') {
const attempts = socket.getReconnectAttempts?.() || 0;
setReconnectAttempt(attempts);
}
});
// 监听系统通知
// 监听重连失败
socket.on('reconnect_failed', () => {
logger.error('NotificationContext', 'Socket reconnect_failed');
setConnectionStatus(CONNECTION_STATUS.FAILED);
toast({
title: '连接失败',
description: '无法连接到服务器,请检查网络连接',
status: 'error',
duration: null, // 不自动关闭
isClosable: true,
});
});
// 监听新事件推送(统一事件名)
socket.on('new_event', (data) => {
logger.info('NotificationContext', 'Received new event', data);
// 使用适配器转换事件格式
const notification = adaptEventToNotification(data);
addNotification(notification);
});
// 保留系统通知监听(兼容性)
socket.on('system_notification', (data) => {
logger.info('NotificationContext', 'Received system notification', data);
addNotification(data);
@@ -275,22 +496,111 @@ export const NotificationProvider = ({ children }) => {
socket.off('connect');
socket.off('disconnect');
socket.off('trade_notification');
socket.off('connect_error');
socket.off('reconnect_failed');
socket.off('new_event');
socket.off('system_notification');
socket.disconnect();
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}, [adaptEventToNotification, connectionStatus, toast]); // eslint-disable-line react-hooks/exhaustive-deps
// ==================== 智能自动重试 ====================
/**
* 标签页聚焦时自动重试
*/
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible' && !isConnected && connectionStatus === CONNECTION_STATUS.FAILED) {
logger.info('NotificationContext', 'Tab refocused, attempting auto-reconnect');
if (SOCKET_TYPE === 'REAL') {
socket.reconnect?.();
} else {
socket.connect();
}
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [isConnected, connectionStatus]);
/**
* 网络恢复时自动重试
*/
useEffect(() => {
const handleOnline = () => {
if (!isConnected && connectionStatus === CONNECTION_STATUS.FAILED) {
logger.info('NotificationContext', 'Network restored, attempting auto-reconnect');
toast({
title: '网络已恢复',
description: '正在重新连接...',
status: 'info',
duration: 2000,
isClosable: true,
});
if (SOCKET_TYPE === 'REAL') {
socket.reconnect?.();
} else {
socket.connect();
}
}
};
window.addEventListener('online', handleOnline);
return () => {
window.removeEventListener('online', handleOnline);
};
}, [isConnected, connectionStatus, toast]);
/**
* 追踪通知点击
* @param {string} id - 通知ID
*/
const trackNotificationClick = useCallback((id) => {
const notification = notifications.find(n => n.id === id);
if (notification) {
logger.info('NotificationContext', 'Notification clicked', { id });
// 监控埋点:追踪点击
notificationMetricsService.trackClicked(notification);
// 标记历史记录为已点击
notificationHistoryService.markAsClicked(id);
}
}, [notifications]);
/**
* 手动重试连接
*/
const retryConnection = useCallback(() => {
logger.info('NotificationContext', 'Manual reconnection triggered');
setConnectionStatus(CONNECTION_STATUS.RECONNECTING);
if (SOCKET_TYPE === 'REAL') {
socket.reconnect?.();
} else {
socket.connect();
}
}, []);
const value = {
notifications,
isConnected,
soundEnabled,
browserPermission,
connectionStatus,
reconnectAttempt,
addNotification,
removeNotification,
clearAllNotifications,
toggleSound,
requestBrowserPermission,
trackNotificationClick,
retryConnection,
};
return (
@@ -300,4 +610,7 @@ export const NotificationProvider = ({ children }) => {
);
};
// 导出连接状态枚举供外部使用
export { CONNECTION_STATUS };
export default NotificationContext;

View File

@@ -329,7 +329,7 @@ class MockSocketService {
// 在连接后3秒发送欢迎消息
setTimeout(() => {
this.emit('trade_notification', {
this.emit('new_event', {
type: 'system_notification',
severity: 'info',
title: '连接成功',
@@ -445,7 +445,7 @@ class MockSocketService {
// 延迟发送(模拟层叠效果)
setTimeout(() => {
this.emit('trade_notification', alert);
this.emit('new_event', alert);
logger.info('mockSocketService', 'Mock notification sent', alert);
}, i * 500); // 每条消息间隔500ms
}
@@ -478,7 +478,7 @@ class MockSocketService {
id: `test_${Date.now()}`,
};
this.emit('trade_notification', notification);
this.emit('new_event', notification);
logger.info('mockSocketService', 'Test notification sent', notification);
}

View File

@@ -0,0 +1,402 @@
// src/services/notificationHistoryService.js
/**
* 通知历史记录服务
* 持久化存储通知历史,支持查询、筛选、搜索、导出
*/
import { logger } from '../utils/logger';
const STORAGE_KEY = 'notification_history';
const MAX_HISTORY_SIZE = 500; // 最多保留 500 条历史记录
class NotificationHistoryService {
constructor() {
this.history = this.loadHistory();
}
/**
* 从 localStorage 加载历史记录
*/
loadHistory() {
try {
const data = localStorage.getItem(STORAGE_KEY);
if (data) {
const parsed = JSON.parse(data);
// 确保是数组
return Array.isArray(parsed) ? parsed : [];
}
} catch (error) {
logger.error('notificationHistoryService', 'loadHistory', error);
}
return [];
}
/**
* 保存历史记录到 localStorage
*/
saveHistory() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.history));
logger.debug('notificationHistoryService', 'History saved', {
count: this.history.length
});
} catch (error) {
logger.error('notificationHistoryService', 'saveHistory', error);
// localStorage 可能已满,尝试清理旧数据
if (error.name === 'QuotaExceededError') {
this.cleanup(100); // 清理 100 条
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.history));
} catch (retryError) {
logger.error('notificationHistoryService', 'saveHistory retry failed', retryError);
}
}
}
}
/**
* 保存通知到历史记录
* @param {object} notification - 通知对象
*/
saveNotification(notification) {
const record = {
id: notification.id || `notif_${Date.now()}`,
notification: { ...notification }, // 深拷贝
receivedAt: Date.now(),
readAt: null,
clickedAt: null,
};
// 检查是否已存在(去重)
const existingIndex = this.history.findIndex(r => r.id === record.id);
if (existingIndex !== -1) {
logger.debug('notificationHistoryService', 'Notification already exists', { id: record.id });
return;
}
// 添加到历史记录开头
this.history.unshift(record);
// 限制最大数量
if (this.history.length > MAX_HISTORY_SIZE) {
this.history = this.history.slice(0, MAX_HISTORY_SIZE);
}
this.saveHistory();
logger.info('notificationHistoryService', 'Notification saved', { id: record.id });
}
/**
* 标记通知为已读
* @param {string} id - 通知 ID
*/
markAsRead(id) {
const record = this.history.find(r => r.id === id);
if (record && !record.readAt) {
record.readAt = Date.now();
this.saveHistory();
logger.debug('notificationHistoryService', 'Marked as read', { id });
}
}
/**
* 标记通知为已点击
* @param {string} id - 通知 ID
*/
markAsClicked(id) {
const record = this.history.find(r => r.id === id);
if (record && !record.clickedAt) {
record.clickedAt = Date.now();
// 点击也意味着已读
if (!record.readAt) {
record.readAt = Date.now();
}
this.saveHistory();
logger.debug('notificationHistoryService', 'Marked as clicked', { id });
}
}
/**
* 获取历史记录
* @param {object} filters - 筛选条件
* @param {string} filters.type - 通知类型
* @param {string} filters.priority - 优先级
* @param {string} filters.readStatus - 阅读状态 ('read' | 'unread' | 'all')
* @param {number} filters.startDate - 开始日期(时间戳)
* @param {number} filters.endDate - 结束日期(时间戳)
* @param {number} filters.page - 页码(从 1 开始)
* @param {number} filters.pageSize - 每页数量
* @returns {object} - { records, total, page, pageSize }
*/
getHistory(filters = {}) {
let filtered = [...this.history];
// 按类型筛选
if (filters.type && filters.type !== 'all') {
filtered = filtered.filter(r => r.notification.type === filters.type);
}
// 按优先级筛选
if (filters.priority && filters.priority !== 'all') {
filtered = filtered.filter(r => r.notification.priority === filters.priority);
}
// 按阅读状态筛选
if (filters.readStatus === 'read') {
filtered = filtered.filter(r => r.readAt !== null);
} else if (filters.readStatus === 'unread') {
filtered = filtered.filter(r => r.readAt === null);
}
// 按日期范围筛选
if (filters.startDate) {
filtered = filtered.filter(r => r.receivedAt >= filters.startDate);
}
if (filters.endDate) {
filtered = filtered.filter(r => r.receivedAt <= filters.endDate);
}
// 分页
const page = filters.page || 1;
const pageSize = filters.pageSize || 20;
const total = filtered.length;
const startIndex = (page - 1) * pageSize;
const endIndex = startIndex + pageSize;
const records = filtered.slice(startIndex, endIndex);
return {
records,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
/**
* 搜索历史记录
* @param {string} keyword - 搜索关键词
* @returns {array} - 匹配的记录
*/
searchHistory(keyword) {
if (!keyword || keyword.trim() === '') {
return [];
}
const lowerKeyword = keyword.toLowerCase();
const results = this.history.filter(record => {
const { title, content, message } = record.notification;
const searchText = `${title || ''} ${content || ''} ${message || ''}`.toLowerCase();
return searchText.includes(lowerKeyword);
});
logger.info('notificationHistoryService', 'Search completed', {
keyword,
resultsCount: results.length
});
return results;
}
/**
* 删除单条历史记录
* @param {string} id - 通知 ID
*/
deleteRecord(id) {
const initialLength = this.history.length;
this.history = this.history.filter(r => r.id !== id);
if (this.history.length < initialLength) {
this.saveHistory();
logger.info('notificationHistoryService', 'Record deleted', { id });
return true;
}
return false;
}
/**
* 批量删除历史记录
* @param {array} ids - 通知 ID 数组
*/
deleteRecords(ids) {
const initialLength = this.history.length;
this.history = this.history.filter(r => !ids.includes(r.id));
const deletedCount = initialLength - this.history.length;
if (deletedCount > 0) {
this.saveHistory();
logger.info('notificationHistoryService', 'Batch delete completed', {
deletedCount
});
}
return deletedCount;
}
/**
* 清空所有历史记录
*/
clearHistory() {
this.history = [];
this.saveHistory();
logger.info('notificationHistoryService', 'History cleared');
}
/**
* 清理旧数据
* @param {number} count - 要清理的数量
*/
cleanup(count) {
if (this.history.length > count) {
this.history = this.history.slice(0, -count);
this.saveHistory();
logger.info('notificationHistoryService', 'Cleanup completed', { count });
}
}
/**
* 获取统计数据
* @returns {object} - 统计信息
*/
getStats() {
const total = this.history.length;
const read = this.history.filter(r => r.readAt !== null).length;
const unread = total - read;
const clicked = this.history.filter(r => r.clickedAt !== null).length;
// 按类型统计
const byType = {};
const byPriority = {};
this.history.forEach(record => {
const { type, priority } = record.notification;
// 类型统计
if (type) {
byType[type] = (byType[type] || 0) + 1;
}
// 优先级统计
if (priority) {
byPriority[priority] = (byPriority[priority] || 0) + 1;
}
});
// 计算点击率
const clickRate = total > 0 ? ((clicked / total) * 100).toFixed(2) : '0.00';
return {
total,
read,
unread,
clicked,
clickRate,
byType,
byPriority,
};
}
/**
* 导出历史记录为 JSON
* @param {object} filters - 筛选条件(可选)
*/
exportToJSON(filters = {}) {
const { records } = this.getHistory(filters);
const exportData = {
exportedAt: new Date().toISOString(),
total: records.length,
records: records.map(r => ({
id: r.id,
notification: r.notification,
receivedAt: new Date(r.receivedAt).toISOString(),
readAt: r.readAt ? new Date(r.readAt).toISOString() : null,
clickedAt: r.clickedAt ? new Date(r.clickedAt).toISOString() : null,
})),
};
return JSON.stringify(exportData, null, 2);
}
/**
* 导出历史记录为 CSV
* @param {object} filters - 筛选条件(可选)
*/
exportToCSV(filters = {}) {
const { records } = this.getHistory(filters);
let csv = 'ID,Type,Priority,Title,Content,Received At,Read At,Clicked At\n';
records.forEach(record => {
const { id, notification, receivedAt, readAt, clickedAt } = record;
const { type, priority, title, content, message } = notification;
const escapeCsv = (str) => {
if (!str) return '';
return `"${String(str).replace(/"/g, '""')}"`;
};
csv += [
escapeCsv(id),
escapeCsv(type),
escapeCsv(priority),
escapeCsv(title),
escapeCsv(content || message),
new Date(receivedAt).toISOString(),
readAt ? new Date(readAt).toISOString() : '',
clickedAt ? new Date(clickedAt).toISOString() : '',
].join(',') + '\n';
});
return csv;
}
/**
* 触发下载文件
* @param {string} content - 文件内容
* @param {string} filename - 文件名
* @param {string} mimeType - MIME 类型
*/
downloadFile(content, filename, mimeType) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
logger.info('notificationHistoryService', 'File downloaded', { filename });
}
/**
* 导出并下载 JSON 文件
* @param {object} filters - 筛选条件(可选)
*/
downloadJSON(filters = {}) {
const json = this.exportToJSON(filters);
const filename = `notifications_${new Date().toISOString().split('T')[0]}.json`;
this.downloadFile(json, filename, 'application/json');
}
/**
* 导出并下载 CSV 文件
* @param {object} filters - 筛选条件(可选)
*/
downloadCSV(filters = {}) {
const csv = this.exportToCSV(filters);
const filename = `notifications_${new Date().toISOString().split('T')[0]}.csv`;
this.downloadFile(csv, filename, 'text/csv');
}
}
// 导出单例
export const notificationHistoryService = new NotificationHistoryService();
export default notificationHistoryService;

View File

@@ -0,0 +1,450 @@
// src/services/notificationMetricsService.js
/**
* 通知性能监控服务
* 追踪推送到达率、点击率、响应时间等指标
*/
import { logger } from '../utils/logger';
const STORAGE_KEY = 'notification_metrics';
const MAX_HISTORY_DAYS = 30; // 保留最近 30 天数据
class NotificationMetricsService {
constructor() {
this.metrics = this.loadMetrics();
}
/**
* 从 localStorage 加载指标数据
*/
loadMetrics() {
try {
const data = localStorage.getItem(STORAGE_KEY);
if (data) {
const parsed = JSON.parse(data);
// 清理过期数据
this.cleanOldData(parsed);
return parsed;
}
} catch (error) {
logger.error('notificationMetricsService', 'loadMetrics', error);
}
// 返回默认结构
return this.getDefaultMetrics();
}
/**
* 获取默认指标结构
*/
getDefaultMetrics() {
return {
summary: {
totalSent: 0,
totalReceived: 0,
totalClicked: 0,
totalDismissed: 0,
totalResponseTime: 0, // 总响应时间(毫秒)
},
byType: {
announcement: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
stock_alert: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
event_alert: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
analysis_report: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
},
byPriority: {
urgent: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
important: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
normal: { sent: 0, received: 0, clicked: 0, dismissed: 0 },
},
hourlyDistribution: Array(24).fill(0), // 每小时推送分布
dailyData: {}, // 按日期存储的每日数据
lastUpdated: Date.now(),
};
}
/**
* 保存指标数据到 localStorage
*/
saveMetrics() {
try {
this.metrics.lastUpdated = Date.now();
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.metrics));
logger.debug('notificationMetricsService', 'Metrics saved', this.metrics.summary);
} catch (error) {
logger.error('notificationMetricsService', 'saveMetrics', error);
}
}
/**
* 清理过期数据
*/
cleanOldData(metrics) {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - MAX_HISTORY_DAYS);
const cutoffDateStr = cutoffDate.toISOString().split('T')[0];
if (metrics.dailyData) {
Object.keys(metrics.dailyData).forEach(date => {
if (date < cutoffDateStr) {
delete metrics.dailyData[date];
}
});
}
}
/**
* 获取当前日期字符串
*/
getTodayDateStr() {
return new Date().toISOString().split('T')[0];
}
/**
* 获取当前小时
*/
getCurrentHour() {
return new Date().getHours();
}
/**
* 确保每日数据结构存在
*/
ensureDailyData(dateStr) {
if (!this.metrics.dailyData[dateStr]) {
this.metrics.dailyData[dateStr] = {
sent: 0,
received: 0,
clicked: 0,
dismissed: 0,
};
}
}
/**
* 追踪通知发送
* @param {object} notification - 通知对象
*/
trackSent(notification) {
const { type, priority } = notification;
const today = this.getTodayDateStr();
const hour = this.getCurrentHour();
// 汇总统计
this.metrics.summary.totalSent++;
// 按类型统计
if (this.metrics.byType[type]) {
this.metrics.byType[type].sent++;
}
// 按优先级统计
if (priority && this.metrics.byPriority[priority]) {
this.metrics.byPriority[priority].sent++;
}
// 每小时分布
this.metrics.hourlyDistribution[hour]++;
// 每日数据
this.ensureDailyData(today);
this.metrics.dailyData[today].sent++;
this.saveMetrics();
logger.debug('notificationMetricsService', 'Tracked sent', { type, priority });
}
/**
* 追踪通知接收
* @param {object} notification - 通知对象
*/
trackReceived(notification) {
const { type, priority, id, timestamp } = notification;
const today = this.getTodayDateStr();
// 汇总统计
this.metrics.summary.totalReceived++;
// 按类型统计
if (this.metrics.byType[type]) {
this.metrics.byType[type].received++;
}
// 按优先级统计
if (priority && this.metrics.byPriority[priority]) {
this.metrics.byPriority[priority].received++;
}
// 每日数据
this.ensureDailyData(today);
this.metrics.dailyData[today].received++;
// 存储接收时间(用于计算响应时间)
this.storeReceivedTime(id, timestamp || Date.now());
this.saveMetrics();
logger.debug('notificationMetricsService', 'Tracked received', { type, priority });
}
/**
* 追踪通知点击
* @param {object} notification - 通知对象
*/
trackClicked(notification) {
const { type, priority, id } = notification;
const today = this.getTodayDateStr();
const clickTime = Date.now();
// 汇总统计
this.metrics.summary.totalClicked++;
// 按类型统计
if (this.metrics.byType[type]) {
this.metrics.byType[type].clicked++;
}
// 按优先级统计
if (priority && this.metrics.byPriority[priority]) {
this.metrics.byPriority[priority].clicked++;
}
// 每日数据
this.ensureDailyData(today);
this.metrics.dailyData[today].clicked++;
// 计算响应时间
const receivedTime = this.getReceivedTime(id);
if (receivedTime) {
const responseTime = clickTime - receivedTime;
this.metrics.summary.totalResponseTime += responseTime;
this.removeReceivedTime(id);
logger.debug('notificationMetricsService', 'Response time', { responseTime });
}
this.saveMetrics();
logger.debug('notificationMetricsService', 'Tracked clicked', { type, priority });
}
/**
* 追踪通知关闭
* @param {object} notification - 通知对象
*/
trackDismissed(notification) {
const { type, priority, id } = notification;
const today = this.getTodayDateStr();
// 汇总统计
this.metrics.summary.totalDismissed++;
// 按类型统计
if (this.metrics.byType[type]) {
this.metrics.byType[type].dismissed++;
}
// 按优先级统计
if (priority && this.metrics.byPriority[priority]) {
this.metrics.byPriority[priority].dismissed++;
}
// 每日数据
this.ensureDailyData(today);
this.metrics.dailyData[today].dismissed++;
// 清理接收时间记录
this.removeReceivedTime(id);
this.saveMetrics();
logger.debug('notificationMetricsService', 'Tracked dismissed', { type, priority });
}
/**
* 存储通知接收时间(用于计算响应时间)
*/
storeReceivedTime(id, timestamp) {
if (!this.receivedTimes) {
this.receivedTimes = new Map();
}
this.receivedTimes.set(id, timestamp);
}
/**
* 获取通知接收时间
*/
getReceivedTime(id) {
if (!this.receivedTimes) {
return null;
}
return this.receivedTimes.get(id);
}
/**
* 移除通知接收时间记录
*/
removeReceivedTime(id) {
if (this.receivedTimes) {
this.receivedTimes.delete(id);
}
}
/**
* 获取汇总统计
*/
getSummary() {
const summary = { ...this.metrics.summary };
// 计算平均响应时间
if (summary.totalClicked > 0) {
summary.avgResponseTime = Math.round(summary.totalResponseTime / summary.totalClicked);
} else {
summary.avgResponseTime = 0;
}
// 计算点击率
if (summary.totalReceived > 0) {
summary.clickRate = ((summary.totalClicked / summary.totalReceived) * 100).toFixed(2);
} else {
summary.clickRate = '0.00';
}
// 计算到达率(假设 sent = received
if (summary.totalSent > 0) {
summary.deliveryRate = ((summary.totalReceived / summary.totalSent) * 100).toFixed(2);
} else {
summary.deliveryRate = '100.00';
}
return summary;
}
/**
* 获取按类型统计
*/
getByType() {
const result = {};
Object.keys(this.metrics.byType).forEach(type => {
const data = this.metrics.byType[type];
result[type] = {
...data,
clickRate: data.received > 0 ? ((data.clicked / data.received) * 100).toFixed(2) : '0.00',
};
});
return result;
}
/**
* 获取按优先级统计
*/
getByPriority() {
const result = {};
Object.keys(this.metrics.byPriority).forEach(priority => {
const data = this.metrics.byPriority[priority];
result[priority] = {
...data,
clickRate: data.received > 0 ? ((data.clicked / data.received) * 100).toFixed(2) : '0.00',
};
});
return result;
}
/**
* 获取每小时分布
*/
getHourlyDistribution() {
return [...this.metrics.hourlyDistribution];
}
/**
* 获取每日数据
* @param {number} days - 获取最近多少天的数据
*/
getDailyData(days = 7) {
const result = [];
const today = new Date();
for (let i = days - 1; i >= 0; i--) {
const date = new Date(today);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0];
const data = this.metrics.dailyData[dateStr] || {
sent: 0,
received: 0,
clicked: 0,
dismissed: 0,
};
result.push({
date: dateStr,
...data,
clickRate: data.received > 0 ? ((data.clicked / data.received) * 100).toFixed(2) : '0.00',
});
}
return result;
}
/**
* 获取完整指标数据
*/
getAllMetrics() {
return {
summary: this.getSummary(),
byType: this.getByType(),
byPriority: this.getByPriority(),
hourlyDistribution: this.getHourlyDistribution(),
dailyData: this.getDailyData(30),
lastUpdated: this.metrics.lastUpdated,
};
}
/**
* 重置所有指标
*/
reset() {
this.metrics = this.getDefaultMetrics();
this.saveMetrics();
logger.info('notificationMetricsService', 'Metrics reset');
}
/**
* 导出指标数据为 JSON
*/
exportToJSON() {
return JSON.stringify(this.getAllMetrics(), null, 2);
}
/**
* 导出指标数据为 CSV
*/
exportToCSV() {
const summary = this.getSummary();
const dailyData = this.getDailyData(30);
let csv = '# Summary\n';
csv += 'Metric,Value\n';
csv += `Total Sent,${summary.totalSent}\n`;
csv += `Total Received,${summary.totalReceived}\n`;
csv += `Total Clicked,${summary.totalClicked}\n`;
csv += `Total Dismissed,${summary.totalDismissed}\n`;
csv += `Delivery Rate,${summary.deliveryRate}%\n`;
csv += `Click Rate,${summary.clickRate}%\n`;
csv += `Avg Response Time,${summary.avgResponseTime}ms\n\n`;
csv += '# Daily Data\n';
csv += 'Date,Sent,Received,Clicked,Dismissed,Click Rate\n';
dailyData.forEach(day => {
csv += `${day.date},${day.sent},${day.received},${day.clicked},${day.dismissed},${day.clickRate}%\n`;
});
return csv;
}
}
// 导出单例
export const notificationMetricsService = new NotificationMetricsService();
export default notificationMetricsService;

View File

@@ -187,6 +187,46 @@ class SocketService {
return this.socket?.id || null;
}
/**
* 手动重连
* @returns {boolean} 是否触发重连
*/
reconnect() {
if (!this.socket) {
logger.warn('socketService', 'Cannot reconnect: socket not initialized');
return false;
}
if (this.connected) {
logger.info('socketService', 'Already connected, no need to reconnect');
return false;
}
logger.info('socketService', 'Manually triggering reconnection...');
// 重置重连计数
this.reconnectAttempts = 0;
// 触发重连
this.socket.connect();
return true;
}
/**
* 获取当前重连尝试次数
*/
getReconnectAttempts() {
return this.reconnectAttempts;
}
/**
* 获取最大重连次数
*/
getMaxReconnectAttempts() {
return this.maxReconnectAttempts;
}
// ==================== 事件推送专用方法 ====================
/**

View File

@@ -33,6 +33,7 @@ import {
Switch,
FormControl,
FormLabel,
useToast,
} from '@chakra-ui/react';
import {
ViewIcon,
@@ -53,6 +54,7 @@ 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';
// ========== 工具函数定义在组件外部 ==========
// 涨跌颜色配置中国A股配色红涨绿跌- 分档次显示
@@ -166,15 +168,55 @@ const PriceArrow = ({ value }) => {
// ========== 主组件 ==========
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 { isConnected } = useEventNotifications({
eventType: 'all',
importance: 'all',
enabled: true,
onNewEvent: (event) => {
logger.info('EventList', '收到新事件推送', event);
// 显示 Toast 通知
toast({
title: '新事件发布',
description: event.title,
status: 'info',
duration: 5000,
isClosable: true,
position: 'top-right',
variant: 'left-accent',
});
// 将新事件添加到列表顶部(防止重复)
setLocalEvents((prevEvents) => {
const exists = prevEvents.some(e => e.id === event.id);
if (exists) {
logger.debug('EventList', '事件已存在,跳过添加', { eventId: event.id });
return prevEvents;
}
logger.info('EventList', '新事件添加到列表顶部', { eventId: event.id });
// 添加到顶部,最多保留 100 个
return [event, ...prevEvents].slice(0, 100);
});
}
});
// 同步外部 events 到 localEvents
useEffect(() => {
setLocalEvents(events);
}, [events]);
// 初始化关注状态与计数
useEffect(() => {
// 初始化计数映射
const initCounts = {};
events.forEach(ev => {
localEvents.forEach(ev => {
initCounts[ev.id] = ev.follower_count || 0;
});
setFollowCountMap(initCounts);
@@ -197,8 +239,8 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
}
};
loadFollowing();
// 仅在 events 更新时重跑
}, [events]);
// 仅在 localEvents 更新时重跑
}, [localEvents]);
const toggleFollow = async (eventId) => {
try {
@@ -766,8 +808,27 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
return (
<Box bg={bgColor} minH="100vh" py={8}>
<Container maxW="container.xl">
{/* 视图切换控制 */}
<Flex justify="flex-end" mb={6}>
{/* 顶部控制栏:连接状态 + 视图切换 */}
<Flex justify="space-between" align="center" mb={6}>
{/* WebSocket 连接状态指示器 */}
<HStack spacing={2}>
<Badge
colorScheme={isConnected ? 'green' : 'red'}
fontSize="sm"
px={3}
py={1}
borderRadius="full"
>
{isConnected ? '🟢 实时推送已开启' : '🔴 实时推送未连接'}
</Badge>
{isConnected && (
<Text fontSize="xs" color={mutedColor}>
新事件将自动推送
</Text>
)}
</HStack>
{/* 视图切换控制 */}
<FormControl display="flex" alignItems="center" w="auto">
<FormLabel htmlFor="compact-mode" mb="0" fontSize="sm" color={textColor}>
精简模式
@@ -781,11 +842,11 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
</FormControl>
</Flex>
{events.length > 0 ? (
{localEvents.length > 0 ? (
<VStack align="stretch" spacing={0}>
{events.map((event, index) => (
{localEvents.map((event, index) => (
<Box key={event.id} position="relative">
{isCompactMode
{isCompactMode
? renderCompactEvent(event)
: renderDetailedEvent(event)
}