// src/components/NotificationContainer/index.js /** * 金融资讯通知容器组件 - 右下角层叠显示实时通知 */ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { Box, VStack, HStack, Text, IconButton, Icon, Badge, Button, Spinner, useColorModeValue, } from '@chakra-ui/react'; 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 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 存在才可点击 const isActuallyClickable = clickable && link; // 判断是否为预测通知 const isPrediction = extra?.isPrediction; // 获取类型配置 let typeConfig = NOTIFICATION_TYPE_CONFIGS[type] || NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.EVENT_ALERT]; // 股票动向需要根据涨跌动态配置 if (type === NOTIFICATION_TYPES.STOCK_ALERT && extra?.priceChange) { const priceChange = extra.priceChange; typeConfig = { ...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 shouldShowIcon = priority === PRIORITY_LEVELS.URGENT || priority === PRIORITY_LEVELS.IMPORTANT; // 获取优先级样式 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 ( 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`), })} > {/* 头部区域:标题 + 可选标识 */} {/* 类型图标 - 仅紧急和重要通知显示 */} {shouldShowIcon && ( )} {/* 标题 */} {title} {/* 优先级标签 */} {priorityConfig.show && ( {priorityConfig.label} )} {/* 关闭按钮 */} } size="xs" variant="ghost" colorScheme={typeConfig.colorScheme} 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: colors.closeButtonHoverBg, }} /> {/* 内容区域 */} {content} {/* 底部元数据区域 */} {/* 时间信息 */} {publishTime && formatNotificationTime(publishTime)} {!publishTime && pushTime && formatNotificationTime(pushTime)} {/* AI 标识 - 小徽章(小屏及以上显示)*/} {isAIGenerated && ( <> | AI )} {/* 预测标识 + 状态提示 - 合并显示(小屏及以上)*/} {isPrediction && ( <> | 预测 {extra?.statusHint && ( <> {extra.statusHint} )} )} {/* 作者信息 - 仅当数据存在时显示(平板及以上)*/} {author && ( <> | {author.name} - {author.organization} )} {/* 可点击提示(仅真正可点击的通知)*/} {isActuallyClickable && ( <> | {/* Loading 时显示 Spinner,否则显示图标 */} {isNavigating ? ( ) : ( )} {/* Loading 时显示"跳转中...",否则显示"查看详情" */} {isNavigating ? '跳转中...' : '查看详情'} )} ); }, (prevProps, nextProps) => { // 自定义比较函数:只在 id 或 isNewest 变化时重渲染 return ( prevProps.notification.id === nextProps.notification.id && prevProps.isNewest === nextProps.isNewest ); }); /** * 通知容器组件 - 主组件 */ const NotificationContainer = () => { const { notifications, removeNotification } = useNotification(); // 使用带过期时间的 localStorage(2分钟 = 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) { return null; } // 根据展开状态决定显示的通知 const maxVisible = NOTIFICATION_CONFIG.maxVisible; const hasMore = notifications.length > maxVisible; const visibleNotifications = isExpanded ? notifications : notifications.slice(0, maxVisible); const hiddenCount = notifications.length - maxVisible; // 颜色配置 const collapseBg = useColorModeValue('gray.100', 'gray.700'); 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 ( {visibleNotifications.map((notification, index) => ( ))} {/* 折叠/展开按钮 */} {hasMore && ( )} ); }; export default NotificationContainer;