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

View File

@@ -1,9 +1,10 @@
// src/components/NotificationContainer/index.js
/**
* 通知容器组件 - 右下角层叠显示实时通知
* 金融资讯通知容器组件 - 右下角层叠显示实时通知
*/
import React from 'react';
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
VStack,
@@ -11,56 +12,69 @@ import {
Text,
IconButton,
Icon,
Badge,
Button,
useColorModeValue,
Slide,
ScaleFade,
} 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';
// 通知类型对应的图标和颜色
const NOTIFICATION_STYLES = {
success: {
icon: MdCheckCircle,
colorScheme: 'green',
bg: 'green.50',
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',
},
};
import {
NOTIFICATION_TYPE_CONFIGS,
NOTIFICATION_TYPES,
PRIORITY_CONFIGS,
NOTIFICATION_CONFIG,
formatNotificationTime,
} from '../../constants/notificationTypes';
/**
* 单个通知项组件
*/
const NotificationItem = ({ notification, onClose, isNewest = false }) => {
const { id, severity = 'info', title, message } = notification;
const style = NOTIFICATION_STYLES[severity] || NOTIFICATION_STYLES.info;
const navigate = useNavigate();
const { id, type, priority, title, content, isAIGenerated, clickable, link, author, publishTime, pushTime, extra } = notification;
const bgColor = useColorModeValue(style.bg, `${style.colorScheme}.900`);
const borderColor = useColorModeValue(style.borderColor, `${style.colorScheme}.500`);
// 严格判断可点击性:只有 clickable=true 且 link 存在才可点击
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 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 (
<ScaleFade initialScale={0.9} in={true}>
@@ -69,72 +83,160 @@ const NotificationItem = ({ notification, onClose, isNewest = false }) => {
borderLeft="4px solid"
borderColor={borderColor}
borderRadius="md"
boxShadow={isNewest ? '2xl' : 'lg'} // 最新消息更强的阴影
boxShadow={isNewest ? '2xl' : 'lg'}
p={4}
minW="350px"
maxW="450px"
w="400px" // 统一宽度
position="relative"
_hover={{
cursor={isActuallyClickable ? 'pointer' : 'default'} // 严格判断
onClick={isActuallyClickable ? handleClick : undefined} // 严格判断
_hover={isActuallyClickable ? {
boxShadow: 'xl',
transform: 'translateX(-4px)',
}}
transform: 'translateY(-2px)',
bg: hoverBg,
} : {}} // 不可点击时无 hover 效果
transition="all 0.2s"
// 最新消息添加微妙的高亮边框
{...(isNewest && {
borderRight: '1px solid',
borderRightColor: borderColor,
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
as={style.icon}
w={6}
h={6}
color={style.iconColor}
as={typeConfig.icon}
w={5}
h={5}
color={typeConfig.iconColor}
mt={0.5}
flexShrink={0}
/>
{/* 内容 */}
<VStack align="start" spacing={1} flex={1} mr={6}>
<Text
fontSize="md"
fontWeight="bold"
color={textColor}
lineHeight="short"
{/* 标题 */}
<Text
fontSize="sm"
fontWeight="bold"
color={textColor}
lineHeight="short"
flex={1}
noOfLines={2}
>
{title}
</Text>
{/* 优先级标签 */}
{priorityConfig.show && (
<Badge
colorScheme={priorityConfig.colorScheme}
size="sm"
flexShrink={0}
>
{title}
</Text>
{message && (
<Text
fontSize="sm"
color={subTextColor}
lineHeight="short"
>
{message}
</Text>
)}
</VStack>
{priorityConfig.label}
</Badge>
)}
{/* 预测标识 */}
{isPrediction && (
<Badge
colorScheme="gray"
size="sm"
flexShrink={0}
>
预测
</Badge>
)}
{/* AI 生成标识 */}
{isAIGenerated && (
<Badge
colorScheme="purple"
size="sm"
flexShrink={0}
>
AI
</Badge>
)}
{/* 关闭按钮 */}
<IconButton
icon={<MdClose />}
size="sm"
size="xs"
variant="ghost"
colorScheme={style.colorScheme}
colorScheme={typeConfig.colorScheme}
aria-label="关闭通知"
onClick={() => onClose(id)}
position="absolute"
top={2}
right={2}
onClick={(e) => {
e.stopPropagation();
onClose(id);
}}
flexShrink={0}
_hover={{
bg: useColorModeValue(`${style.colorScheme}.100`, `${style.colorScheme}.800`),
bg: closeButtonHoverBg,
}}
/>
</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>
</ScaleFade>
);
@@ -145,12 +247,24 @@ const NotificationItem = ({ notification, onClose, isNewest = false }) => {
*/
const NotificationContainer = () => {
const { notifications, removeNotification } = useNotification();
const [isExpanded, setIsExpanded] = useState(false);
// 如果没有通知,不渲染
if (notifications.length === 0) {
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 (
<Box
position="fixed"
@@ -164,10 +278,10 @@ const NotificationContainer = () => {
align="flex-end"
pointerEvents="auto"
>
{notifications.map((notification, index) => (
{visibleNotifications.map((notification, index) => (
<Slide
key={notification.id}
direction="right"
direction="bottom"
in={true}
style={{
position: 'relative',
@@ -181,6 +295,28 @@ const NotificationContainer = () => {
/>
</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>
</Box>
);