feat: 添加消息推送能力

This commit is contained in:
zdl
2025-10-21 15:48:38 +08:00
parent 955e0db740
commit 38499ce650
8 changed files with 2485 additions and 417 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,10 @@
// src/components/NotificationContainer/index.js // src/components/NotificationContainer/index.js
/** /**
* 通知容器组件 - 右下角层叠显示实时通知 * 金融资讯通知容器组件 - 右下角层叠显示实时通知
*/ */
import React from 'react'; import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { import {
Box, Box,
VStack, VStack,
@@ -11,56 +12,69 @@ import {
Text, Text,
IconButton, IconButton,
Icon, Icon,
Badge,
Button,
useColorModeValue, useColorModeValue,
Slide, Slide,
ScaleFade, ScaleFade,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { MdClose, MdCheckCircle, MdError, MdWarning, MdInfo } from 'react-icons/md'; import { MdClose, MdOpenInNew, MdSchedule, MdExpandMore, MdExpandLess } from 'react-icons/md';
import { useNotification } from '../../contexts/NotificationContext'; import { useNotification } from '../../contexts/NotificationContext';
import {
// 通知类型对应的图标和颜色 NOTIFICATION_TYPE_CONFIGS,
const NOTIFICATION_STYLES = { NOTIFICATION_TYPES,
success: { PRIORITY_CONFIGS,
icon: MdCheckCircle, NOTIFICATION_CONFIG,
colorScheme: 'green', formatNotificationTime,
bg: 'green.50', } from '../../constants/notificationTypes';
borderColor: 'green.400',
iconColor: 'green.500',
},
error: {
icon: MdError,
colorScheme: 'red',
bg: 'red.50',
borderColor: 'red.400',
iconColor: 'red.500',
},
warning: {
icon: MdWarning,
colorScheme: 'orange',
bg: 'orange.50',
borderColor: 'orange.400',
iconColor: 'orange.500',
},
info: {
icon: MdInfo,
colorScheme: 'blue',
bg: 'blue.50',
borderColor: 'blue.400',
iconColor: 'blue.500',
},
};
/** /**
* 单个通知项组件 * 单个通知项组件
*/ */
const NotificationItem = ({ notification, onClose, isNewest = false }) => { const NotificationItem = ({ notification, onClose, isNewest = false }) => {
const { id, severity = 'info', title, message } = notification; const navigate = useNavigate();
const style = NOTIFICATION_STYLES[severity] || NOTIFICATION_STYLES.info; const { id, type, priority, title, content, isAIGenerated, clickable, link, author, publishTime, pushTime, extra } = notification;
const bgColor = useColorModeValue(style.bg, `${style.colorScheme}.900`); // 严格判断可点击性:只有 clickable=true 且 link 存在才可点击
const borderColor = useColorModeValue(style.borderColor, `${style.colorScheme}.500`); 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),
};
}
// 获取优先级配置
const priorityConfig = PRIORITY_CONFIGS[priority] || PRIORITY_CONFIGS.normal;
const bgColor = useColorModeValue(typeConfig.bg, `${typeConfig.colorScheme}.900`);
const borderColor = useColorModeValue(typeConfig.borderColor, `${typeConfig.colorScheme}.500`);
const textColor = useColorModeValue('gray.800', 'white'); const textColor = useColorModeValue('gray.800', 'white');
const subTextColor = useColorModeValue('gray.600', 'gray.300'); const subTextColor = useColorModeValue('gray.600', 'gray.300');
const metaTextColor = useColorModeValue('gray.500', 'gray.400');
const hoverBg = typeConfig.hoverBg;
const closeButtonHoverBg = useColorModeValue(`${typeConfig.colorScheme}.200`, `${typeConfig.colorScheme}.700`);
// 点击处理(只有真正可点击时才执行)
const handleClick = () => {
if (isActuallyClickable) {
navigate(link);
}
};
return ( return (
<ScaleFade initialScale={0.9} in={true}> <ScaleFade initialScale={0.9} in={true}>
@@ -69,72 +83,160 @@ const NotificationItem = ({ notification, onClose, isNewest = false }) => {
borderLeft="4px solid" borderLeft="4px solid"
borderColor={borderColor} borderColor={borderColor}
borderRadius="md" borderRadius="md"
boxShadow={isNewest ? '2xl' : 'lg'} // 最新消息更强的阴影 boxShadow={isNewest ? '2xl' : 'lg'}
p={4} p={4}
minW="350px" w="400px" // 统一宽度
maxW="450px"
position="relative" position="relative"
_hover={{ cursor={isActuallyClickable ? 'pointer' : 'default'} // 严格判断
onClick={isActuallyClickable ? handleClick : undefined} // 严格判断
_hover={isActuallyClickable ? {
boxShadow: 'xl', boxShadow: 'xl',
transform: 'translateX(-4px)', transform: 'translateY(-2px)',
}} bg: hoverBg,
} : {}} // 不可点击时无 hover 效果
transition="all 0.2s" transition="all 0.2s"
// 最新消息添加微妙的高亮边框
{...(isNewest && { {...(isNewest && {
borderRight: '1px solid', borderRight: '1px solid',
borderRightColor: borderColor, borderRightColor: borderColor,
borderTop: '1px solid', borderTop: '1px solid',
borderTopColor: useColorModeValue(`${style.colorScheme}.100`, `${style.colorScheme}.700`), borderTopColor: useColorModeValue(`${typeConfig.colorScheme}.100`, `${typeConfig.colorScheme}.700`),
})} })}
> >
<HStack spacing={3} align="start"> {/* 头部区域:图标 + 标题 + 优先级 + AI标识 */}
{/* 图标 */} <HStack spacing={2} align="start" mb={2}>
{/* 类型图标 */}
<Icon <Icon
as={style.icon} as={typeConfig.icon}
w={6} w={5}
h={6} h={5}
color={style.iconColor} color={typeConfig.iconColor}
mt={0.5} mt={0.5}
flexShrink={0} flexShrink={0}
/> />
{/* 内容 */} {/* 标题 */}
<VStack align="start" spacing={1} flex={1} mr={6}>
<Text <Text
fontSize="md" fontSize="sm"
fontWeight="bold" fontWeight="bold"
color={textColor} color={textColor}
lineHeight="short" lineHeight="short"
flex={1}
noOfLines={2}
> >
{title} {title}
</Text> </Text>
{message && (
<Text {/* 优先级标签 */}
fontSize="sm" {priorityConfig.show && (
color={subTextColor} <Badge
lineHeight="short" colorScheme={priorityConfig.colorScheme}
size="sm"
flexShrink={0}
> >
{message} {priorityConfig.label}
</Text> </Badge>
)}
{/* 预测标识 */}
{isPrediction && (
<Badge
colorScheme="gray"
size="sm"
flexShrink={0}
>
预测
</Badge>
)}
{/* AI 生成标识 */}
{isAIGenerated && (
<Badge
colorScheme="purple"
size="sm"
flexShrink={0}
>
AI
</Badge>
)} )}
</VStack>
{/* 关闭按钮 */} {/* 关闭按钮 */}
<IconButton <IconButton
icon={<MdClose />} icon={<MdClose />}
size="sm" size="xs"
variant="ghost" variant="ghost"
colorScheme={style.colorScheme} colorScheme={typeConfig.colorScheme}
aria-label="关闭通知" aria-label="关闭通知"
onClick={() => onClose(id)} onClick={(e) => {
position="absolute" e.stopPropagation();
top={2} onClose(id);
right={2} }}
flexShrink={0}
_hover={{ _hover={{
bg: useColorModeValue(`${style.colorScheme}.100`, `${style.colorScheme}.800`), bg: closeButtonHoverBg,
}} }}
/> />
</HStack> </HStack>
{/* 内容区域 */}
<Text
fontSize="sm"
color={subTextColor}
lineHeight="short"
noOfLines={3}
mb={3}
pl={7} // 与图标对齐
>
{content}
</Text>
{/* 底部元数据区域 */}
<HStack
spacing={2}
fontSize="xs"
color={metaTextColor}
pl={7} // 与图标对齐
flexWrap="wrap"
>
{/* 作者信息(仅分析报告) */}
{author && (
<HStack spacing={1}>
<Text>👤</Text>
<Text>{author.name} - {author.organization}</Text>
<Text>|</Text>
</HStack>
)}
{/* 时间信息 */}
<HStack spacing={1}>
<Text>📅</Text>
<Text>
{publishTime && formatNotificationTime(publishTime)}
{!publishTime && pushTime && formatNotificationTime(pushTime)}
</Text>
</HStack>
{/* 状态提示(仅预测通知) */}
{extra?.statusHint && (
<>
<Text>|</Text>
<HStack spacing={1} color="gray.400">
<Icon as={MdSchedule} w={3} h={3} />
<Text>{extra.statusHint}</Text>
</HStack>
</>
)}
{/* 可点击提示(仅真正可点击的通知) */}
{isActuallyClickable && (
<>
<Text>|</Text>
<HStack spacing={1}>
<Icon as={MdOpenInNew} w={3} h={3} />
<Text>查看详情</Text>
</HStack>
</>
)}
</HStack>
</Box> </Box>
</ScaleFade> </ScaleFade>
); );
@@ -145,12 +247,24 @@ const NotificationItem = ({ notification, onClose, isNewest = false }) => {
*/ */
const NotificationContainer = () => { const NotificationContainer = () => {
const { notifications, removeNotification } = useNotification(); const { notifications, removeNotification } = useNotification();
const [isExpanded, setIsExpanded] = useState(false);
// 如果没有通知,不渲染 // 如果没有通知,不渲染
if (notifications.length === 0) { if (notifications.length === 0) {
return null; 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');
return ( return (
<Box <Box
position="fixed" position="fixed"
@@ -164,10 +278,10 @@ const NotificationContainer = () => {
align="flex-end" align="flex-end"
pointerEvents="auto" pointerEvents="auto"
> >
{notifications.map((notification, index) => ( {visibleNotifications.map((notification, index) => (
<Slide <Slide
key={notification.id} key={notification.id}
direction="right" direction="bottom"
in={true} in={true}
style={{ style={{
position: 'relative', position: 'relative',
@@ -181,6 +295,28 @@ const NotificationContainer = () => {
/> />
</Slide> </Slide>
))} ))}
{/* 折叠/展开按钮 */}
{hasMore && (
<ScaleFade initialScale={0.9} in={true}>
<Button
size="sm"
variant="solid"
bg={collapseBg}
color={collapseTextColor}
_hover={{ bg: collapseHoverBg }}
leftIcon={<Icon as={isExpanded ? MdExpandLess : MdExpandMore} />}
onClick={() => setIsExpanded(!isExpanded)}
boxShadow="md"
borderRadius="md"
>
{isExpanded
? '收起通知'
: NOTIFICATION_CONFIG.collapse.textTemplate.replace('{count}', hiddenCount)
}
</Button>
</ScaleFade>
)}
</VStack> </VStack>
</Box> </Box>
); );

View File

@@ -1,7 +1,7 @@
// src/components/NotificationTestTool/index.js // src/components/NotificationTestTool/index.js
/** /**
* 通知测试工具 - 仅在开发环境显示 * 金融资讯通知测试工具 - 仅在开发环境显示
* 用于手动测试通知功能 * 用于手动测试4种通知类型
*/ */
import React, { useState } from 'react'; import React, { useState } from 'react';
@@ -15,87 +15,299 @@ import {
Collapse, Collapse,
useDisclosure, useDisclosure,
Badge, Badge,
Divider,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { MdNotifications, MdClose, MdVolumeOff, MdVolumeUp } from 'react-icons/md'; import { MdNotifications, MdClose, MdVolumeOff, MdVolumeUp, MdCampaign, MdTrendingUp, MdArticle, MdAssessment } from 'react-icons/md';
import { useNotification } from '../../contexts/NotificationContext'; import { useNotification } from '../../contexts/NotificationContext';
import { SOCKET_TYPE } from '../../services/socket'; import { SOCKET_TYPE } from '../../services/socket';
import { NOTIFICATION_TYPES, PRIORITY_LEVELS } from '../../constants/notificationTypes';
const NotificationTestTool = () => { const NotificationTestTool = () => {
const { isOpen, onToggle } = useDisclosure(); const { isOpen, onToggle } = useDisclosure();
const { addNotification, soundEnabled, toggleSound, isConnected, clearAllNotifications, notifications } = useNotification(); const { addNotification, soundEnabled, toggleSound, isConnected, clearAllNotifications, notifications, browserPermission, requestBrowserPermission } = useNotification();
const [testCount, setTestCount] = useState(0); const [testCount, setTestCount] = useState(0);
// 浏览器权限状态标签
const getPermissionLabel = () => {
switch (browserPermission) {
case 'granted':
return '已授权';
case 'denied':
return '已拒绝';
case 'default':
return '未授权';
default:
return '不支持';
}
};
const getPermissionColor = () => {
switch (browserPermission) {
case 'granted':
return 'green';
case 'denied':
return 'red';
case 'default':
return 'gray';
default:
return 'gray';
}
};
// 请求浏览器权限
const handleRequestPermission = async () => {
await requestBrowserPermission();
};
// 只在开发环境显示 // 只在开发环境显示
if (process.env.NODE_ENV !== 'development') { if (process.env.NODE_ENV !== 'development') {
return null; return null;
} }
const testNotifications = [ // 公告通知测试数据
{ const testAnnouncement = () => {
severity: 'success',
title: '买入成功',
message: '您的订单已成功执行:买入 贵州茅台(600519) 100股',
},
{
severity: 'error',
title: '委托失败',
message: '卖出订单失败:资金不足',
},
{
severity: 'warning',
title: '价格预警',
message: '您关注的股票已触达预设价格',
},
{
severity: 'info',
title: '持仓提醒',
message: '您持有的股票今日涨幅达 5.2%',
},
];
const handleTestNotification = (index) => {
const notif = testNotifications[index];
addNotification({ addNotification({
...notif, type: NOTIFICATION_TYPES.ANNOUNCEMENT,
type: 'trade_alert', priority: PRIORITY_LEVELS.IMPORTANT,
autoClose: 8000, title: '【测试】贵州茅台发布2024年度财报公告',
content: '2024年度营收同比增长15.2%净利润创历史新高董事会建议每10股派息180元',
publishTime: Date.now(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/event-detail/test001',
extra: {
announcementType: '财报',
companyCode: '600519',
companyName: '贵州茅台',
},
autoClose: 10000,
}); });
setTestCount(prev => prev + 1); setTestCount(prev => prev + 1);
}; };
const handleMultipleNotifications = () => { // 股票动向测试数据(涨)
testNotifications.forEach((notif, index) => { const testStockAlertUp = () => {
setTimeout(() => {
addNotification({ addNotification({
...notif, type: NOTIFICATION_TYPES.STOCK_ALERT,
type: 'trade_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, autoClose: 10000,
}); });
}, index * 600); setTestCount(prev => prev + 1);
});
setTestCount(prev => prev + testNotifications.length);
}; };
const handleMaxLimitTest = () => { // 股票动向测试数据(跌)
// 测试最大限制快速发送6条验证只保留最新5条 const testStockAlertDown = () => {
for (let i = 1; i <= 6; i++) {
setTimeout(() => {
addNotification({ addNotification({
severity: i % 2 === 0 ? 'success' : 'info', type: NOTIFICATION_TYPES.STOCK_ALERT,
title: `测试消息 #${i}`, priority: PRIORITY_LEVELS.IMPORTANT,
message: `这是第 ${i} 条测试消息共6条应只保留最新5条`, title: '【测试】您关注的股票异常波动',
type: 'trade_alert', 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 = () => {
addNotification({
type: NOTIFICATION_TYPES.EVENT_ALERT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: '【测试】央行宣布降准0.5个百分点',
content: '中国人民银行宣布下调金融机构存款准备金率0.5个百分点释放长期资金约1万亿元利好股市',
publishTime: Date.now(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/event-detail/test003',
extra: {
eventId: 'test003',
relatedStocks: 12,
impactLevel: '重大利好',
},
autoClose: 12000, autoClose: 12000,
}); });
}, i * 400); setTestCount(prev => prev + 1);
} };
setTestCount(prev => prev + 6);
// 分析报告测试数据非AI
const testAnalysisReport = () => {
addNotification({
type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: '【测试】医药行业深度报告:创新药迎来政策拐点',
content: 'CXO板块持续受益于全球创新药研发外包需求建议关注药明康德、凯莱英等龙头企业',
publishTime: Date.now(),
pushTime: Date.now(),
author: {
name: '李明',
organization: '中信证券',
},
isAIGenerated: false,
clickable: true,
link: '/forecast-report?id=test004',
extra: {
reportType: '行业研报',
industry: '医药',
},
autoClose: 12000,
});
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 = () => {
addNotification({
type: NOTIFICATION_TYPES.EVENT_ALERT,
priority: PRIORITY_LEVELS.NORMAL,
title: '【测试】【预测】央行可能宣布降准政策',
content: '基于最新宏观数据分析预计央行将在本周宣布降准0.5个百分点,释放长期资金',
publishTime: Date.now(),
pushTime: Date.now(),
isAIGenerated: true,
clickable: false, // ❌ 不可点击
link: null,
extra: {
isPrediction: true,
statusHint: '详细报告生成中...',
},
autoClose: 15000,
});
setTestCount(prev => prev + 1);
};
// 预测→详情流程测试先推预测5秒后推详情
const testPredictionFlow = () => {
// 阶段 1: 推送预测
addNotification({
type: NOTIFICATION_TYPES.EVENT_ALERT,
priority: PRIORITY_LEVELS.NORMAL,
title: '【测试】【预测】新能源汽车补贴政策将延期',
content: '根据政策趋势分析预计财政部将宣布新能源汽车购置补贴政策延长至2025年底',
publishTime: Date.now(),
pushTime: Date.now(),
isAIGenerated: true,
clickable: false,
link: null,
extra: {
isPrediction: true,
statusHint: '详细报告生成中...',
relatedPredictionId: 'pred_test_001',
},
autoClose: 15000,
});
setTestCount(prev => prev + 1);
// 阶段 2: 5秒后推送详情
setTimeout(() => {
addNotification({
type: NOTIFICATION_TYPES.EVENT_ALERT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: '【测试】新能源汽车补贴政策延期至2025年底',
content: '财政部宣布新能源汽车购置补贴政策延长至2025年底涉及比亚迪、理想汽车等5家龙头企业',
publishTime: Date.now(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true, // ✅ 可点击
link: '/event-detail/test_pred_001',
extra: {
isPrediction: false,
relatedPredictionId: 'pred_test_001',
eventId: 'test_pred_001',
relatedStocks: 5,
impactLevel: '重大利好',
},
autoClose: 12000,
});
setTestCount(prev => prev + 1);
}, 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 ( return (
<Box <Box
position="fixed" position="fixed"
top={4} top="316px"
right={4} right={4}
zIndex={9998} zIndex={9998}
bg="white" bg="white"
@@ -114,7 +326,7 @@ const NotificationTestTool = () => {
> >
<MdNotifications size={20} /> <MdNotifications size={20} />
<Text fontSize="sm" fontWeight="bold"> <Text fontSize="sm" fontWeight="bold">
通知测试工具 金融资讯测试工具
</Text> </Text>
<Badge colorScheme={isConnected ? 'green' : 'red'} ml="auto"> <Badge colorScheme={isConnected ? 'green' : 'red'} ml="auto">
{isConnected ? 'Connected' : 'Disconnected'} {isConnected ? 'Connected' : 'Disconnected'}
@@ -122,6 +334,9 @@ const NotificationTestTool = () => {
<Badge colorScheme="purple"> <Badge colorScheme="purple">
{SOCKET_TYPE} {SOCKET_TYPE}
</Badge> </Badge>
<Badge colorScheme={getPermissionColor()}>
浏览器: {getPermissionLabel()}
</Badge>
<IconButton <IconButton
icon={isOpen ? <MdClose /> : <MdNotifications />} icon={isOpen ? <MdClose /> : <MdNotifications />}
size="xs" size="xs"
@@ -133,60 +348,151 @@ const NotificationTestTool = () => {
{/* 工具面板 */} {/* 工具面板 */}
<Collapse in={isOpen} animateOpacity> <Collapse in={isOpen} animateOpacity>
<VStack p={4} spacing={3} align="stretch" minW="250px"> <VStack p={4} spacing={3} align="stretch" minW="280px">
<Text fontSize="xs" color="gray.600"> <Text fontSize="xs" color="gray.600" fontWeight="bold">
点击按钮测试不同类型的通知 通知类型测试
</Text> </Text>
{/* 测试按钮 */} {/* 公告通知 */}
<Button
size="sm"
colorScheme="green"
onClick={() => handleTestNotification(0)}
>
成功通知
</Button>
<Button
size="sm"
colorScheme="red"
onClick={() => handleTestNotification(1)}
>
错误通知
</Button>
<Button
size="sm"
colorScheme="orange"
onClick={() => handleTestNotification(2)}
>
警告通知
</Button>
<Button <Button
size="sm" size="sm"
colorScheme="blue" colorScheme="blue"
onClick={() => handleTestNotification(3)} leftIcon={<MdCampaign />}
onClick={testAnnouncement}
> >
信息通知 公告通知
</Button> </Button>
{/* 股票动向 */}
<HStack spacing={2}>
<Button
size="sm"
colorScheme="red"
leftIcon={<MdTrendingUp />}
onClick={testStockAlertUp}
flex={1}
>
股票上涨
</Button>
<Button
size="sm"
colorScheme="green"
leftIcon={<MdTrendingUp style={{ transform: 'rotate(180deg)' }} />}
onClick={testStockAlertDown}
flex={1}
>
股票下跌
</Button>
</HStack>
{/* 事件动向 */}
<Button
size="sm"
colorScheme="orange"
leftIcon={<MdArticle />}
onClick={testEventAlert}
>
事件动向
</Button>
{/* 分析报告 */}
<HStack spacing={2}>
<Button <Button
size="sm" size="sm"
colorScheme="purple" colorScheme="purple"
onClick={handleMultipleNotifications} leftIcon={<MdAssessment />}
onClick={testAnalysisReport}
flex={1}
> >
层叠通知4 分析报告
</Button>
<Badge colorScheme="purple" alignSelf="center">AI</Badge>
<Button
size="sm"
colorScheme="purple"
variant="outline"
onClick={testAIReport}
flex={1}
>
AI报告
</Button>
</HStack>
{/* 预测通知 */}
<Button
size="sm"
colorScheme="gray"
leftIcon={<MdArticle />}
onClick={testPrediction}
>
预测通知不可跳转
</Button> </Button>
<Divider />
<Text fontSize="xs" color="gray.600" fontWeight="bold">
组合测试
</Text>
{/* 层叠测试 */}
<Button
size="sm"
colorScheme="teal"
onClick={testAllTypes}
>
层叠测试4种类型
</Button>
{/* 优先级测试 */}
<Button <Button
size="sm" size="sm"
colorScheme="pink" colorScheme="pink"
onClick={handleMaxLimitTest} onClick={testPriority}
> >
测试最大限制65 优先级测试3个级别
</Button> </Button>
{/* 预测→详情流程测试 */}
<Button
size="sm"
colorScheme="cyan"
onClick={testPredictionFlow}
>
预测详情流程5秒延迟
</Button>
<Divider />
<Text fontSize="xs" color="gray.600" fontWeight="bold">
浏览器通知
</Text>
{/* 请求权限按钮 */}
{browserPermission !== 'granted' && (
<Button
size="sm"
colorScheme={browserPermission === 'denied' ? 'red' : 'blue'}
onClick={handleRequestPermission}
isDisabled={browserPermission === 'denied'}
>
{browserPermission === 'denied' ? '权限已拒绝' : '请求浏览器权限'}
</Button>
)}
{/* 浏览器通知状态说明 */}
{browserPermission === 'granted' && (
<Text fontSize="xs" color="green.500">
浏览器通知已启用
</Text>
)}
{browserPermission === 'denied' && (
<Text fontSize="xs" color="red.500">
请在浏览器设置中允许通知
</Text>
)}
<Divider />
{/* 功能按钮 */} {/* 功能按钮 */}
<HStack spacing={2}> <HStack spacing={2}>
<Button <Button

View File

@@ -17,26 +17,28 @@ export default function SubscriptionButton({ subscriptionInfo, onClick }) {
const getButtonStyles = () => { const getButtonStyles = () => {
if (subscriptionInfo.type === 'max') { if (subscriptionInfo.type === 'max') {
return { return {
bg: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', bg: 'transparent',
color: 'white', color: '#3182CE',
icon: '👑', icon: '👑',
label: 'Max', label: 'Max',
shadow: '0 4px 12px rgba(118, 75, 162, 0.4)', shadow: 'none',
hoverShadow: '0 6px 16px rgba(118, 75, 162, 0.5)', hoverShadow: '0 2px 8px rgba(49, 130, 206, 0.2)',
border: 'none', border: '1.5px solid',
accentColor: '#764ba2', borderColor: '#4299E1',
accentColor: '#3182CE',
}; };
} }
if (subscriptionInfo.type === 'pro') { if (subscriptionInfo.type === 'pro') {
return { return {
bg: 'linear-gradient(135deg, #667eea 0%, #3182CE 100%)', bg: 'transparent',
color: 'white', color: '#667eea',
icon: '💎', icon: '💎',
label: 'Pro', label: 'Pro',
shadow: '0 4px 12px rgba(49, 130, 206, 0.4)', shadow: 'none',
hoverShadow: '0 6px 16px rgba(49, 130, 206, 0.5)', hoverShadow: '0 2px 8px rgba(102, 126, 234, 0.2)',
border: 'none', border: '1.5px solid',
accentColor: '#3182CE', borderColor: '#667eea',
accentColor: '#667eea',
}; };
} }
// 基础版 // 基础版
@@ -168,11 +170,11 @@ export default function SubscriptionButton({ subscriptionInfo, onClick }) {
<Box <Box
as="button" as="button"
onClick={onClick} onClick={onClick}
px={3} px={2}
py={2} py={1}
minW="60px" w="70px"
h="40px" h="32px"
borderRadius="lg" borderRadius="md"
bg={styles.bg} bg={styles.bg}
color={styles.color} color={styles.color}
border={styles.border} border={styles.border}
@@ -184,7 +186,7 @@ export default function SubscriptionButton({ subscriptionInfo, onClick }) {
alignItems="center" alignItems="center"
justifyContent="center" justifyContent="center"
_hover={{ _hover={{
transform: 'translateY(-2px)', transform: 'translateY(-1px)',
boxShadow: styles.hoverShadow, boxShadow: styles.hoverShadow,
}} }}
_active={{ _active={{
@@ -192,7 +194,7 @@ export default function SubscriptionButton({ subscriptionInfo, onClick }) {
}} }}
> >
<Text fontSize="sm" fontWeight="600" lineHeight="1"> <Text fontSize="sm" fontWeight="600" lineHeight="1">
{styles.icon} {styles.label} <Text as="span" fontSize="md">{styles.icon}</Text> {styles.label}
</Text> </Text>
</Box> </Box>
</Tooltip> </Tooltip>

View File

@@ -0,0 +1,181 @@
// src/constants/notificationTypes.js
/**
* 金融资讯通知系统 - 类型定义和常量
*/
import { MdCampaign, MdTrendingUp, MdTrendingDown, MdArticle, MdAssessment } from 'react-icons/md';
// 通知类型
export const NOTIFICATION_TYPES = {
ANNOUNCEMENT: 'announcement', // 公告通知
STOCK_ALERT: 'stock_alert', // 股票动向
EVENT_ALERT: 'event_alert', // 事件动向
ANALYSIS_REPORT: 'analysis_report', // 分析报告
};
// 优先级
export const PRIORITY_LEVELS = {
URGENT: 'urgent', // 紧急
IMPORTANT: 'important', // 重要
NORMAL: 'normal', // 普通
};
// 通知状态(用于预测通知)
export const NOTIFICATION_STATUS = {
PREDICTION: 'prediction', // 预测状态(详情未就绪)
READY: 'ready', // 详情已就绪
};
// 通知系统配置
export const NOTIFICATION_CONFIG = {
// 显示策略
maxVisible: 3, // 最多显示3条通知
maxHistory: 15, // 历史保留15条折叠区
// 自动关闭时长(毫秒)- 按优先级区分
autoCloseDuration: {
[PRIORITY_LEVELS.URGENT]: 0, // 紧急:不自动关闭
[PRIORITY_LEVELS.IMPORTANT]: 30000, // 重要30秒
[PRIORITY_LEVELS.NORMAL]: 15000, // 普通15秒
},
// 推送频率配置(测试模式)
mockPush: {
interval: 60000, // 60秒推送一次
minBatch: 1, // 最少1条
maxBatch: 2, // 最多2条
},
// 折叠配置
collapse: {
threshold: 3, // 超过3条开始折叠
textTemplate: '还有 {count} 条通知', // 折叠提示文案
},
};
// 优先级标签配置
export const PRIORITY_CONFIGS = {
[PRIORITY_LEVELS.URGENT]: {
label: '紧急',
colorScheme: 'red',
show: true,
},
[PRIORITY_LEVELS.IMPORTANT]: {
label: '重要',
colorScheme: 'orange',
show: true,
},
[PRIORITY_LEVELS.NORMAL]: {
label: '',
colorScheme: 'gray',
show: false, // 普通优先级不显示标签
},
};
// 通知类型样式配置
export const NOTIFICATION_TYPE_CONFIGS = {
[NOTIFICATION_TYPES.ANNOUNCEMENT]: {
name: '公告通知',
icon: MdCampaign,
colorScheme: 'blue',
bg: 'blue.50',
borderColor: 'blue.400',
iconColor: 'blue.500',
hoverBg: 'blue.100',
},
[NOTIFICATION_TYPES.STOCK_ALERT]: {
name: '股票动向',
// 图标根据涨跌动态设置
getIcon: (priceChange) => {
if (!priceChange) return MdTrendingUp;
return priceChange.startsWith('+') ? MdTrendingUp : MdTrendingDown;
},
// 颜色根据涨跌动态设置
getColorScheme: (priceChange) => {
if (!priceChange) return 'red';
return priceChange.startsWith('+') ? 'red' : 'green';
},
getBg: (priceChange) => {
const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
return `${scheme}.50`;
},
getBorderColor: (priceChange) => {
const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
return `${scheme}.400`;
},
getIconColor: (priceChange) => {
const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
return `${scheme}.500`;
},
getHoverBg: (priceChange) => {
const scheme = NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.STOCK_ALERT].getColorScheme(priceChange);
return `${scheme}.100`;
},
},
[NOTIFICATION_TYPES.EVENT_ALERT]: {
name: '事件动向',
icon: MdArticle,
colorScheme: 'orange',
bg: 'orange.50',
borderColor: 'orange.400',
iconColor: 'orange.500',
hoverBg: 'orange.100',
},
[NOTIFICATION_TYPES.ANALYSIS_REPORT]: {
name: '分析报告',
icon: MdAssessment,
colorScheme: 'purple',
bg: 'purple.50',
borderColor: 'purple.400',
iconColor: 'purple.500',
hoverBg: 'purple.100',
},
};
// 时间格式化辅助函数
export const formatNotificationTime = (timestamp) => {
const date = new Date(timestamp);
const now = new Date();
const diff = now - date;
// 小于1分钟
if (diff < 60000) {
return '刚刚';
}
// 小于1小时
if (diff < 3600000) {
return `${Math.floor(diff / 60000)}分钟前`;
}
// 小于24小时
if (diff < 86400000) {
return `${Math.floor(diff / 3600000)}小时前`;
}
// 今天
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const dateDay = new Date(date.getFullYear(), date.getMonth(), date.getDate());
if (dateDay.getTime() === today.getTime()) {
return `今天 ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
}
// 昨天
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (dateDay.getTime() === yesterday.getTime()) {
return `昨天 ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
}
// 其他
return `${date.getMonth() + 1}-${date.getDate()} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
};
export default {
NOTIFICATION_TYPES,
PRIORITY_LEVELS,
NOTIFICATION_STATUS,
PRIORITY_CONFIGS,
NOTIFICATION_TYPE_CONFIGS,
formatNotificationTime,
};

View File

@@ -7,6 +7,8 @@ import React, { createContext, useContext, useState, useEffect, useCallback, use
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import socket, { SOCKET_TYPE } from '../services/socket'; import socket, { SOCKET_TYPE } from '../services/socket';
import notificationSound from '../assets/sounds/notification.wav'; import notificationSound from '../assets/sounds/notification.wav';
import { browserNotificationService } from '../services/browserNotificationService';
import { PRIORITY_LEVELS, NOTIFICATION_CONFIG } from '../constants/notificationTypes';
// 创建通知上下文 // 创建通知上下文
const NotificationContext = createContext(); const NotificationContext = createContext();
@@ -25,6 +27,7 @@ export const NotificationProvider = ({ children }) => {
const [notifications, setNotifications] = useState([]); const [notifications, setNotifications] = useState([]);
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [soundEnabled, setSoundEnabled] = useState(true); const [soundEnabled, setSoundEnabled] = useState(true);
const [browserPermission, setBrowserPermission] = useState(browserNotificationService.getPermissionStatus());
const audioRef = useRef(null); const audioRef = useRef(null);
// 初始化音频 // 初始化音频
@@ -57,54 +60,6 @@ export const NotificationProvider = ({ children }) => {
} }
}, [soundEnabled]); }, [soundEnabled]);
/**
* 添加通知到队列
* @param {object} notification - 通知对象
*/
const addNotification = useCallback((notification) => {
const newNotification = {
id: notification.id || `notif_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
type: notification.type || 'info',
severity: notification.severity || 'info',
title: notification.title || '通知',
message: notification.message || '',
timestamp: notification.timestamp || Date.now(),
autoClose: notification.autoClose !== undefined ? notification.autoClose : 8000,
...notification,
};
logger.info('NotificationContext', 'Adding notification', newNotification);
// 新消息插入到数组开头最多保留5条
setNotifications(prev => {
const updated = [newNotification, ...prev];
const maxNotifications = 5;
// 如果超过最大数量,移除最旧的(数组末尾)
if (updated.length > maxNotifications) {
const removed = updated.slice(maxNotifications);
removed.forEach(old => {
logger.info('NotificationContext', 'Auto-removing old notification', { id: old.id });
});
return updated.slice(0, maxNotifications);
}
return updated;
});
// 播放音效
playNotificationSound();
// 自动关闭
if (newNotification.autoClose && newNotification.autoClose > 0) {
setTimeout(() => {
removeNotification(newNotification.id);
}, newNotification.autoClose);
}
return newNotification.id;
}, [playNotificationSound]);
/** /**
* 移除通知 * 移除通知
* @param {string} id - 通知ID * @param {string} id - 通知ID
@@ -133,6 +88,144 @@ export const NotificationProvider = ({ children }) => {
}); });
}, []); }, []);
/**
* 请求浏览器通知权限
*/
const requestBrowserPermission = useCallback(async () => {
logger.info('NotificationContext', 'Requesting browser notification permission');
const permission = await browserNotificationService.requestPermission();
setBrowserPermission(permission);
return permission;
}, []);
/**
* 发送浏览器通知
*/
const sendBrowserNotification = useCallback((notificationData) => {
if (browserPermission !== 'granted') {
logger.warn('NotificationContext', 'Browser permission not granted');
return;
}
const { priority, title, content, link, type } = notificationData;
// 生成唯一 tag
const tag = `${type}_${Date.now()}`;
// 判断是否需要用户交互(紧急通知不自动关闭)
const requireInteraction = priority === PRIORITY_LEVELS.URGENT;
// 发送浏览器通知
const notification = browserNotificationService.sendNotification({
title: title || '新通知',
body: content || '',
tag,
requireInteraction,
data: { link },
autoClose: requireInteraction ? 0 : 8000,
});
// 设置点击处理(聚焦窗口并跳转)
if (notification && link) {
notification.onclick = () => {
window.focus();
// 使用 window.location 跳转(不需要 React Router
window.location.hash = link;
notification.close();
};
}
logger.info('NotificationContext', 'Browser notification sent', { title, tag });
}, [browserPermission]);
/**
* 添加网页通知(内部方法)
*/
const addWebNotification = useCallback((newNotification) => {
// 新消息插入到数组开头,最多保留 maxHistory 条
setNotifications(prev => {
const updated = [newNotification, ...prev];
const maxNotifications = NOTIFICATION_CONFIG.maxHistory;
// 如果超过最大数量,移除最旧的(数组末尾)
if (updated.length > maxNotifications) {
const removed = updated.slice(maxNotifications);
removed.forEach(old => {
logger.info('NotificationContext', 'Auto-removing old notification', { id: old.id });
});
return updated.slice(0, maxNotifications);
}
return updated;
});
// 播放音效
playNotificationSound();
// 自动关闭
if (newNotification.autoClose && newNotification.autoClose > 0) {
setTimeout(() => {
removeNotification(newNotification.id);
}, newNotification.autoClose);
}
}, [playNotificationSound, removeNotification]);
/**
* 添加通知到队列
* @param {object} notification - 通知对象
*/
const addNotification = useCallback((notification) => {
// 根据优先级获取自动关闭时长
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)}`,
type: notification.type || 'info',
severity: notification.severity || 'info',
title: notification.title || '通知',
message: notification.message || '',
timestamp: notification.timestamp || Date.now(),
priority: priority,
autoClose: notification.autoClose !== undefined ? notification.autoClose : defaultAutoClose,
...notification,
};
logger.info('NotificationContext', 'Adding notification', newNotification);
const isPageHidden = document.hidden; // 页面是否在后台
// ========== 智能分发策略 ==========
// 策略 1: 紧急通知 - 双重保障(浏览器 + 网页)
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);
}
}
// 策略 3: 普通通知 - 仅网页通知
else {
logger.info('NotificationContext', 'Normal notification: sending web only');
addWebNotification(newNotification);
}
return newNotification.id;
}, [sendBrowserNotification, addWebNotification]);
// 连接到 Socket 服务 // 连接到 Socket 服务
useEffect(() => { useEffect(() => {
logger.info('NotificationContext', 'Initializing socket connection...'); logger.info('NotificationContext', 'Initializing socket connection...');
@@ -147,9 +240,10 @@ export const NotificationProvider = ({ children }) => {
// 如果使用 mock可以启动定期推送 // 如果使用 mock可以启动定期推送
if (SOCKET_TYPE === 'MOCK') { if (SOCKET_TYPE === 'MOCK') {
// 启动模拟推送:每20秒推送1-2条消息 // 启动模拟推送:使用配置的间隔和数量
socket.startMockPush(20000, 2); const { interval, maxBatch } = NOTIFICATION_CONFIG.mockPush;
logger.info('NotificationContext', 'Mock push started'); socket.startMockPush(interval, maxBatch);
logger.info('NotificationContext', 'Mock push started', { interval, maxBatch });
} }
}); });
@@ -191,10 +285,12 @@ export const NotificationProvider = ({ children }) => {
notifications, notifications,
isConnected, isConnected,
soundEnabled, soundEnabled,
browserPermission,
addNotification, addNotification,
removeNotification, removeNotification,
clearAllNotifications, clearAllNotifications,
toggleSound, toggleSound,
requestBrowserPermission,
}; };
return ( return (

View File

@@ -0,0 +1,208 @@
// src/services/browserNotificationService.js
/**
* 浏览器原生通知服务
* 提供系统级通知功能Web Notifications API
*/
import { logger } from '../utils/logger';
class BrowserNotificationService {
constructor() {
this.permission = this.isSupported() ? Notification.permission : 'denied';
this.activeNotifications = new Map(); // 存储活跃的通知
}
/**
* 检查浏览器是否支持通知 API
*/
isSupported() {
return 'Notification' in window;
}
/**
* 获取当前权限状态
* @returns {string} 'granted' | 'denied' | 'default'
*/
getPermissionStatus() {
if (!this.isSupported()) {
return 'denied';
}
return Notification.permission;
}
/**
* 请求通知权限
* @returns {Promise<string>} 权限状态
*/
async requestPermission() {
if (!this.isSupported()) {
logger.warn('browserNotificationService', 'Notifications not supported');
return 'denied';
}
if (this.permission === 'granted') {
logger.info('browserNotificationService', 'Permission already granted');
return 'granted';
}
try {
const permission = await Notification.requestPermission();
this.permission = permission;
logger.info('browserNotificationService', `Permission ${permission}`);
return permission;
} catch (error) {
logger.error('browserNotificationService', 'requestPermission', error);
return 'denied';
}
}
/**
* 发送浏览器通知
* @param {Object} options 通知选项
* @param {string} options.title 标题
* @param {string} options.body 内容
* @param {string} options.icon 图标路径
* @param {string} options.tag 标签(防止重复)
* @param {boolean} options.requireInteraction 是否需要用户交互才关闭
* @param {Object} options.data 自定义数据(如跳转链接)
* @param {number} options.autoClose 自动关闭时间(毫秒)
* @returns {Notification|null} 通知对象
*/
sendNotification({
title,
body,
icon = '/logo192.png',
tag,
requireInteraction = false,
data = {},
autoClose = 0,
}) {
if (!this.isSupported()) {
logger.warn('browserNotificationService', 'Notifications not supported');
return null;
}
if (this.permission !== 'granted') {
logger.warn('browserNotificationService', 'Permission not granted');
return null;
}
try {
// 关闭相同 tag 的旧通知
if (tag && this.activeNotifications.has(tag)) {
const oldNotification = this.activeNotifications.get(tag);
oldNotification.close();
}
// 创建通知
const notification = new Notification(title, {
body,
icon,
badge: '/badge.png',
tag: tag || `notification_${Date.now()}`,
requireInteraction,
data,
silent: false, // 允许声音
});
// 存储通知引用
if (tag) {
this.activeNotifications.set(tag, notification);
}
// 自动关闭
if (autoClose > 0 && !requireInteraction) {
setTimeout(() => {
notification.close();
}, autoClose);
}
// 通知关闭时清理引用
notification.onclose = () => {
if (tag) {
this.activeNotifications.delete(tag);
}
};
logger.info('browserNotificationService', 'Notification sent', { title, tag });
return notification;
} catch (error) {
logger.error('browserNotificationService', 'sendNotification', error);
return null;
}
}
/**
* 设置通知点击处理
* @param {Notification} notification 通知对象
* @param {Function} navigate React Router navigate 函数
*/
setupClickHandler(notification, navigate) {
if (!notification) return;
notification.onclick = (event) => {
event.preventDefault();
// 聚焦窗口
window.focus();
// 跳转链接
if (notification.data?.link) {
navigate(notification.data.link);
}
// 关闭通知
notification.close();
logger.info('browserNotificationService', 'Notification clicked', notification.data);
};
}
/**
* 关闭所有活跃通知
*/
closeAll() {
this.activeNotifications.forEach(notification => {
notification.close();
});
this.activeNotifications.clear();
logger.info('browserNotificationService', 'All notifications closed');
}
/**
* 根据通知数据发送浏览器通知
* @param {Object} notificationData 通知数据
* @param {Function} navigate React Router navigate 函数
*/
sendFromNotificationData(notificationData, navigate) {
const { type, priority, title, content, link, extra } = notificationData;
// 生成唯一 tag
const tag = `${type}_${Date.now()}`;
// 判断是否需要用户交互(紧急通知不自动关闭)
const requireInteraction = priority === 'urgent';
// 发送通知
const notification = this.sendNotification({
title: title || '新通知',
body: content || '',
tag,
requireInteraction,
data: { link, ...extra },
autoClose: requireInteraction ? 0 : 8000, // 紧急通知不自动关闭
});
// 设置点击处理
if (notification && navigate) {
this.setupClickHandler(notification, navigate);
}
return notification;
}
}
// 导出单例
export const browserNotificationService = new BrowserNotificationService();
export default browserNotificationService;

View File

@@ -1,61 +1,303 @@
// src/services/mockSocketService.js // src/services/mockSocketService.js
/** /**
* Mock Socket 服务 - 用于开发环境模拟实时推送 * Mock Socket 服务 - 用于开发环境模拟实时推送
* 模拟交易提醒、系统通知等实时消息推送 * 模拟金融资讯、事件动向、分析报告等实时消息推送
*/ */
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { NOTIFICATION_TYPES, PRIORITY_LEVELS } from '../constants/notificationTypes';
// 模拟交易提醒数据 // 模拟金融资讯数据
const mockTradeAlerts = [ const mockFinancialNews = [
// ========== 公告通知 ==========
{ {
type: 'trade_alert', type: NOTIFICATION_TYPES.ANNOUNCEMENT,
severity: 'success', priority: PRIORITY_LEVELS.IMPORTANT,
title: '买入成功', title: '贵州茅台发布2024年度财报公告',
message: '您的订单已成功执行:买入 贵州茅台(600519) 100股成交价 ¥1,850.00', content: '2024年度营收同比增长15.2%净利润创历史新高董事会建议每10股派息180元',
timestamp: Date.now(), publishTime: new Date('2024-03-28T15:30:00').getTime(),
autoClose: 8000, pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/event-detail/ann001',
extra: {
announcementType: '财报',
companyCode: '600519',
companyName: '贵州茅台',
}, },
{
type: 'trade_alert',
severity: 'warning',
title: '价格预警',
message: '您关注的股票 比亚迪(002594) 当前价格 ¥245.50,已触达预设价格',
timestamp: Date.now(),
autoClose: 10000, autoClose: 10000,
}, },
{ {
type: 'trade_alert', type: NOTIFICATION_TYPES.ANNOUNCEMENT,
severity: 'info', priority: PRIORITY_LEVELS.URGENT,
title: '持仓提醒', title: '宁德时代发布重大资产重组公告',
message: '您持有的 宁德时代(300750) 今日涨幅达 5.2%,当前盈利 +¥12,350', content: '公司拟收购某新能源材料公司100%股权交易金额约120亿元预计增厚业绩20%',
timestamp: Date.now(), publishTime: new Date('2024-03-28T09:00:00').getTime(),
autoClose: 8000, pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/event-detail/ann002',
extra: {
announcementType: '重组',
companyCode: '300750',
companyName: '宁德时代',
}, },
{
type: 'trade_alert',
severity: 'error',
title: '委托失败',
message: '卖出订单失败:五粮液(000858) 当前处于停牌状态,无法交易',
timestamp: Date.now(),
autoClose: 12000, autoClose: 12000,
}, },
{ {
type: 'system_notification', type: NOTIFICATION_TYPES.ANNOUNCEMENT,
severity: 'info', priority: PRIORITY_LEVELS.NORMAL,
title: '系统公告', title: '中国平安发布分红派息公告',
message: '市场将于15:00收盘请注意及时调整持仓', content: '2023年度利润分配方案每10股派发现金红利23.0元含税分红率达30.5%',
timestamp: Date.now(), publishTime: new Date('2024-03-27T16:00:00').getTime(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/event-detail/ann003',
extra: {
announcementType: '分红',
companyCode: '601318',
companyName: '中国平安',
},
autoClose: 10000,
},
// ========== 股票动向 ==========
{
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',
triggerType: '目标价',
},
autoClose: 10000, autoClose: 10000,
}, },
{ {
type: 'trade_alert', type: NOTIFICATION_TYPES.STOCK_ALERT,
severity: 'success', priority: PRIORITY_LEVELS.IMPORTANT,
title: '分红到账', title: '您关注的股票异常波动',
message: '您持有的 中国平安(601318) 分红已到账,金额 ¥560.00', content: '比亚迪(002594) 5分钟内跌幅达 -3.8%,当前价格 ¥198.20,建议关注',
timestamp: Date.now(), 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',
triggerType: '异常波动',
},
autoClose: 10000,
},
{
type: NOTIFICATION_TYPES.STOCK_ALERT,
priority: PRIORITY_LEVELS.NORMAL,
title: '持仓股票表现',
content: '隆基绿能(601012) 今日表现优异,涨幅 +4.5%,您当前持仓浮盈 +¥8,200',
publishTime: Date.now(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/trading-simulation',
extra: {
stockCode: '601012',
stockName: '隆基绿能',
priceChange: '+4.5%',
profit: '+8200',
},
autoClose: 8000, autoClose: 8000,
}, },
// ========== 事件动向 ==========
{
type: NOTIFICATION_TYPES.EVENT_ALERT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: '央行宣布降准0.5个百分点',
content: '中国人民银行宣布下调金融机构存款准备金率0.5个百分点释放长期资金约1万亿元利好股市',
publishTime: new Date('2024-03-28T09:00:00').getTime(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/event-detail/evt001',
extra: {
eventId: 'evt001',
relatedStocks: 12,
impactLevel: '重大利好',
sectors: ['银行', '地产', '基建'],
},
autoClose: 12000,
},
{
type: NOTIFICATION_TYPES.EVENT_ALERT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: '新能源汽车补贴政策延期',
content: '财政部宣布新能源汽车购置补贴政策延长至2024年底涉及比亚迪、理想汽车等5家龙头企业',
publishTime: new Date('2024-03-28T10:30:00').getTime(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/event-detail/evt002',
extra: {
eventId: 'evt002',
relatedStocks: 5,
impactLevel: '重大利好',
sectors: ['新能源汽车'],
},
autoClose: 12000,
},
{
type: NOTIFICATION_TYPES.EVENT_ALERT,
priority: PRIORITY_LEVELS.NORMAL,
title: '芯片产业扶持政策出台',
content: '工信部发布《半导体产业发展指导意见》未来三年投入500亿专项资金支持芯片研发',
publishTime: new Date('2024-03-27T14:00:00').getTime(),
pushTime: Date.now(),
isAIGenerated: false,
clickable: true,
link: '/event-detail/evt003',
extra: {
eventId: 'evt003',
relatedStocks: 8,
impactLevel: '中长期利好',
sectors: ['半导体', '芯片设计'],
},
autoClose: 10000,
},
// ========== 预测通知 ==========
{
type: NOTIFICATION_TYPES.EVENT_ALERT,
priority: PRIORITY_LEVELS.NORMAL,
title: '【预测】央行可能宣布降准政策',
content: '基于最新宏观数据分析预计央行将在本周宣布降准0.5个百分点,释放长期资金',
publishTime: Date.now(),
pushTime: Date.now(),
isAIGenerated: true,
clickable: false, // ❌ 不可点击
link: null,
extra: {
isPrediction: true,
statusHint: '详细报告生成中...',
relatedPredictionId: 'pred_001',
},
autoClose: 15000,
},
{
type: NOTIFICATION_TYPES.EVENT_ALERT,
priority: PRIORITY_LEVELS.NORMAL,
title: '【预测】新能源补贴政策或将延期',
content: '根据政策趋势分析财政部可能宣布新能源汽车购置补贴政策延长至2025年底',
publishTime: Date.now(),
pushTime: Date.now(),
isAIGenerated: true,
clickable: false, // ❌ 不可点击
link: null,
extra: {
isPrediction: true,
statusHint: '详细报告生成中...',
relatedPredictionId: 'pred_002',
},
autoClose: 15000,
},
// ========== 分析报告 ==========
{
type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: '医药行业深度报告:创新药迎来政策拐点',
content: 'CXO板块持续受益于全球创新药研发外包需求建议关注药明康德、凯莱英等龙头企业',
publishTime: new Date('2024-03-28T08:00:00').getTime(),
pushTime: Date.now(),
author: {
name: '李明',
organization: '中信证券',
},
isAIGenerated: false,
clickable: true,
link: '/forecast-report?id=rpt001',
extra: {
reportType: '行业研报',
industry: '医药',
rating: '强烈推荐',
},
autoClose: 12000,
},
{
type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: 'AI产业链投资机会分析',
content: '随着大模型应用加速落地,算力、数据、应用三大方向均存在投资机会,重点关注海光信息、寒武纪',
publishTime: new Date('2024-03-28T07:30:00').getTime(),
pushTime: Date.now(),
author: {
name: '王芳',
organization: '招商证券',
},
isAIGenerated: true,
clickable: true,
link: '/forecast-report?id=rpt002',
extra: {
reportType: '策略报告',
industry: '人工智能',
rating: '推荐',
},
autoClose: 12000,
},
{
type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
priority: PRIORITY_LEVELS.NORMAL,
title: '比亚迪:新能源汽车龙头业绩持续超预期',
content: '2024年销量目标400万辆海外市场拓展顺利维持"买入"评级目标价280元',
publishTime: new Date('2024-03-27T09:00:00').getTime(),
pushTime: Date.now(),
author: {
name: '张伟',
organization: '国泰君安',
},
isAIGenerated: false,
clickable: true,
link: '/forecast-report?id=rpt003',
extra: {
reportType: '公司研报',
industry: '新能源汽车',
rating: '买入',
targetPrice: '280',
},
autoClose: 10000,
},
{
type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
priority: PRIORITY_LEVELS.NORMAL,
title: '2024年A股市场展望结构性行情延续',
content: 'AI应用、高端制造、自主可控三大主线贯穿全年建议关注科技成长板块配置机会',
publishTime: new Date('2024-03-26T16:00:00').getTime(),
pushTime: Date.now(),
author: {
name: 'AI分析师',
organization: '价值前沿',
},
isAIGenerated: true,
clickable: true,
link: '/forecast-report?id=rpt004',
extra: {
reportType: '策略报告',
industry: '市场策略',
rating: '谨慎乐观',
},
autoClose: 10000,
},
]; ];
class MockSocketService { class MockSocketService {
@@ -194,9 +436,9 @@ class MockSocketService {
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
// 从模拟数据中随机选择一条 // 从模拟数据中随机选择一条
const randomIndex = Math.floor(Math.random() * mockTradeAlerts.length); const randomIndex = Math.floor(Math.random() * mockFinancialNews.length);
const alert = { const alert = {
...mockTradeAlerts[randomIndex], ...mockFinancialNews[randomIndex],
timestamp: Date.now(), timestamp: Date.now(),
id: `mock_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, id: `mock_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
}; };