diff --git a/src/contexts/NotificationContext.js b/src/contexts/NotificationContext.js index 9a72aebf..30e5b094 100644 --- a/src/contexts/NotificationContext.js +++ b/src/contexts/NotificationContext.js @@ -12,7 +12,8 @@ */ import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'; -import { useToast, Box, HStack, Text, Button, CloseButton } from '@chakra-ui/react'; +import { useToast, Box, HStack, Text, Button, CloseButton, VStack, Icon } from '@chakra-ui/react'; +import { BellIcon } from '@chakra-ui/icons'; import { logger } from '../utils/logger'; import socket, { SOCKET_TYPE } from '../services/socket'; import notificationSound from '../assets/sounds/notification.wav'; @@ -20,6 +21,7 @@ import { browserNotificationService } from '../services/browserNotificationServi import { notificationMetricsService } from '../services/notificationMetricsService'; import { notificationHistoryService } from '../services/notificationHistoryService'; import { PRIORITY_LEVELS, NOTIFICATION_CONFIG, NOTIFICATION_TYPES } from '../constants/notificationTypes'; +import { usePermissionGuide, GUIDE_TYPES } from '../hooks/usePermissionGuide'; // 连接状态枚举 const CONNECTION_STATUS = { @@ -59,6 +61,9 @@ export const NotificationProvider = ({ children }) => { const audioRef = useRef(null); const reconnectedTimerRef = useRef(null); // 用于自动消失 RECONNECTED 状态 + // ⚡ 使用权限引导管理 Hook + const { shouldShowGuide, markGuideAsShown } = usePermissionGuide(); + // 初始化音频 useEffect(() => { try { @@ -160,6 +165,127 @@ export const NotificationProvider = ({ children }) => { return permission; }, [toast]); + /** + * ⚡ 显示权限引导(通用方法) + * @param {string} guideType - 引导类型 + * @param {object} options - 引导选项 + */ + const showPermissionGuide = useCallback((guideType, options = {}) => { + // 检查是否应该显示引导 + if (!shouldShowGuide(guideType)) { + logger.debug('NotificationContext', 'Guide already shown, skipping', { guideType }); + return; + } + + // 检查权限状态:只在未授权时显示引导 + if (browserPermission === 'granted') { + logger.debug('NotificationContext', 'Permission already granted, skipping guide', { guideType }); + return; + } + + // 默认选项 + const { + title = '开启桌面通知', + description = '及时接收重要事件和股票提醒', + icon = true, + duration = 10000, + } = options; + + // 显示引导 Toast + const toastId = `permission-guide-${guideType}`; + if (!toast.isActive(toastId)) { + toast({ + id: toastId, + duration, + render: ({ onClose }) => ( + + + {icon && ( + + + + {title} + + + )} + + {description} + + + + + + + + ), + }); + + logger.info('NotificationContext', 'Permission guide shown', { guideType }); + } + }, [toast, shouldShowGuide, markGuideAsShown, browserPermission, requestBrowserPermission]); + + /** + * ⚡ 显示欢迎引导(登录后) + */ + const showWelcomeGuide = useCallback(() => { + showPermissionGuide(GUIDE_TYPES.WELCOME, { + title: '🎉 欢迎使用价值前沿', + description: '开启桌面通知,第一时间接收重要投资事件和股票提醒', + duration: 12000, + }); + }, [showPermissionGuide]); + + /** + * ⚡ 显示社区功能引导 + */ + const showCommunityGuide = useCallback(() => { + showPermissionGuide(GUIDE_TYPES.COMMUNITY, { + title: '关注感兴趣的事件', + description: '开启通知后,您关注的事件有新动态时会第一时间提醒您', + duration: 10000, + }); + }, [showPermissionGuide]); + + /** + * ⚡ 显示首次关注引导 + */ + const showFirstFollowGuide = useCallback(() => { + showPermissionGuide(GUIDE_TYPES.FIRST_FOLLOW, { + title: '关注成功', + description: '开启桌面通知,事件有更新时我们会及时提醒您', + duration: 8000, + }); + }, [showPermissionGuide]); + /** * 发送浏览器通知 */ @@ -626,6 +752,10 @@ export const NotificationProvider = ({ children }) => { requestBrowserPermission, trackNotificationClick, retryConnection, + // ⚡ 新增:权限引导方法 + showWelcomeGuide, + showCommunityGuide, + showFirstFollowGuide, }; return ( diff --git a/src/hooks/usePermissionGuide.js b/src/hooks/usePermissionGuide.js new file mode 100644 index 00000000..51e11efd --- /dev/null +++ b/src/hooks/usePermissionGuide.js @@ -0,0 +1,170 @@ +// src/hooks/usePermissionGuide.js +/** + * 通知权限引导管理 Hook + * + * 功能: + * - 管理多个引导场景的显示状态 + * - 使用 localStorage 持久化记录 + * - 支持定期提醒策略 + */ + +import { useState, useCallback, useEffect } from 'react'; +import { logger } from '../utils/logger'; + +// 引导场景类型 +export const GUIDE_TYPES = { + WELCOME: 'welcome', // 首次登录欢迎引导 + COMMUNITY: 'community', // 社区功能引导 + FIRST_FOLLOW: 'first_follow', // 首次关注事件引导 + PERIODIC: 'periodic', // 定期提醒 +}; + +// localStorage 键名 +const STORAGE_KEYS = { + SHOWN_GUIDES: 'notification_guides_shown', + LAST_PERIODIC: 'notification_last_periodic_prompt', + TOTAL_PROMPTS: 'notification_total_prompts', +}; + +// 定期提醒间隔(毫秒) +const PERIODIC_INTERVAL = 3 * 24 * 60 * 60 * 1000; // 3 天 +const MAX_PERIODIC_PROMPTS = 3; // 最多提醒 3 次 + +/** + * 权限引导管理 Hook + */ +export function usePermissionGuide() { + const [shownGuides, setShownGuides] = useState(() => { + try { + const stored = localStorage.getItem(STORAGE_KEYS.SHOWN_GUIDES); + return stored ? JSON.parse(stored) : []; + } catch (error) { + logger.error('usePermissionGuide', 'Failed to load shown guides', error); + return []; + } + }); + + /** + * 检查是否应该显示某个引导 + * @param {string} guideType - 引导类型 + * @returns {boolean} + */ + const shouldShowGuide = useCallback((guideType) => { + // 已经显示过的引导不再显示 + if (shownGuides.includes(guideType)) { + return false; + } + + // 特殊逻辑:定期提醒 + if (guideType === GUIDE_TYPES.PERIODIC) { + try { + const lastPrompt = localStorage.getItem(STORAGE_KEYS.LAST_PERIODIC); + const totalPrompts = parseInt(localStorage.getItem(STORAGE_KEYS.TOTAL_PROMPTS) || '0', 10); + + // 超过最大提醒次数 + if (totalPrompts >= MAX_PERIODIC_PROMPTS) { + logger.debug('usePermissionGuide', 'Periodic prompts limit reached', { totalPrompts }); + return false; + } + + // 未到提醒间隔 + if (lastPrompt) { + const elapsed = Date.now() - parseInt(lastPrompt, 10); + if (elapsed < PERIODIC_INTERVAL) { + logger.debug('usePermissionGuide', 'Periodic interval not reached', { + elapsed: Math.round(elapsed / 1000 / 60 / 60), // 小时 + required: Math.round(PERIODIC_INTERVAL / 1000 / 60 / 60) + }); + return false; + } + } + + return true; + } catch (error) { + logger.error('usePermissionGuide', 'Failed to check periodic guide', error); + return false; + } + } + + return true; + }, [shownGuides]); + + /** + * 标记引导已显示 + * @param {string} guideType - 引导类型 + */ + const markGuideAsShown = useCallback((guideType) => { + try { + // 更新状态 + setShownGuides(prev => { + if (prev.includes(guideType)) { + return prev; + } + const updated = [...prev, guideType]; + // 持久化 + localStorage.setItem(STORAGE_KEYS.SHOWN_GUIDES, JSON.stringify(updated)); + logger.info('usePermissionGuide', 'Guide marked as shown', { guideType }); + return updated; + }); + + // 特殊处理:定期提醒 + if (guideType === GUIDE_TYPES.PERIODIC) { + localStorage.setItem(STORAGE_KEYS.LAST_PERIODIC, String(Date.now())); + + const totalPrompts = parseInt(localStorage.getItem(STORAGE_KEYS.TOTAL_PROMPTS) || '0', 10); + localStorage.setItem(STORAGE_KEYS.TOTAL_PROMPTS, String(totalPrompts + 1)); + + logger.info('usePermissionGuide', 'Periodic prompt recorded', { + totalPrompts: totalPrompts + 1 + }); + } + } catch (error) { + logger.error('usePermissionGuide', 'Failed to mark guide as shown', error); + } + }, []); + + /** + * 重置所有引导(用于测试或用户主动重置) + */ + const resetAllGuides = useCallback(() => { + try { + localStorage.removeItem(STORAGE_KEYS.SHOWN_GUIDES); + localStorage.removeItem(STORAGE_KEYS.LAST_PERIODIC); + localStorage.removeItem(STORAGE_KEYS.TOTAL_PROMPTS); + setShownGuides([]); + logger.info('usePermissionGuide', 'All guides reset'); + } catch (error) { + logger.error('usePermissionGuide', 'Failed to reset guides', error); + } + }, []); + + /** + * 获取定期提醒的统计信息(用于调试) + */ + const getPeriodicStats = useCallback(() => { + try { + const lastPrompt = localStorage.getItem(STORAGE_KEYS.LAST_PERIODIC); + const totalPrompts = parseInt(localStorage.getItem(STORAGE_KEYS.TOTAL_PROMPTS) || '0', 10); + + return { + lastPromptTime: lastPrompt ? new Date(parseInt(lastPrompt, 10)) : null, + totalPrompts, + remainingPrompts: MAX_PERIODIC_PROMPTS - totalPrompts, + nextPromptTime: lastPrompt + ? new Date(parseInt(lastPrompt, 10) + PERIODIC_INTERVAL) + : new Date(), + }; + } catch (error) { + logger.error('usePermissionGuide', 'Failed to get periodic stats', error); + return null; + } + }, []); + + return { + shouldShowGuide, + markGuideAsShown, + resetAllGuides, + getPeriodicStats, + shownGuides, + }; +} diff --git a/src/views/Community/index.js b/src/views/Community/index.js index c4e8ecec..240fa316 100644 --- a/src/views/Community/index.js +++ b/src/views/Community/index.js @@ -71,6 +71,7 @@ import ImportanceLegend from './components/ImportanceLegend'; import InvestmentCalendar from './components/InvestmentCalendar'; import { eventService } from '../../services/eventService'; import { logger } from '../../utils/logger'; +import { useNotification } from '../../contexts/NotificationContext'; // 导航栏已由 MainLayout 提供,无需在此导入 @@ -91,11 +92,14 @@ const Community = () => { const bgColor = useColorModeValue('gray.50', 'gray.900'); const cardBg = useColorModeValue('white', 'gray.800'); const borderColor = useColorModeValue('gray.200', 'gray.700'); - + // Modal/Drawer控制 const { isOpen: isEventModalOpen, onOpen: onEventModalOpen, onClose: onEventModalClose } = useDisclosure(); const { isOpen: isStockDrawerOpen, onOpen: onStockDrawerOpen, onClose: onStockDrawerClose } = useDisclosure(); + // ⚡ 通知权限引导 + const { showCommunityGuide } = useNotification(); + // 状态管理 const [events, setEvents] = useState([]); const [pagination, setPagination] = useState({ @@ -271,7 +275,17 @@ const Community = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchParams]); // 只监听 URL 参数变化 + // ⚡ 首次访问社区时,延迟显示权限引导 + useEffect(() => { + if (showCommunityGuide) { + const timer = setTimeout(() => { + logger.info('Community', '显示社区权限引导'); + showCommunityGuide(); + }, 5000); // 延迟 5 秒,让用户先浏览页面 + return () => clearTimeout(timer); + } + }, [showCommunityGuide]); // 只在组件挂载时执行一次 return (