diff --git a/src/components/NotificationContainer/index.js b/src/components/NotificationContainer/index.js index 0b818b17..5deca5c5 100644 --- a/src/components/NotificationContainer/index.js +++ b/src/components/NotificationContainer/index.js @@ -299,8 +299,8 @@ const NotificationItem = React.memo(({ notification, onClose, isNewest = false } const isDark = useColorModeValue(false, true); const priorityBgOpacity = getPriorityBgOpacity(priority, isDark); - // 根据优先级调整背景色深度 - const getPriorityBgColor = () => { + // 根据优先级调整背景色深度(使用 useMemo 缓存计算结果) + const priorityBgColor = useMemo(() => { const colorScheme = typeConfig.colorScheme; // 亮色模式:根据优先级使用不同深度的颜色 if (!isDark) { @@ -323,31 +323,41 @@ const NotificationItem = React.memo(({ notification, onClose, isNewest = false } return 'gray.800'; } } - }; + }, [isDark, priority, typeConfig]); - // 颜色配置 - 支持亮色/暗色模式(使用 useMemo 优化) + // 颜色配置 - 支持亮色/暗色模式 + // ⚠️ 必须在组件顶层调用 useColorModeValue,不能在 useMemo 内部调用 + const borderColor = useColorModeValue( + typeConfig.borderColor, + typeConfig.darkBorderColor || `${typeConfig.colorScheme}.400` + ); + const iconColor = useColorModeValue( + typeConfig.iconColor, + typeConfig.darkIconColor || `${typeConfig.colorScheme}.300` + ); + const textColor = useColorModeValue('gray.800', 'gray.100'); + const subTextColor = useColorModeValue('gray.600', 'gray.300'); + const metaTextColor = useColorModeValue('gray.500', 'gray.500'); + const hoverBgColor = useColorModeValue( + typeConfig.hoverBg, + typeConfig.darkHoverBg || `${typeConfig.colorScheme}.700` + ); + const closeButtonHoverBgColor = useColorModeValue( + `${typeConfig.colorScheme}.200`, + `${typeConfig.colorScheme}.700` + ); + + // 使用 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]); + bg: priorityBgColor, + border: borderColor, + icon: iconColor, + text: textColor, + subText: subTextColor, + metaText: metaTextColor, + hoverBg: hoverBgColor, + closeButtonHoverBg: closeButtonHoverBgColor, + }), [priorityBgColor, borderColor, iconColor, textColor, subTextColor, metaTextColor, hoverBgColor, closeButtonHoverBgColor]); // 点击处理(只有真正可点击时才执行)- 使用 useCallback 优化 const handleClick = useCallback(() => { @@ -636,6 +646,11 @@ const NotificationContainer = () => { } }, [notifications]); + // ⚠️ 颜色配置 - 必须在条件return之前调用所有Hooks + const collapseBg = useColorModeValue('gray.100', 'gray.700'); + const collapseHoverBg = useColorModeValue('gray.200', 'gray.600'); + const collapseTextColor = useColorModeValue('gray.700', 'gray.200'); + // 如果没有通知,不渲染 if (notifications.length === 0) { return null; @@ -647,11 +662,6 @@ const NotificationContainer = () => { 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键或空格键查看详情。` diff --git a/src/components/NotificationTestTool/index.js b/src/components/NotificationTestTool/index.js index cbec0cd5..ab8c89ec 100644 --- a/src/components/NotificationTestTool/index.js +++ b/src/components/NotificationTestTool/index.js @@ -4,7 +4,7 @@ * 用于手动测试4种通知类型 */ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { Box, Button, @@ -16,8 +16,15 @@ import { useDisclosure, Badge, Divider, + Alert, + AlertIcon, + AlertTitle, + AlertDescription, + Code, + UnorderedList, + ListItem, } from '@chakra-ui/react'; -import { MdNotifications, MdClose, MdVolumeOff, MdVolumeUp, MdCampaign, MdTrendingUp, MdArticle, MdAssessment } from 'react-icons/md'; +import { MdNotifications, MdClose, MdVolumeOff, MdVolumeUp, MdCampaign, MdTrendingUp, MdArticle, MdAssessment, MdWarning } from 'react-icons/md'; import { useNotification } from '../../contexts/NotificationContext'; import { SOCKET_TYPE } from '../../services/socket'; import { NOTIFICATION_TYPES, PRIORITY_LEVELS } from '../../constants/notificationTypes'; @@ -27,6 +34,62 @@ const NotificationTestTool = () => { const { addNotification, soundEnabled, toggleSound, isConnected, clearAllNotifications, notifications, browserPermission, requestBrowserPermission } = useNotification(); const [testCount, setTestCount] = useState(0); + // 测试状态 + const [isTestingNotification, setIsTestingNotification] = useState(false); + const [testCountdown, setTestCountdown] = useState(0); + const [notificationShown, setNotificationShown] = useState(null); // null | true | false + + // 系统环境检测 + const [isFullscreen, setIsFullscreen] = useState(false); + const [isMacOS, setIsMacOS] = useState(false); + + // 故障排查面板 + const { isOpen: isTroubleshootOpen, onToggle: onTroubleshootToggle } = useDisclosure(); + + // 检测系统环境 + useEffect(() => { + // 检测是否为 macOS + const platform = navigator.platform.toLowerCase(); + setIsMacOS(platform.includes('mac')); + + // 检测全屏状态 + const checkFullscreen = () => { + setIsFullscreen(!!document.fullscreenElement); + }; + document.addEventListener('fullscreenchange', checkFullscreen); + checkFullscreen(); + + return () => { + document.removeEventListener('fullscreenchange', checkFullscreen); + }; + }, []); + + // 倒计时逻辑 + useEffect(() => { + if (testCountdown > 0) { + const timer = setTimeout(() => { + setTestCountdown(testCountdown - 1); + }, 1000); + return () => clearTimeout(timer); + } else if (testCountdown === 0 && isTestingNotification) { + // 倒计时结束,询问用户 + setIsTestingNotification(false); + + // 延迟一下再询问,确保用户有时间看到通知 + setTimeout(() => { + const sawNotification = window.confirm('您是否看到了浏览器桌面通知?\n\n点击"确定"表示看到了\n点击"取消"表示没看到'); + setNotificationShown(sawNotification); + + if (!sawNotification) { + // 没看到通知,展开故障排查面板 + if (!isTroubleshootOpen) { + onTroubleshootToggle(); + } + } + }, 500); + } + }, [testCountdown, isTestingNotification, isTroubleshootOpen, onTroubleshootToggle]); + // 浏览器权限状态标签 const getPermissionLabel = () => { switch (browserPermission) { @@ -86,51 +149,6 @@ const NotificationTestTool = () => { setTestCount(prev => prev + 1); }; - // 股票动向测试数据(涨) - const testStockAlertUp = () => { - addNotification({ - type: NOTIFICATION_TYPES.STOCK_ALERT, - priority: PRIORITY_LEVELS.URGENT, - title: '【测试】您关注的股票触发预警', - content: '宁德时代(300750) 当前价格 ¥245.50,盘中涨幅达 +5.2%,已触达您设置的目标价位', - publishTime: Date.now(), - pushTime: Date.now(), - isAIGenerated: false, - clickable: true, - link: '/stock-overview?code=300750', - extra: { - stockCode: '300750', - stockName: '宁德时代', - priceChange: '+5.2%', - currentPrice: '245.50', - }, - autoClose: 10000, - }); - setTestCount(prev => prev + 1); - }; - - // 股票动向测试数据(跌) - const testStockAlertDown = () => { - addNotification({ - type: NOTIFICATION_TYPES.STOCK_ALERT, - priority: PRIORITY_LEVELS.IMPORTANT, - title: '【测试】您关注的股票异常波动', - content: '比亚迪(002594) 5分钟内跌幅达 -3.8%,当前价格 ¥198.20,建议关注', - publishTime: Date.now(), - pushTime: Date.now(), - isAIGenerated: false, - clickable: true, - link: '/stock-overview?code=002594', - extra: { - stockCode: '002594', - stockName: '比亚迪', - priceChange: '-3.8%', - currentPrice: '198.20', - }, - autoClose: 10000, - }); - setTestCount(prev => prev + 1); - }; // 事件动向测试数据 const testEventAlert = () => { @@ -179,30 +197,6 @@ const NotificationTestTool = () => { setTestCount(prev => prev + 1); }; - // AI分析报告测试数据 - const testAIReport = () => { - addNotification({ - type: NOTIFICATION_TYPES.ANALYSIS_REPORT, - priority: PRIORITY_LEVELS.NORMAL, - title: '【测试】AI产业链投资机会分析', - content: '随着大模型应用加速落地,算力、数据、应用三大方向均存在投资机会,重点关注海光信息、寒武纪', - publishTime: Date.now(), - pushTime: Date.now(), - author: { - name: 'AI分析师', - organization: '价值前沿', - }, - isAIGenerated: true, - clickable: true, - link: '/forecast-report?id=test005', - extra: { - reportType: '策略报告', - industry: '人工智能', - }, - autoClose: 12000, - }); - setTestCount(prev => prev + 1); - }; // 预测通知测试数据(不可跳转) const testPrediction = () => { @@ -272,42 +266,11 @@ const NotificationTestTool = () => { }, 5000); }; - // 测试全部类型(层叠效果) - const testAllTypes = () => { - const tests = [testAnnouncement, testStockAlertUp, testEventAlert, testAnalysisReport]; - tests.forEach((test, index) => { - setTimeout(() => test(), index * 600); - }); - }; - - // 测试优先级 - const testPriority = () => { - [ - { priority: PRIORITY_LEVELS.URGENT, label: '紧急' }, - { priority: PRIORITY_LEVELS.IMPORTANT, label: '重要' }, - { priority: PRIORITY_LEVELS.NORMAL, label: '普通' }, - ].forEach((item, index) => { - setTimeout(() => { - addNotification({ - type: NOTIFICATION_TYPES.ANNOUNCEMENT, - priority: item.priority, - title: `【测试】${item.label}优先级通知`, - content: `这是一条${item.label}优先级的测试通知,用于验证优先级标签显示`, - publishTime: Date.now(), - pushTime: Date.now(), - isAIGenerated: false, - clickable: false, - autoClose: 10000, - }); - setTestCount(prev => prev + 1); - }, index * 600); - }); - }; return ( { 公告通知 - {/* 股票动向 */} - - - - - {/* 事件动向 */} {/* 分析报告 */} - - - AI - - + {/* 预测通知 */} - - {/* 优先级测试 */} - - {/* 预测→详情流程测试 */} )} + {/* 测试浏览器通知按钮 */} + {browserPermission === 'granted' && ( + + )} + {/* 浏览器通知状态说明 */} {browserPermission === 'granted' && ( @@ -491,6 +488,136 @@ const NotificationTestTool = () => { )} + {/* 实时权限状态 */} + + + 实际权限: + + + {('Notification' in window) ? Notification.permission : '不支持'} + + + + {/* 环境警告 */} + {isFullscreen && ( + + + + 全屏模式 + 某些浏览器在全屏模式下不显示通知 + + + )} + + {isMacOS && notificationShown === false && ( + + + + 未检测到通知显示 + 可能是专注模式阻止了通知 + + + )} + + + + {/* 故障排查面板 */} + + + + + + + 如果看不到浏览器通知,请检查: + + + {/* macOS 专注模式 */} + {isMacOS && ( + + + + macOS 专注模式 + + + 点击右上角控制中心 + 关闭「专注模式」或「勿扰模式」 + 或者:系统设置 → 专注模式 → 关闭 + + + + + )} + + {/* macOS 系统通知设置 */} + {isMacOS && ( + + + + macOS 系统通知设置 + + + 系统设置 → 通知 + 找到 Google ChromeMicrosoft Edge + 确保「允许通知」已开启 + 通知样式设置为「横幅」或「提醒」 + + + + + )} + + {/* Chrome 浏览器设置 */} + + + + Chrome 浏览器设置 + + + 地址栏输入: chrome://settings/content/notifications + 确保「网站可以请求发送通知」已开启 + 检查本站点是否在「允许」列表中 + + + + + + {/* 全屏模式提示 */} + {isFullscreen && ( + + + + 退出全屏模式 + + 按 ESC 键退出全屏,然后重新测试 + + + + )} + + {/* 测试结果反馈 */} + {notificationShown === true && ( + + + ✅ 通知功能正常! + + )} + + + + {/* 功能按钮 */} diff --git a/src/contexts/AuthContext.js b/src/contexts/AuthContext.js index 8dc81e1e..add8e8bc 100755 --- a/src/contexts/AuthContext.js +++ b/src/contexts/AuthContext.js @@ -3,6 +3,7 @@ import React, { createContext, useContext, useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { useToast } from '@chakra-ui/react'; import { logger } from '../utils/logger'; +import { useNotification } from '../contexts/NotificationContext'; // 创建认证上下文 const AuthContext = createContext(); @@ -23,6 +24,7 @@ export const AuthProvider = ({ children }) => { const [isAuthenticated, setIsAuthenticated] = useState(false); const navigate = useNavigate(); const toast = useToast(); + const { showWelcomeGuide } = useNotification(); // 检查Session状态 const checkSession = async () => { @@ -156,6 +158,11 @@ export const AuthProvider = ({ children }) => { // isClosable: true, // }); + // ⚡ 登录成功后显示欢迎引导(延迟2秒,避免与登录Toast冲突) + setTimeout(() => { + showWelcomeGuide(); + }, 2000); + return { success: true }; } catch (error) { @@ -203,6 +210,11 @@ export const AuthProvider = ({ children }) => { isClosable: true, }); + // ⚡ 注册成功后显示欢迎引导(延迟2秒) + setTimeout(() => { + showWelcomeGuide(); + }, 2000); + return { success: true }; } catch (error) { @@ -252,6 +264,11 @@ export const AuthProvider = ({ children }) => { isClosable: true, }); + // ⚡ 注册成功后显示欢迎引导(延迟2秒) + setTimeout(() => { + showWelcomeGuide(); + }, 2000); + return { success: true }; } catch (error) { @@ -299,6 +316,11 @@ export const AuthProvider = ({ children }) => { isClosable: true, }); + // ⚡ 注册成功后显示欢迎引导(延迟2秒) + setTimeout(() => { + showWelcomeGuide(); + }, 2000); + return { success: true }; } catch (error) { diff --git a/src/contexts/NotificationContext.js b/src/contexts/NotificationContext.js index 30e5b094..68c0f25e 100644 --- a/src/contexts/NotificationContext.js +++ b/src/contexts/NotificationContext.js @@ -60,6 +60,8 @@ export const NotificationProvider = ({ children }) => { const [maxReconnectAttempts, setMaxReconnectAttempts] = useState(Infinity); const audioRef = useRef(null); const reconnectedTimerRef = useRef(null); // 用于自动消失 RECONNECTED 状态 + const processedEventIds = useRef(new Set()); // 用于Socket层去重,记录已处理的事件ID + const MAX_PROCESSED_IDS = 1000; // 最多保留1000个ID,避免内存泄漏 // ⚡ 使用权限引导管理 Hook const { shouldShowGuide, markGuideAsShown } = usePermissionGuide(); @@ -435,12 +437,23 @@ export const NotificationProvider = ({ children }) => { * @param {object} notification - 通知对象 */ const addNotification = useCallback(async (notification) => { + // ========== 显示层去重检查 ========== + const notificationId = notification.id || `notif_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + // 检查当前显示队列中是否已存在该通知 + const isDuplicate = notifications.some(n => n.id === notificationId); + if (isDuplicate) { + logger.debug('NotificationContext', 'Duplicate notification ignored at display level', { id: notificationId }); + return notificationId; // 返回ID但不显示 + } + // ========== 显示层去重检查结束 ========== + // 根据优先级获取自动关闭时长 const priority = notification.priority || PRIORITY_LEVELS.NORMAL; const defaultAutoClose = NOTIFICATION_CONFIG.autoCloseDuration[priority]; const newNotification = { - id: notification.id || `notif_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + id: notificationId, // 使用预先生成的ID type: notification.type || 'info', severity: notification.severity || 'info', title: notification.title || '通知', @@ -453,94 +466,108 @@ 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 }) => ( - - - - - {newNotification.title} - - - 💡 开启桌面通知以便后台接收 - - - - + // ========== 增强权限请求策略 ========== + // 只要收到通知,就检查并提示用户授权 + + // 如果权限是default(未授权),自动请求 + if (browserPermission === 'default' && !hasRequestedPermission) { + logger.info('NotificationContext', 'Auto-requesting browser permission on notification'); + await requestBrowserPermission(); + } + // 如果权限是denied(已拒绝),提供设置指引 + else if (browserPermission === 'denied') { + const toastId = 'browser-permission-denied-guide'; + if (!toast.isActive(toastId)) { + toast({ + id: toastId, + duration: 12000, + isClosable: true, + position: 'top', + render: ({ onClose }) => ( + + + + + + 浏览器通知已被拒绝 + - - ), - }); - } + + {newNotification.title} + + + 💡 如需接收桌面通知,请在浏览器设置中允许通知权限 + + + Chrome: 地址栏左侧 🔒 → 网站设置 → 通知 + Safari: 偏好设置 → 网站 → 通知 + Edge: 地址栏右侧 ⋯ → 网站权限 → 通知 + + + + + ), + }); } } const isPageHidden = document.hidden; // 页面是否在后台 - // ========== 智能分发策略 ========== - + // ========== 原分发策略(按优先级区分)- 已废弃 ========== // 策略 1: 紧急通知 - 双重保障(浏览器 + 网页) - if (priority === PRIORITY_LEVELS.URGENT) { - logger.info('NotificationContext', 'Urgent notification: sending browser + web'); - // 总是发送浏览器通知 - sendBrowserNotification(newNotification); - // 如果在前台,也显示网页通知 - if (!isPageHidden) { - addWebNotification(newNotification); - } - } + // if (priority === PRIORITY_LEVELS.URGENT) { + // logger.info('NotificationContext', 'Urgent notification: sending browser + web'); + // // 总是发送浏览器通知 + // sendBrowserNotification(newNotification); + // // 如果在前台,也显示网页通知 + // if (!isPageHidden) { + // addWebNotification(newNotification); + // } + // } // 策略 2: 重要通知 - 智能切换(后台=浏览器,前台=网页) - else if (priority === PRIORITY_LEVELS.IMPORTANT) { - if (isPageHidden) { - logger.info('NotificationContext', 'Important notification (background): sending browser'); - sendBrowserNotification(newNotification); - } else { - logger.info('NotificationContext', 'Important notification (foreground): sending web'); - addWebNotification(newNotification); - } - } + // else if (priority === PRIORITY_LEVELS.IMPORTANT) { + // if (isPageHidden) { + // logger.info('NotificationContext', 'Important notification (background): sending browser'); + // sendBrowserNotification(newNotification); + // } else { + // logger.info('NotificationContext', 'Important notification (foreground): sending web'); + // addWebNotification(newNotification); + // } + // } // 策略 3: 普通通知 - 仅网页通知 - else { - logger.info('NotificationContext', 'Normal notification: sending web only'); + // else { + // logger.info('NotificationContext', 'Normal notification: sending web only'); + // addWebNotification(newNotification); + // } + + // ========== 新分发策略(仅区分前后台) ========== + if (isPageHidden) { + // 页面在后台:发送浏览器通知 + logger.info('NotificationContext', 'Page hidden: sending browser notification'); + sendBrowserNotification(newNotification); + } else { + // 页面在前台:发送网页通知 + logger.info('NotificationContext', 'Page visible: sending web notification'); addWebNotification(newNotification); } return newNotification.id; - }, [sendBrowserNotification, addWebNotification, browserPermission, hasRequestedPermission, requestBrowserPermission]); + }, [notifications, toast, sendBrowserNotification, addWebNotification, browserPermission, hasRequestedPermission, requestBrowserPermission]); // 连接到 Socket 服务 useEffect(() => { @@ -624,6 +651,27 @@ export const NotificationProvider = ({ children }) => { socket.on('new_event', (data) => { logger.info('NotificationContext', 'Received new event', data); + // ========== Socket层去重检查 ========== + const eventId = data.id || `${data.type}_${data.publishTime}`; + + if (processedEventIds.current.has(eventId)) { + logger.debug('NotificationContext', 'Duplicate event ignored at socket level', { eventId }); + return; // 重复事件,直接忽略 + } + + // 记录已处理的事件ID + processedEventIds.current.add(eventId); + + // 限制Set大小,避免内存泄漏 + if (processedEventIds.current.size > MAX_PROCESSED_IDS) { + const idsArray = Array.from(processedEventIds.current); + processedEventIds.current = new Set(idsArray.slice(-MAX_PROCESSED_IDS)); + logger.debug('NotificationContext', 'Cleaned up old processed event IDs', { + kept: MAX_PROCESSED_IDS + }); + } + // ========== Socket层去重检查结束 ========== + // 使用适配器转换事件格式 const notification = adaptEventToNotification(data); addNotification(notification);