From a9fee411ea7ceb9840197034e2cd704ff9c38a9d Mon Sep 17 00:00:00 2001
From: zdl <3489966805@qq.com>
Date: Wed, 22 Oct 2025 15:23:36 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9D=83=E9=99=90=E5=BC=95=E5=AF=BC?=
=?UTF-8?q?=E8=83=BD=E5=8A=9B=E6=B5=8B=E8=AF=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/contexts/NotificationContext.js | 132 ++++++++++++++++++++-
src/hooks/usePermissionGuide.js | 170 ++++++++++++++++++++++++++++
src/views/Community/index.js | 16 ++-
3 files changed, 316 insertions(+), 2 deletions(-)
create mode 100644 src/hooks/usePermissionGuide.js
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 (