feat: 权限引导能力测试
This commit is contained in:
@@ -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 }) => (
|
||||
<Box
|
||||
p={4}
|
||||
bg="blue.500"
|
||||
color="white"
|
||||
borderRadius="md"
|
||||
boxShadow="lg"
|
||||
maxW="400px"
|
||||
>
|
||||
<VStack spacing={3} align="stretch">
|
||||
{icon && (
|
||||
<HStack spacing={2}>
|
||||
<Icon as={BellIcon} boxSize={5} />
|
||||
<Text fontWeight="bold" fontSize="md">
|
||||
{title}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
<Text fontSize="sm" opacity={0.9}>
|
||||
{description}
|
||||
</Text>
|
||||
<HStack spacing={2} justify="flex-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="whiteAlpha"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
markGuideAsShown(guideType);
|
||||
}}
|
||||
>
|
||||
稍后再说
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="whiteAlpha"
|
||||
bg="whiteAlpha.300"
|
||||
_hover={{ bg: 'whiteAlpha.400' }}
|
||||
onClick={async () => {
|
||||
onClose();
|
||||
markGuideAsShown(guideType);
|
||||
await requestBrowserPermission();
|
||||
}}
|
||||
>
|
||||
立即开启
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
),
|
||||
});
|
||||
|
||||
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 (
|
||||
|
||||
170
src/hooks/usePermissionGuide.js
Normal file
170
src/hooks/usePermissionGuide.js
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 (
|
||||
<Box minH="100vh" bg={bgColor}>
|
||||
|
||||
Reference in New Issue
Block a user