feat: sockt 弹窗功能添加
This commit is contained in:
132
src/components/ConnectionStatusBar/index.js
Normal file
132
src/components/ConnectionStatusBar/index.js
Normal file
@@ -0,0 +1,132 @@
|
||||
// src/components/ConnectionStatusBar/index.js
|
||||
/**
|
||||
* Socket 连接状态栏组件
|
||||
* 显示 Socket 连接状态并提供重试功能
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Alert,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
Button,
|
||||
CloseButton,
|
||||
Box,
|
||||
HStack,
|
||||
useColorModeValue,
|
||||
Slide,
|
||||
} from '@chakra-ui/react';
|
||||
import { MdRefresh } from 'react-icons/md';
|
||||
|
||||
/**
|
||||
* 连接状态枚举
|
||||
*/
|
||||
export const CONNECTION_STATUS = {
|
||||
CONNECTED: 'connected', // 已连接
|
||||
DISCONNECTED: 'disconnected', // 已断开
|
||||
RECONNECTING: 'reconnecting', // 重连中
|
||||
FAILED: 'failed', // 连接失败
|
||||
};
|
||||
|
||||
/**
|
||||
* 连接状态栏组件
|
||||
*/
|
||||
const ConnectionStatusBar = ({
|
||||
status = CONNECTION_STATUS.CONNECTED,
|
||||
reconnectAttempt = 0,
|
||||
maxReconnectAttempts = 5,
|
||||
onRetry,
|
||||
onClose,
|
||||
}) => {
|
||||
// 仅在非正常状态时显示
|
||||
const shouldShow = status !== CONNECTION_STATUS.CONNECTED;
|
||||
|
||||
// 状态配置
|
||||
const statusConfig = {
|
||||
[CONNECTION_STATUS.DISCONNECTED]: {
|
||||
status: 'warning',
|
||||
title: '连接已断开',
|
||||
description: '正在尝试重新连接...',
|
||||
},
|
||||
[CONNECTION_STATUS.RECONNECTING]: {
|
||||
status: 'warning',
|
||||
title: '正在重新连接',
|
||||
description: `尝试重连中 (第 ${reconnectAttempt}/${maxReconnectAttempts} 次)`,
|
||||
},
|
||||
[CONNECTION_STATUS.FAILED]: {
|
||||
status: 'error',
|
||||
title: '连接失败',
|
||||
description: '无法连接到服务器,请检查网络连接',
|
||||
},
|
||||
};
|
||||
|
||||
const config = statusConfig[status] || statusConfig[CONNECTION_STATUS.DISCONNECTED];
|
||||
|
||||
// 颜色配置
|
||||
const bg = useColorModeValue(
|
||||
{
|
||||
warning: 'orange.50',
|
||||
error: 'red.50',
|
||||
}[config.status],
|
||||
{
|
||||
warning: 'orange.900',
|
||||
error: 'red.900',
|
||||
}[config.status]
|
||||
);
|
||||
|
||||
return (
|
||||
<Slide
|
||||
direction="top"
|
||||
in={shouldShow}
|
||||
style={{ zIndex: 10000 }}
|
||||
>
|
||||
<Alert
|
||||
status={config.status}
|
||||
variant="subtle"
|
||||
bg={bg}
|
||||
borderBottom="1px solid"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
py={3}
|
||||
px={{ base: 4, md: 6 }}
|
||||
>
|
||||
<AlertIcon />
|
||||
<Box flex="1">
|
||||
<HStack spacing={2} align="center" flexWrap="wrap">
|
||||
<AlertTitle fontSize="sm" fontWeight="bold" mb={0}>
|
||||
{config.title}
|
||||
</AlertTitle>
|
||||
<AlertDescription fontSize="sm" mb={0}>
|
||||
{config.description}
|
||||
</AlertDescription>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* 重试按钮(仅失败状态显示) */}
|
||||
{status === CONNECTION_STATUS.FAILED && onRetry && (
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="red"
|
||||
leftIcon={<MdRefresh />}
|
||||
onClick={onRetry}
|
||||
mr={2}
|
||||
flexShrink={0}
|
||||
>
|
||||
立即重试
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 关闭按钮(仅失败状态显示) */}
|
||||
{status === CONNECTION_STATUS.FAILED && onClose && (
|
||||
<CloseButton
|
||||
onClick={onClose}
|
||||
size="sm"
|
||||
flexShrink={0}
|
||||
/>
|
||||
)}
|
||||
</Alert>
|
||||
</Slide>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionStatusBar;
|
||||
@@ -3,7 +3,7 @@
|
||||
* 金融资讯通知容器组件 - 右下角层叠显示实时通知
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
@@ -14,25 +14,249 @@ import {
|
||||
Icon,
|
||||
Badge,
|
||||
Button,
|
||||
Spinner,
|
||||
useColorModeValue,
|
||||
Slide,
|
||||
ScaleFade,
|
||||
} from '@chakra-ui/react';
|
||||
import { MdClose, MdOpenInNew, MdSchedule, MdExpandMore, MdExpandLess } from 'react-icons/md';
|
||||
import { keyframes } from '@emotion/react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { MdClose, MdOpenInNew, MdSchedule, MdExpandMore, MdExpandLess, MdPerson, MdAccessTime } from 'react-icons/md';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import {
|
||||
NOTIFICATION_TYPE_CONFIGS,
|
||||
NOTIFICATION_TYPES,
|
||||
PRIORITY_CONFIGS,
|
||||
PRIORITY_LEVELS,
|
||||
NOTIFICATION_CONFIG,
|
||||
formatNotificationTime,
|
||||
getPriorityBgOpacity,
|
||||
getPriorityBorderWidth,
|
||||
} from '../../constants/notificationTypes';
|
||||
|
||||
/**
|
||||
* 单个通知项组件
|
||||
* 自定义 Hook:带过期时间的 localStorage 持久化状态
|
||||
* @param {string} key - localStorage 的 key
|
||||
* @param {*} initialValue - 初始值
|
||||
* @param {number} expiryMs - 过期时间(毫秒),0 表示不过期
|
||||
* @returns {[*, Function]} - [状态值, 设置函数]
|
||||
*/
|
||||
const NotificationItem = ({ notification, onClose, isNewest = false }) => {
|
||||
const useLocalStorageWithExpiry = (key, initialValue, expiryMs = 0) => {
|
||||
// 从 localStorage 读取带过期时间的值
|
||||
const readValue = () => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
if (!item) {
|
||||
return initialValue;
|
||||
}
|
||||
|
||||
const { value, timestamp } = JSON.parse(item);
|
||||
|
||||
// 检查是否过期(仅当设置了过期时间时)
|
||||
if (expiryMs > 0 && timestamp) {
|
||||
const now = Date.now();
|
||||
const elapsed = now - timestamp;
|
||||
|
||||
if (elapsed > expiryMs) {
|
||||
// 已过期,删除并返回初始值
|
||||
window.localStorage.removeItem(key);
|
||||
return initialValue;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
} catch (error) {
|
||||
console.error(`Error reading ${key} from localStorage:`, error);
|
||||
return initialValue;
|
||||
}
|
||||
};
|
||||
|
||||
const [storedValue, setStoredValue] = useState(readValue);
|
||||
|
||||
// 保存值到 localStorage(带时间戳)
|
||||
const setValue = (value) => {
|
||||
try {
|
||||
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
||||
setStoredValue(valueToStore);
|
||||
|
||||
const item = {
|
||||
value: valueToStore,
|
||||
timestamp: Date.now(), // 保存时间戳
|
||||
};
|
||||
|
||||
window.localStorage.setItem(key, JSON.stringify(item));
|
||||
} catch (error) {
|
||||
console.error(`Error saving ${key} to localStorage:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听 storage 事件(跨标签页同步)
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (e) => {
|
||||
if (e.key === key && e.newValue !== null) {
|
||||
try {
|
||||
const { value, timestamp } = JSON.parse(e.newValue);
|
||||
|
||||
// 检查是否过期
|
||||
if (expiryMs > 0 && timestamp) {
|
||||
const now = Date.now();
|
||||
const elapsed = now - timestamp;
|
||||
|
||||
if (elapsed > expiryMs) {
|
||||
// 过期,设置为初始值
|
||||
setStoredValue(initialValue);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
setStoredValue(value);
|
||||
} catch (error) {
|
||||
console.error(`Error parsing storage event for ${key}:`, error);
|
||||
}
|
||||
} else if (e.key === key && e.newValue === null) {
|
||||
// 其他标签页删除了该值
|
||||
setStoredValue(initialValue);
|
||||
}
|
||||
};
|
||||
|
||||
// 添加事件监听
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
};
|
||||
}, [key, expiryMs, initialValue]);
|
||||
|
||||
// 定时检查过期(可选,更精确的过期控制)
|
||||
useEffect(() => {
|
||||
if (expiryMs <= 0) return; // 不需要过期检查
|
||||
|
||||
const checkExpiry = () => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
if (!item) return;
|
||||
|
||||
const { value, timestamp } = JSON.parse(item);
|
||||
const now = Date.now();
|
||||
const elapsed = now - timestamp;
|
||||
|
||||
if (elapsed > expiryMs) {
|
||||
// 已过期,重置状态
|
||||
setStoredValue(initialValue);
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error checking expiry for ${key}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
// 每10秒检查一次过期
|
||||
const intervalId = setInterval(checkExpiry, 10000);
|
||||
|
||||
// 立即执行一次检查
|
||||
checkExpiry();
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [key, expiryMs, initialValue]);
|
||||
|
||||
return [storedValue, setValue];
|
||||
};
|
||||
|
||||
/**
|
||||
* 辅助函数:生成通知的完整无障碍描述
|
||||
* @param {object} notification - 通知对象
|
||||
* @returns {string} - ARIA 描述文本
|
||||
*/
|
||||
const getNotificationDescription = (notification) => {
|
||||
const { type, priority, title, content, isAIGenerated, publishTime, pushTime, extra } = notification;
|
||||
|
||||
// 获取配置
|
||||
const typeConfig = NOTIFICATION_TYPE_CONFIGS[type] || NOTIFICATION_TYPE_CONFIGS[NOTIFICATION_TYPES.EVENT_ALERT];
|
||||
const priorityConfig = PRIORITY_CONFIGS[priority];
|
||||
|
||||
// 构建描述片段
|
||||
const parts = [];
|
||||
|
||||
// 优先级(如果需要显示)
|
||||
if (priorityConfig?.show) {
|
||||
parts.push(`${priorityConfig.label}通知`);
|
||||
}
|
||||
|
||||
// 类型
|
||||
parts.push(typeConfig.name);
|
||||
|
||||
// 标题
|
||||
parts.push(title);
|
||||
|
||||
// 内容
|
||||
if (content) {
|
||||
parts.push(content);
|
||||
}
|
||||
|
||||
// AI 生成标识
|
||||
if (isAIGenerated) {
|
||||
parts.push('由AI生成');
|
||||
}
|
||||
|
||||
// 预测标识
|
||||
if (extra?.isPrediction) {
|
||||
parts.push('预测状态');
|
||||
if (extra?.statusHint) {
|
||||
parts.push(extra.statusHint);
|
||||
}
|
||||
}
|
||||
|
||||
// 时间信息
|
||||
const time = publishTime || pushTime;
|
||||
if (time) {
|
||||
parts.push(`时间:${formatNotificationTime(time)}`);
|
||||
}
|
||||
|
||||
// 操作提示
|
||||
if (notification.clickable && notification.link) {
|
||||
parts.push('按回车键或空格键查看详情');
|
||||
}
|
||||
|
||||
return parts.join(',');
|
||||
};
|
||||
|
||||
/**
|
||||
* 辅助函数:处理键盘按键事件(Enter / Space)
|
||||
* @param {KeyboardEvent} event - 键盘事件
|
||||
* @param {Function} callback - 回调函数
|
||||
*/
|
||||
const handleKeyPress = (event, callback) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 紧急通知脉冲动画 - 边框颜色脉冲效果
|
||||
*/
|
||||
const pulseAnimation = keyframes`
|
||||
0%, 100% {
|
||||
border-left-color: currentColor;
|
||||
box-shadow: 0 0 0 0 currentColor;
|
||||
}
|
||||
50% {
|
||||
border-left-color: currentColor;
|
||||
box-shadow: -4px 0 12px 0 currentColor;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* 单个通知项组件
|
||||
* 使用 React.memo 优化,避免不必要的重渲染
|
||||
*/
|
||||
const NotificationItem = React.memo(({ notification, onClose, isNewest = false }) => {
|
||||
const navigate = useNavigate();
|
||||
const { trackNotificationClick } = useNotification();
|
||||
|
||||
// 加载状态管理 - 点击跳转时显示 loading
|
||||
const [isNavigating, setIsNavigating] = useState(false);
|
||||
|
||||
const { id, type, priority, title, content, isAIGenerated, clickable, link, author, publishTime, pushTime, extra } = notification;
|
||||
|
||||
// 严格判断可点击性:只有 clickable=true 且 link 存在才可点击
|
||||
@@ -51,77 +275,177 @@ const NotificationItem = ({ notification, onClose, isNewest = false }) => {
|
||||
...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),
|
||||
// 暗色模式
|
||||
darkBg: typeConfig.getDarkBg(priceChange),
|
||||
darkBorderColor: typeConfig.getDarkBorderColor(priceChange),
|
||||
darkIconColor: typeConfig.getDarkIconColor(priceChange),
|
||||
darkHoverBg: typeConfig.getDarkHoverBg(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 shouldShowIcon = priority === PRIORITY_LEVELS.URGENT || priority === PRIORITY_LEVELS.IMPORTANT;
|
||||
|
||||
// 点击处理(只有真正可点击时才执行)
|
||||
const handleClick = () => {
|
||||
if (isActuallyClickable) {
|
||||
navigate(link);
|
||||
// 获取优先级样式
|
||||
const priorityBorderWidth = getPriorityBorderWidth(priority);
|
||||
const isDark = useColorModeValue(false, true);
|
||||
const priorityBgOpacity = getPriorityBgOpacity(priority, isDark);
|
||||
|
||||
// 根据优先级调整背景色深度
|
||||
const getPriorityBgColor = () => {
|
||||
const colorScheme = typeConfig.colorScheme;
|
||||
// 亮色模式:根据优先级使用不同深度的颜色
|
||||
if (!isDark) {
|
||||
if (priority === PRIORITY_LEVELS.URGENT) {
|
||||
return `${colorScheme}.200`; // 紧急:深色背景 + 脉冲动画
|
||||
} else if (priority === PRIORITY_LEVELS.IMPORTANT) {
|
||||
return `${colorScheme}.100`; // 重要:中色背景
|
||||
} else {
|
||||
// 普通:极淡背景(使用 white 或 gray.50,降低视觉干扰)
|
||||
return 'white';
|
||||
}
|
||||
} else {
|
||||
// 暗色模式:使用 typeConfig 的 darkBg 或回退
|
||||
if (priority === PRIORITY_LEVELS.URGENT) {
|
||||
return typeConfig.darkBg || `${colorScheme}.800`;
|
||||
} else if (priority === PRIORITY_LEVELS.IMPORTANT) {
|
||||
return typeConfig.darkBg || `${colorScheme}.800`;
|
||||
} else {
|
||||
// 普通通知在暗色模式下使用更暗的灰色背景
|
||||
return 'gray.800';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 颜色配置 - 支持亮色/暗色模式(使用 useMemo 优化)
|
||||
const colors = useMemo(() => ({
|
||||
bg: getPriorityBgColor(),
|
||||
border: useColorModeValue(
|
||||
typeConfig.borderColor,
|
||||
typeConfig.darkBorderColor || `${typeConfig.colorScheme}.400`
|
||||
),
|
||||
icon: useColorModeValue(
|
||||
typeConfig.iconColor,
|
||||
typeConfig.darkIconColor || `${typeConfig.colorScheme}.300`
|
||||
),
|
||||
text: useColorModeValue('gray.800', 'gray.100'),
|
||||
subText: useColorModeValue('gray.600', 'gray.300'),
|
||||
metaText: useColorModeValue('gray.500', 'gray.500'),
|
||||
hoverBg: useColorModeValue(
|
||||
typeConfig.hoverBg,
|
||||
typeConfig.darkHoverBg || `${typeConfig.colorScheme}.700`
|
||||
),
|
||||
closeButtonHoverBg: useColorModeValue(
|
||||
`${typeConfig.colorScheme}.200`,
|
||||
`${typeConfig.colorScheme}.700`
|
||||
),
|
||||
}), [isDark, priority, typeConfig]);
|
||||
|
||||
// 点击处理(只有真正可点击时才执行)- 使用 useCallback 优化
|
||||
const handleClick = useCallback(() => {
|
||||
if (isActuallyClickable && !isNavigating) {
|
||||
// 设置加载状态
|
||||
setIsNavigating(true);
|
||||
|
||||
// 追踪点击(监控埋点)
|
||||
trackNotificationClick(id);
|
||||
|
||||
// 导航到目标页面
|
||||
navigate(link);
|
||||
|
||||
// 延迟关闭通知(给用户足够的视觉反馈 - 300ms)
|
||||
setTimeout(() => {
|
||||
onClose(id, true);
|
||||
}, 300);
|
||||
}
|
||||
}, [id, link, isActuallyClickable, isNavigating, trackNotificationClick, navigate, onClose]);
|
||||
|
||||
// 生成完整的无障碍描述
|
||||
const ariaDescription = getNotificationDescription(notification);
|
||||
|
||||
return (
|
||||
<ScaleFade initialScale={0.9} in={true}>
|
||||
<Box
|
||||
bg={bgColor}
|
||||
borderLeft="4px solid"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
boxShadow={isNewest ? '2xl' : 'lg'}
|
||||
p={4}
|
||||
w="400px" // 统一宽度
|
||||
position="relative"
|
||||
cursor={isActuallyClickable ? 'pointer' : 'default'} // 严格判断
|
||||
onClick={isActuallyClickable ? handleClick : undefined} // 严格判断
|
||||
_hover={isActuallyClickable ? {
|
||||
boxShadow: 'xl',
|
||||
transform: 'translateY(-2px)',
|
||||
bg: hoverBg,
|
||||
} : {}} // 不可点击时无 hover 效果
|
||||
transition="all 0.2s"
|
||||
{...(isNewest && {
|
||||
borderRight: '1px solid',
|
||||
borderRightColor: borderColor,
|
||||
borderTop: '1px solid',
|
||||
borderTopColor: useColorModeValue(`${typeConfig.colorScheme}.100`, `${typeConfig.colorScheme}.700`),
|
||||
})}
|
||||
>
|
||||
{/* 头部区域:图标 + 标题 + 优先级 + AI标识 */}
|
||||
<Box
|
||||
// 无障碍属性
|
||||
role={priority === 'urgent' ? 'alert' : 'status'}
|
||||
aria-live={priority === 'urgent' ? 'assertive' : 'polite'}
|
||||
aria-atomic="true"
|
||||
aria-label={ariaDescription}
|
||||
tabIndex={isActuallyClickable ? 0 : -1}
|
||||
onKeyDown={(e) => isActuallyClickable && handleKeyPress(e, handleClick)}
|
||||
// 样式属性
|
||||
bg={colors.bg}
|
||||
borderLeft={`${priorityBorderWidth} solid`}
|
||||
borderColor={colors.border}
|
||||
// 可点击的通知添加完整边框提示
|
||||
{...(isActuallyClickable && {
|
||||
border: '1px solid',
|
||||
borderLeftWidth: priorityBorderWidth, // 保持左侧优先级边框
|
||||
})}
|
||||
borderRadius="md"
|
||||
// 可点击的通知使用更明显的阴影(悬浮感)
|
||||
boxShadow={isActuallyClickable
|
||||
? (isNewest ? '2xl' : 'md')
|
||||
: (isNewest ? 'xl' : 'sm')}
|
||||
// 紧急通知添加脉冲动画
|
||||
animation={priority === PRIORITY_LEVELS.URGENT ? `${pulseAnimation} 2s ease-in-out infinite` : undefined}
|
||||
p={{ base: 3, md: 4 }}
|
||||
w={{ base: "calc(100vw - 32px)", sm: "360px", md: "380px", lg: "400px" }}
|
||||
maxW="400px"
|
||||
position="relative"
|
||||
cursor={isActuallyClickable ? (isNavigating ? 'wait' : 'pointer') : 'default'}
|
||||
onClick={isActuallyClickable && !isNavigating ? handleClick : undefined}
|
||||
opacity={isNavigating ? 0.7 : 1}
|
||||
pointerEvents={isNavigating ? 'none' : 'auto'}
|
||||
_hover={isActuallyClickable && !isNavigating ? {
|
||||
boxShadow: 'xl',
|
||||
transform: 'translateY(-2px)',
|
||||
bg: colors.hoverBg,
|
||||
} : {}} // 不可点击时无 hover 效果
|
||||
_focus={{
|
||||
outline: '2px solid',
|
||||
outlineColor: 'blue.500',
|
||||
outlineOffset: '2px',
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
willChange="transform, opacity" // 性能优化:GPU 加速
|
||||
{...(isNewest && {
|
||||
borderRight: '1px solid',
|
||||
borderRightColor: colors.border,
|
||||
borderTop: '1px solid',
|
||||
borderTopColor: useColorModeValue(`${typeConfig.colorScheme}.100`, `${typeConfig.colorScheme}.700`),
|
||||
})}
|
||||
>
|
||||
{/* 头部区域:标题 + 可选标识 */}
|
||||
<HStack spacing={2} align="start" mb={2}>
|
||||
{/* 类型图标 */}
|
||||
<Icon
|
||||
as={typeConfig.icon}
|
||||
w={5}
|
||||
h={5}
|
||||
color={typeConfig.iconColor}
|
||||
mt={0.5}
|
||||
flexShrink={0}
|
||||
/>
|
||||
{/* 类型图标 - 仅紧急和重要通知显示 */}
|
||||
{shouldShowIcon && (
|
||||
<Icon
|
||||
as={typeConfig.icon}
|
||||
w={5}
|
||||
h={5}
|
||||
color={colors.icon} // 使用响应式颜色
|
||||
mt={0.5}
|
||||
flexShrink={0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 标题 */}
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={textColor}
|
||||
color={colors.text}
|
||||
lineHeight="short"
|
||||
flex={1}
|
||||
noOfLines={2}
|
||||
noOfLines={{ base: 1, md: 2 }}
|
||||
pl={shouldShowIcon ? 0 : 0}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
@@ -137,42 +461,27 @@ const NotificationItem = ({ notification, onClose, isNewest = false }) => {
|
||||
</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="xs"
|
||||
variant="ghost"
|
||||
colorScheme={typeConfig.colorScheme}
|
||||
aria-label="关闭通知"
|
||||
aria-label={`关闭通知:${title}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose(id);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose(id);
|
||||
}
|
||||
}}
|
||||
flexShrink={0}
|
||||
_hover={{
|
||||
bg: closeButtonHoverBg,
|
||||
bg: colors.closeButtonHoverBg,
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
@@ -180,11 +489,11 @@ const NotificationItem = ({ notification, onClose, isNewest = false }) => {
|
||||
{/* 内容区域 */}
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={subTextColor}
|
||||
color={colors.subText}
|
||||
lineHeight="short"
|
||||
noOfLines={3}
|
||||
noOfLines={{ base: 2, md: 3 }}
|
||||
mb={3}
|
||||
pl={7} // 与图标对齐
|
||||
pl={shouldShowIcon ? 7 : 0} // 有图标时与图标对齐
|
||||
>
|
||||
{content}
|
||||
</Text>
|
||||
@@ -193,61 +502,139 @@ const NotificationItem = ({ notification, onClose, isNewest = false }) => {
|
||||
<HStack
|
||||
spacing={2}
|
||||
fontSize="xs"
|
||||
color={metaTextColor}
|
||||
pl={7} // 与图标对齐
|
||||
color={colors.metaText}
|
||||
pl={shouldShowIcon ? 7 : 0} // 有图标时与图标对齐
|
||||
flexWrap="wrap"
|
||||
>
|
||||
{/* 作者信息(仅分析报告) */}
|
||||
{author && (
|
||||
<HStack spacing={1}>
|
||||
<Text>👤</Text>
|
||||
<Text>{author.name} - {author.organization}</Text>
|
||||
<Text>|</Text>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* 时间信息 */}
|
||||
<HStack spacing={1}>
|
||||
<Text>📅</Text>
|
||||
<Icon as={MdAccessTime} w={3} h={3} />
|
||||
<Text>
|
||||
{publishTime && formatNotificationTime(publishTime)}
|
||||
{!publishTime && pushTime && formatNotificationTime(pushTime)}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 状态提示(仅预测通知) */}
|
||||
{extra?.statusHint && (
|
||||
{/* AI 标识 - 小徽章(小屏及以上显示)*/}
|
||||
{isAIGenerated && (
|
||||
<>
|
||||
<Text>|</Text>
|
||||
<HStack spacing={1} color="gray.400">
|
||||
<Icon as={MdSchedule} w={3} h={3} />
|
||||
<Text>{extra.statusHint}</Text>
|
||||
<Text display={{ base: "none", sm: "inline" }}>|</Text>
|
||||
<Badge
|
||||
colorScheme="purple"
|
||||
size="xs"
|
||||
display={{ base: "none", sm: "inline-flex" }}
|
||||
>
|
||||
AI
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 预测标识 + 状态提示 - 合并显示(小屏及以上)*/}
|
||||
{isPrediction && (
|
||||
<>
|
||||
<Text display={{ base: "none", sm: "inline" }}>|</Text>
|
||||
<HStack spacing={1} display={{ base: "none", sm: "flex" }}>
|
||||
<Badge colorScheme="gray" size="xs">预测</Badge>
|
||||
{extra?.statusHint && (
|
||||
<>
|
||||
<Icon as={MdSchedule} w={3} h={3} color="gray.400" />
|
||||
<Text color="gray.400">{extra.statusHint}</Text>
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 可点击提示(仅真正可点击的通知) */}
|
||||
{/* 作者信息 - 仅当数据存在时显示(平板及以上)*/}
|
||||
{author && (
|
||||
<>
|
||||
<Text display={{ base: "none", md: "inline" }}>|</Text>
|
||||
<HStack spacing={1} display={{ base: "none", md: "flex" }}>
|
||||
<Icon as={MdPerson} w={3} h={3} />
|
||||
<Text>{author.name} - {author.organization}</Text>
|
||||
</HStack>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 可点击提示(仅真正可点击的通知)*/}
|
||||
{isActuallyClickable && (
|
||||
<>
|
||||
<Text>|</Text>
|
||||
<HStack spacing={1}>
|
||||
<Icon as={MdOpenInNew} w={3} h={3} />
|
||||
<Text>查看详情</Text>
|
||||
{/* Loading 时显示 Spinner,否则显示图标 */}
|
||||
{isNavigating ? (
|
||||
<Spinner size="xs" />
|
||||
) : (
|
||||
<Icon as={MdOpenInNew} w={3} h={3} />
|
||||
)}
|
||||
{/* Loading 时显示"跳转中...",否则显示"查看详情" */}
|
||||
<Text color={isNavigating ? 'blue.500' : undefined}>
|
||||
{isNavigating ? '跳转中...' : '查看详情'}
|
||||
</Text>
|
||||
</HStack>
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</Box>
|
||||
</ScaleFade>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
}, (prevProps, nextProps) => {
|
||||
// 自定义比较函数:只在 id 或 isNewest 变化时重渲染
|
||||
return (
|
||||
prevProps.notification.id === nextProps.notification.id &&
|
||||
prevProps.isNewest === nextProps.isNewest
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* 通知容器组件 - 主组件
|
||||
*/
|
||||
const NotificationContainer = () => {
|
||||
const { notifications, removeNotification } = useNotification();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
// 使用带过期时间的 localStorage(2分钟 = 120000 毫秒)
|
||||
const [isExpanded, setIsExpanded] = useLocalStorageWithExpiry(
|
||||
'notification-expanded-state',
|
||||
false,
|
||||
120000
|
||||
);
|
||||
|
||||
// 追踪新通知(性能优化:只对新通知做动画)
|
||||
const prevNotificationIdsRef = useRef(new Set());
|
||||
const isFirstRenderRef = useRef(true);
|
||||
const [newNotificationIds, setNewNotificationIds] = useState(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
// 首次渲染跳过动画检测
|
||||
if (isFirstRenderRef.current) {
|
||||
isFirstRenderRef.current = false;
|
||||
const currentIds = new Set(notifications.map(n => n.id));
|
||||
prevNotificationIdsRef.current = currentIds;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIds = new Set(notifications.map(n => n.id));
|
||||
const prevIds = prevNotificationIdsRef.current;
|
||||
|
||||
// 找出新增的通知 ID
|
||||
const newIds = new Set();
|
||||
currentIds.forEach(id => {
|
||||
if (!prevIds.has(id)) {
|
||||
newIds.add(id);
|
||||
}
|
||||
});
|
||||
|
||||
setNewNotificationIds(newIds);
|
||||
|
||||
// 更新引用
|
||||
prevNotificationIdsRef.current = currentIds;
|
||||
|
||||
// 1秒后清除新通知标记(动画完成)
|
||||
if (newIds.size > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
setNewNotificationIds(new Set());
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [notifications]);
|
||||
|
||||
// 如果没有通知,不渲染
|
||||
if (notifications.length === 0) {
|
||||
@@ -265,11 +652,18 @@ const NotificationContainer = () => {
|
||||
const collapseHoverBg = useColorModeValue('gray.200', 'gray.600');
|
||||
const collapseTextColor = useColorModeValue('gray.700', 'gray.200');
|
||||
|
||||
// 构建无障碍描述
|
||||
const containerAriaLabel = hasMore
|
||||
? `通知中心,共有 ${notifications.length} 条通知,当前显示 ${visibleNotifications.length} 条,${isExpanded ? '已展开全部' : `还有 ${hiddenCount} 条折叠`}。使用Tab键导航,Enter键或空格键查看详情。`
|
||||
: `通知中心,共有 ${notifications.length} 条通知。使用Tab键导航,Enter键或空格键查看详情。`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
role="region"
|
||||
aria-label={containerAriaLabel}
|
||||
position="fixed"
|
||||
bottom={6}
|
||||
right={6}
|
||||
bottom={{ base: 3, md: 28 }}
|
||||
right={{ base: 3, md: 6 }}
|
||||
zIndex={9999}
|
||||
pointerEvents="none"
|
||||
>
|
||||
@@ -278,35 +672,56 @@ const NotificationContainer = () => {
|
||||
align="flex-end"
|
||||
pointerEvents="auto"
|
||||
>
|
||||
{visibleNotifications.map((notification, index) => (
|
||||
<Slide
|
||||
key={notification.id}
|
||||
direction="bottom"
|
||||
in={true}
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 9999 - index, // 最新消息(index=0)z-index最高
|
||||
}}
|
||||
>
|
||||
<NotificationItem
|
||||
notification={notification}
|
||||
onClose={removeNotification}
|
||||
isNewest={index === 0} // 第一条消息是最新的
|
||||
/>
|
||||
</Slide>
|
||||
))}
|
||||
<AnimatePresence mode="popLayout">
|
||||
{visibleNotifications.map((notification, index) => (
|
||||
<motion.div
|
||||
key={notification.id}
|
||||
layout // 自动处理位置变化(流畅重排)
|
||||
initial={{ opacity: 0, y: 50, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: 300, scale: 0.9 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
mass: 0.8,
|
||||
}}
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 9999 - index,
|
||||
}}
|
||||
>
|
||||
<NotificationItem
|
||||
notification={notification}
|
||||
onClose={removeNotification}
|
||||
isNewest={index === 0}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 折叠/展开按钮 */}
|
||||
{hasMore && (
|
||||
<ScaleFade initialScale={0.9} in={true}>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
size={{ base: "xs", md: "sm" }}
|
||||
variant="solid"
|
||||
bg={collapseBg}
|
||||
color={collapseTextColor}
|
||||
_hover={{ bg: collapseHoverBg }}
|
||||
_focus={{
|
||||
outline: '2px solid',
|
||||
outlineColor: 'blue.500',
|
||||
outlineOffset: '2px',
|
||||
}}
|
||||
leftIcon={<Icon as={isExpanded ? MdExpandLess : MdExpandMore} />}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={isExpanded ? '收起通知' : `展开查看还有 ${hiddenCount} 条通知`}
|
||||
boxShadow="md"
|
||||
borderRadius="md"
|
||||
>
|
||||
@@ -315,7 +730,7 @@ const NotificationContainer = () => {
|
||||
: NOTIFICATION_CONFIG.collapse.textTemplate.replace('{count}', hiddenCount)
|
||||
}
|
||||
</Button>
|
||||
</ScaleFade>
|
||||
</motion.div>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user