feat: 添加消息推送能力,添加新闻催化分析页的合规提示
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
// src/components/Citation/CitedContent.js
|
||||
import React from 'react';
|
||||
import { Typography, Space, Tag } from 'antd';
|
||||
import { RobotOutlined, FileSearchOutlined } from '@ant-design/icons';
|
||||
import { Typography, Tag } from 'antd';
|
||||
import { RobotOutlined } from '@ant-design/icons';
|
||||
import CitationMark from './CitationMark';
|
||||
import { processCitationData } from '../../utils/citationUtils';
|
||||
import { logger } from '../../utils/logger';
|
||||
@@ -9,20 +9,25 @@ import { logger } from '../../utils/logger';
|
||||
const { Text } = Typography;
|
||||
|
||||
/**
|
||||
* 带引用标注的内容组件
|
||||
* 带引用标注的内容组件(块级模式)
|
||||
* 展示拼接的文本,每句话后显示上标引用【1】【2】【3】
|
||||
* 支持鼠标悬浮和点击查看引用来源
|
||||
* AI 标识统一显示在右上角,不占用布局高度
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Object} props.data - API 返回的原始数据 { data: [...] }
|
||||
* @param {string} props.title - 标题文本,默认 "AI 分析结果"
|
||||
* @param {boolean} props.showAIBadge - 是否显示 AI 生成标识,默认 true
|
||||
* @param {string} props.prefix - 内容前的前缀标签,如 "机制:"(可选)
|
||||
* @param {Object} props.prefixStyle - 前缀标签的自定义样式(可选)
|
||||
* @param {boolean} props.showAIBadge - 是否显示右上角 AI 标识,默认 true(可选)
|
||||
* @param {Object} props.containerStyle - 容器额外样式(可选)
|
||||
*
|
||||
* @example
|
||||
* <CitedContent
|
||||
* data={apiData}
|
||||
* title="关联描述"
|
||||
* prefix="机制:"
|
||||
* prefixStyle={{ color: '#666' }}
|
||||
* showAIBadge={true}
|
||||
* containerStyle={{ marginTop: 16 }}
|
||||
* />
|
||||
@@ -30,6 +35,8 @@ const { Text } = Typography;
|
||||
const CitedContent = ({
|
||||
data,
|
||||
title = 'AI 分析结果',
|
||||
prefix = '',
|
||||
prefixStyle = {},
|
||||
showAIBadge = true,
|
||||
containerStyle = {}
|
||||
}) => {
|
||||
@@ -45,59 +52,65 @@ const CitedContent = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
// 判断是否显示标题栏(内联模式:title为空且不显示AI徽章)
|
||||
const showHeader = title || showAIBadge;
|
||||
|
||||
// 根据是否显示标题栏决定容器样式
|
||||
const defaultContainerStyle = showHeader ? {
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: 6,
|
||||
padding: 16
|
||||
} : {};
|
||||
|
||||
// 检查是否为内联模式
|
||||
const isInlineMode = containerStyle?.display && containerStyle.display.includes('inline');
|
||||
|
||||
// 根据内联模式选择容器元素类型
|
||||
const ContainerTag = isInlineMode ? 'span' : 'div';
|
||||
const ContentTag = isInlineMode ? 'span' : 'div';
|
||||
|
||||
return (
|
||||
<ContainerTag
|
||||
<div
|
||||
style={{
|
||||
...defaultContainerStyle,
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: 6,
|
||||
padding: 16,
|
||||
paddingTop: title ? 16 : 20,
|
||||
...containerStyle
|
||||
}}
|
||||
>
|
||||
{/* 标题栏 - 仅在需要时显示 */}
|
||||
{showHeader && (
|
||||
<Space
|
||||
{/* AI 标识 - 固定在右上角 */}
|
||||
{showAIBadge && (
|
||||
<Tag
|
||||
icon={<RobotOutlined />}
|
||||
color="purple"
|
||||
style={{
|
||||
width: '100%',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
margin: 0,
|
||||
zIndex: 10,
|
||||
fontSize: 12,
|
||||
padding: '2px 8px'
|
||||
}}
|
||||
className="ai-badge-responsive"
|
||||
>
|
||||
<Space>
|
||||
<FileSearchOutlined style={{ color: '#1890ff', fontSize: 16 }} />
|
||||
<Text strong style={{ fontSize: 14 }}>
|
||||
{title}
|
||||
</Text>
|
||||
</Space>
|
||||
{showAIBadge && (
|
||||
<Tag
|
||||
icon={<RobotOutlined />}
|
||||
color="purple"
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
AI 生成
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
AI合成
|
||||
</Tag>
|
||||
)}
|
||||
|
||||
{/* 标题栏 */}
|
||||
{title && (
|
||||
<div style={{ marginBottom: 12, paddingRight: 80 }}>
|
||||
<Text strong style={{ fontSize: 14 }}>
|
||||
{title}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 带引用的文本内容 */}
|
||||
<ContentTag style={{ lineHeight: isInlineMode ? 'inherit' : 1.8 }}>
|
||||
<div style={{
|
||||
lineHeight: 1.8,
|
||||
paddingRight: title ? 0 : (showAIBadge ? 80 : 0)
|
||||
}}>
|
||||
{/* 前缀标签(如果有) */}
|
||||
{prefix && (
|
||||
<Text style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
display: 'inline',
|
||||
marginRight: 4,
|
||||
...prefixStyle
|
||||
}}>
|
||||
{prefix}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{processed.segments.map((segment, index) => (
|
||||
<React.Fragment key={`segment-${segment.citationId}`}>
|
||||
{/* 文本片段 */}
|
||||
@@ -117,8 +130,18 @@ const CitedContent = ({
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ContentTag>
|
||||
</ContainerTag>
|
||||
</div>
|
||||
|
||||
{/* 响应式样式 */}
|
||||
<style jsx>{`
|
||||
@media (max-width: 768px) {
|
||||
.ai-badge-responsive {
|
||||
font-size: 10px !important;
|
||||
padding: 1px 6px !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
189
src/components/NotificationContainer/index.js
Normal file
189
src/components/NotificationContainer/index.js
Normal file
@@ -0,0 +1,189 @@
|
||||
// src/components/NotificationContainer/index.js
|
||||
/**
|
||||
* 通知容器组件 - 右下角层叠显示实时通知
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
IconButton,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
Slide,
|
||||
ScaleFade,
|
||||
} from '@chakra-ui/react';
|
||||
import { MdClose, MdCheckCircle, MdError, MdWarning, MdInfo } 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',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 单个通知项组件
|
||||
*/
|
||||
const NotificationItem = ({ notification, onClose, isNewest = false }) => {
|
||||
const { id, severity = 'info', title, message } = notification;
|
||||
const style = NOTIFICATION_STYLES[severity] || NOTIFICATION_STYLES.info;
|
||||
|
||||
const bgColor = useColorModeValue(style.bg, `${style.colorScheme}.900`);
|
||||
const borderColor = useColorModeValue(style.borderColor, `${style.colorScheme}.500`);
|
||||
const textColor = useColorModeValue('gray.800', 'white');
|
||||
const subTextColor = useColorModeValue('gray.600', 'gray.300');
|
||||
|
||||
return (
|
||||
<ScaleFade initialScale={0.9} in={true}>
|
||||
<Box
|
||||
bg={bgColor}
|
||||
borderLeft="4px solid"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
boxShadow={isNewest ? '2xl' : 'lg'} // 最新消息更强的阴影
|
||||
p={4}
|
||||
minW="350px"
|
||||
maxW="450px"
|
||||
position="relative"
|
||||
_hover={{
|
||||
boxShadow: 'xl',
|
||||
transform: 'translateX(-4px)',
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
// 最新消息添加微妙的高亮边框
|
||||
{...(isNewest && {
|
||||
borderRight: '1px solid',
|
||||
borderRightColor: borderColor,
|
||||
borderTop: '1px solid',
|
||||
borderTopColor: useColorModeValue(`${style.colorScheme}.100`, `${style.colorScheme}.700`),
|
||||
})}
|
||||
>
|
||||
<HStack spacing={3} align="start">
|
||||
{/* 图标 */}
|
||||
<Icon
|
||||
as={style.icon}
|
||||
w={6}
|
||||
h={6}
|
||||
color={style.iconColor}
|
||||
mt={0.5}
|
||||
flexShrink={0}
|
||||
/>
|
||||
|
||||
{/* 内容 */}
|
||||
<VStack align="start" spacing={1} flex={1} mr={6}>
|
||||
<Text
|
||||
fontSize="md"
|
||||
fontWeight="bold"
|
||||
color={textColor}
|
||||
lineHeight="short"
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{message && (
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={subTextColor}
|
||||
lineHeight="short"
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
<IconButton
|
||||
icon={<MdClose />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme={style.colorScheme}
|
||||
aria-label="关闭通知"
|
||||
onClick={() => onClose(id)}
|
||||
position="absolute"
|
||||
top={2}
|
||||
right={2}
|
||||
_hover={{
|
||||
bg: useColorModeValue(`${style.colorScheme}.100`, `${style.colorScheme}.800`),
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
</Box>
|
||||
</ScaleFade>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 通知容器组件 - 主组件
|
||||
*/
|
||||
const NotificationContainer = () => {
|
||||
const { notifications, removeNotification } = useNotification();
|
||||
|
||||
// 如果没有通知,不渲染
|
||||
if (notifications.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="fixed"
|
||||
bottom={6}
|
||||
right={6}
|
||||
zIndex={9999}
|
||||
pointerEvents="none"
|
||||
>
|
||||
<VStack
|
||||
spacing={3} // 消息之间间距 12px
|
||||
align="flex-end"
|
||||
pointerEvents="auto"
|
||||
>
|
||||
{notifications.map((notification, index) => (
|
||||
<Slide
|
||||
key={notification.id}
|
||||
direction="right"
|
||||
in={true}
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 9999 - index, // 最新消息(index=0)z-index最高
|
||||
}}
|
||||
>
|
||||
<NotificationItem
|
||||
notification={notification}
|
||||
onClose={removeNotification}
|
||||
isNewest={index === 0} // 第一条消息是最新的
|
||||
/>
|
||||
</Slide>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationContainer;
|
||||
231
src/components/NotificationTestTool/index.js
Normal file
231
src/components/NotificationTestTool/index.js
Normal file
@@ -0,0 +1,231 @@
|
||||
// src/components/NotificationTestTool/index.js
|
||||
/**
|
||||
* 通知测试工具 - 仅在开发环境显示
|
||||
* 用于手动测试通知功能
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
IconButton,
|
||||
Collapse,
|
||||
useDisclosure,
|
||||
Badge,
|
||||
} from '@chakra-ui/react';
|
||||
import { MdNotifications, MdClose, MdVolumeOff, MdVolumeUp } from 'react-icons/md';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import { SOCKET_TYPE } from '../../services/socket';
|
||||
|
||||
const NotificationTestTool = () => {
|
||||
const { isOpen, onToggle } = useDisclosure();
|
||||
const { addNotification, soundEnabled, toggleSound, isConnected, clearAllNotifications, notifications } = useNotification();
|
||||
const [testCount, setTestCount] = useState(0);
|
||||
|
||||
// 只在开发环境显示
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const testNotifications = [
|
||||
{
|
||||
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({
|
||||
...notif,
|
||||
type: 'trade_alert',
|
||||
autoClose: 8000,
|
||||
});
|
||||
setTestCount(prev => prev + 1);
|
||||
};
|
||||
|
||||
const handleMultipleNotifications = () => {
|
||||
testNotifications.forEach((notif, index) => {
|
||||
setTimeout(() => {
|
||||
addNotification({
|
||||
...notif,
|
||||
type: 'trade_alert',
|
||||
autoClose: 10000,
|
||||
});
|
||||
}, index * 600);
|
||||
});
|
||||
setTestCount(prev => prev + testNotifications.length);
|
||||
};
|
||||
|
||||
const handleMaxLimitTest = () => {
|
||||
// 测试最大限制:快速发送6条,验证只保留最新5条
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
setTimeout(() => {
|
||||
addNotification({
|
||||
severity: i % 2 === 0 ? 'success' : 'info',
|
||||
title: `测试消息 #${i}`,
|
||||
message: `这是第 ${i} 条测试消息(共6条,应只保留最新5条)`,
|
||||
type: 'trade_alert',
|
||||
autoClose: 12000,
|
||||
});
|
||||
}, i * 400);
|
||||
}
|
||||
setTestCount(prev => prev + 6);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="fixed"
|
||||
top={4}
|
||||
right={4}
|
||||
zIndex={9998}
|
||||
bg="white"
|
||||
borderRadius="md"
|
||||
boxShadow="lg"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* 折叠按钮 */}
|
||||
<HStack
|
||||
p={2}
|
||||
bg="blue.500"
|
||||
color="white"
|
||||
cursor="pointer"
|
||||
onClick={onToggle}
|
||||
spacing={2}
|
||||
>
|
||||
<MdNotifications size={20} />
|
||||
<Text fontSize="sm" fontWeight="bold">
|
||||
通知测试工具
|
||||
</Text>
|
||||
<Badge colorScheme={isConnected ? 'green' : 'red'} ml="auto">
|
||||
{isConnected ? 'Connected' : 'Disconnected'}
|
||||
</Badge>
|
||||
<Badge colorScheme="purple">
|
||||
{SOCKET_TYPE}
|
||||
</Badge>
|
||||
<IconButton
|
||||
icon={isOpen ? <MdClose /> : <MdNotifications />}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
colorScheme="whiteAlpha"
|
||||
aria-label={isOpen ? '关闭' : '打开'}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{/* 工具面板 */}
|
||||
<Collapse in={isOpen} animateOpacity>
|
||||
<VStack p={4} spacing={3} align="stretch" minW="250px">
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
点击按钮测试不同类型的通知
|
||||
</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
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
onClick={() => handleTestNotification(3)}
|
||||
>
|
||||
信息通知
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
onClick={handleMultipleNotifications}
|
||||
>
|
||||
层叠通知(4条)
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="pink"
|
||||
onClick={handleMaxLimitTest}
|
||||
>
|
||||
测试最大限制(6条→5条)
|
||||
</Button>
|
||||
|
||||
{/* 功能按钮 */}
|
||||
<HStack spacing={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
colorScheme="gray"
|
||||
onClick={clearAllNotifications}
|
||||
flex={1}
|
||||
>
|
||||
清空全部
|
||||
</Button>
|
||||
|
||||
<IconButton
|
||||
size="sm"
|
||||
icon={soundEnabled ? <MdVolumeUp /> : <MdVolumeOff />}
|
||||
colorScheme={soundEnabled ? 'blue' : 'gray'}
|
||||
onClick={toggleSound}
|
||||
aria-label="切换音效"
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{/* 统计信息 */}
|
||||
<VStack spacing={1}>
|
||||
<HStack justify="space-between" w="full">
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
当前队列:
|
||||
</Text>
|
||||
<Badge colorScheme={notifications.length >= 5 ? 'red' : 'blue'}>
|
||||
{notifications.length} / 5
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.400" textAlign="center">
|
||||
已测试: {testCount} 条通知
|
||||
</Text>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationTestTool;
|
||||
@@ -553,7 +553,6 @@ const StockChartAntdModal = ({
|
||||
<CitedContent
|
||||
data={stock.relation_desc}
|
||||
title="关联描述"
|
||||
showAIBadge={true}
|
||||
containerStyle={{ marginTop: 16 }}
|
||||
/>
|
||||
) : stock?.relation_desc ? (
|
||||
|
||||
Reference in New Issue
Block a user