Compare commits

..

15 Commits

Author SHA1 Message Date
zdl
9429eb0559 Merge branch 'feature' of https://git.valuefrontier.cn/vf/vf_react into feature
# Conflicts:
#	src/views/Community/components/EventFilters.js
2025-10-24 12:37:35 +08:00
zdl
e69f822150 feat: user依赖优化 2025-10-24 12:34:43 +08:00
zdl
13c3c74b92 feat: 添加mock数据 2025-10-24 12:32:36 +08:00
zdl
bcf81f4d47 feat: 使用静态行业数据 2025-10-24 12:32:14 +08:00
zdl
f0d30244d2 feat: 添加重要性等级说明 2025-10-24 12:25:23 +08:00
zdl
f2cdc0756c feat: 添加行业静态数据 2025-10-24 12:21:22 +08:00
zdl
e91656d332 feat: user 依赖优化 2025-10-24 12:19:37 +08:00
zdl
5eb4227e29 feat: 股票概览中心改为个股中心 2025-10-24 11:03:41 +08:00
zdl
34a6c402c4 feat: homeNavar 将投资日历从社区页面的右侧导航移到了顶部导航栏
InvestmentCalendar.js 将 loadEventCounts 函数改为使用 useCallback 包装
  - 修复了 useEffect 的依赖数组,添加了 loadEventCounts
  - 为事件列表 Modal 添加了 zIndex={1500}
  - 为内容详情 Drawer 添加了 zIndex={1500}
  - 为相关股票 Modal 添加了 zIndex={1500}
src/views/Community/components/RightSidebar.js

  修改内容:
  - 已删除此文件
2025-10-24 10:56:43 +08:00
zdl
6ad38594bb feat: 添加重要事件说明 2025-10-23 17:37:03 +08:00
zdl
1ba8b8fd2f feat: 消息通知能力测试 2025-10-23 15:25:36 +08:00
zdl
45b88309b3 pref: 代码优化 2025-10-23 15:03:39 +08:00
zdl
28975f74e9 feat: 将新闻中心改为事件中心 2025-10-23 14:57:26 +08:00
zdl
4eaeab521f feat: 事件请求防抖优化 2025-10-23 14:42:14 +08:00
zdl
9dcd4bfbf3 feat: 调整行业请求数据结构 2025-10-23 14:24:26 +08:00
23 changed files with 6172 additions and 422 deletions

View File

@@ -4,7 +4,8 @@
"Read(//Users/qiye/**)",
"Bash(npm run lint:check)",
"Bash(npm run build)",
"Bash(chmod +x /Users/qiye/Desktop/jzqy/vf_react/scripts/*.sh)"
"Bash(chmod +x /Users/qiye/Desktop/jzqy/vf_react/scripts/*.sh)",
"Bash(node scripts/parseIndustryCSV.js)"
],
"deny": [],
"ask": []

View File

@@ -44,6 +44,7 @@ const TradingSimulation = React.lazy(() => import("views/TradingSimulation"));
import { AuthProvider } from "contexts/AuthContext";
import { AuthModalProvider } from "contexts/AuthModalContext";
import { NotificationProvider, useNotification } from "contexts/NotificationContext";
import { IndustryProvider } from "contexts/IndustryContext";
// Components
import ProtectedRoute from "components/ProtectedRoute";
@@ -301,16 +302,18 @@ export default function App() {
}}
>
<ErrorBoundary>
<AuthProvider>
<AuthModalProvider>
<NotificationProvider>
<AppContent />
<AuthModalManager />
<NotificationContainer />
<NotificationTestTool />
</NotificationProvider>
</AuthModalProvider>
</AuthProvider>
<NotificationProvider>
<AuthProvider>
<AuthModalProvider>
<IndustryProvider>
<AppContent />
<AuthModalManager />
<NotificationContainer />
<NotificationTestTool />
</IndustryProvider>
</AuthModalProvider>
</AuthProvider>
</NotificationProvider>
</ErrorBoundary>
</ChakraProvider>
);

View File

@@ -32,6 +32,12 @@ import {
useColorModeValue,
useToast,
Tooltip,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
} from '@chakra-ui/react';
import { ChevronDownIcon, HamburgerIcon, SunIcon, MoonIcon } from '@chakra-ui/icons';
import { FiStar, FiCalendar, FiUser, FiSettings, FiHome, FiLogOut } from 'react-icons/fi';
@@ -44,6 +50,7 @@ import { getApiBase } from '../../utils/apiConfig';
import SubscriptionButton from '../Subscription/SubscriptionButton';
import SubscriptionModal from '../Subscription/SubscriptionModal';
import { CrownIcon, TooltipContent } from '../Subscription/CrownTooltip';
import InvestmentCalendar from '../../views/Community/components/InvestmentCalendar';
/** 二级导航栏组件 - 显示当前一级菜单下的所有二级菜单项 */
const SecondaryNav = ({ showCompletenessAlert }) => {
@@ -51,20 +58,22 @@ const SecondaryNav = ({ showCompletenessAlert }) => {
const location = useLocation();
const navbarBg = useColorModeValue('gray.50', 'gray.700');
const itemHoverBg = useColorModeValue('white', 'gray.600');
// ⚠️ 必须在组件顶层调用所有Hooks不能在JSX中调用
const borderColorValue = useColorModeValue('gray.200', 'gray.600');
// 定义二级导航结构
const secondaryNavConfig = {
'/community': {
title: '高频跟踪',
items: [
{ path: '/community', label: '新闻催化分析', badges: [{ text: 'HOT', colorScheme: 'green' }, { text: 'NEW', colorScheme: 'red' }] },
{ path: '/community', label: '事件中心', badges: [{ text: 'HOT', colorScheme: 'green' }, { text: 'NEW', colorScheme: 'red' }] },
{ path: '/concepts', label: '概念中心', badges: [{ text: 'NEW', colorScheme: 'red' }] }
]
},
'/concepts': {
title: '高频跟踪',
items: [
{ path: '/community', label: '新闻催化分析', badges: [{ text: 'HOT', colorScheme: 'green' }, { text: 'NEW', colorScheme: 'red' }] },
{ path: '/community', label: '事件中心', badges: [{ text: 'HOT', colorScheme: 'green' }, { text: 'NEW', colorScheme: 'red' }] },
{ path: '/concepts', label: '概念中心', badges: [{ text: 'NEW', colorScheme: 'red' }] }
]
},
@@ -108,7 +117,7 @@ const SecondaryNav = ({ showCompletenessAlert }) => {
<Box
bg={navbarBg}
borderBottom="1px"
borderColor={useColorModeValue('gray.200', 'gray.600')}
borderColor={borderColorValue}
py={2}
position="sticky"
top={showCompletenessAlert ? "120px" : "60px"}
@@ -217,7 +226,7 @@ const MoreNavMenu = ({ isAuthenticated, user }) => {
bg={location.pathname.includes('/community') ? 'blue.50' : 'transparent'}
>
<Flex justify="space-between" align="center" w="100%">
<Text fontSize="sm">新闻催化分析</Text>
<Text fontSize="sm">事件中心</Text>
<HStack spacing={1}>
<Badge size="sm" colorScheme="green">HOT</Badge>
<Badge size="sm" colorScheme="red">NEW</Badge>
@@ -301,6 +310,9 @@ const NavItems = ({ isAuthenticated, user }) => {
const navigate = useNavigate();
const location = useLocation();
// ⚠️ 必须在组件顶层调用所有Hooks不能在JSX中调用
const contactTextColor = useColorModeValue('gray.500', 'gray.300');
// 辅助函数:判断导航项是否激活
const isActive = useCallback((paths) => {
return paths.some(path => location.pathname.includes(path));
@@ -333,7 +345,7 @@ const NavItems = ({ isAuthenticated, user }) => {
fontWeight={location.pathname.includes('/community') ? 'bold' : 'normal'}
>
<Flex justify="space-between" align="center" w="100%">
<Text fontSize="sm">新闻催化分析</Text>
<Text fontSize="sm">事件中心</Text>
<HStack spacing={1}>
<Badge size="sm" colorScheme="green">HOT</Badge>
<Badge size="sm" colorScheme="red">NEW</Badge>
@@ -452,7 +464,7 @@ const NavItems = ({ isAuthenticated, user }) => {
联系我们
</MenuButton>
<MenuList minW="260px" p={4}>
<Text fontSize="sm" color={useColorModeValue('gray.500', 'gray.300')}>敬请期待</Text>
<Text fontSize="sm" color={contactTextColor}>敬请期待</Text>
</MenuList>
</Menu>
</HStack>
@@ -477,6 +489,11 @@ export default function HomeNavbar() {
const brandHover = useColorModeValue('blue.600', 'blue.300');
const toast = useToast();
// ⚡ 提取 userId 为独立变量,避免 user 对象引用变化导致无限循环
const userId = user?.id;
const prevUserIdRef = React.useRef(userId);
const prevIsAuthenticatedRef = React.useRef(isAuthenticated);
// 添加调试信息
logger.debug('HomeNavbar', '组件渲染状态', {
hasUser: !!user,
@@ -521,6 +538,9 @@ export default function HomeNavbar() {
const WATCHLIST_PAGE_SIZE = 10;
const EVENTS_PAGE_SIZE = 8;
// 投资日历 Modal 状态
const [calendarModalOpen, setCalendarModalOpen] = useState(false);
// 用户信息完整性状态
const [profileCompleteness, setProfileCompleteness] = useState(null);
const [showCompletenessAlert, setShowCompletenessAlert] = useState(false);
@@ -712,65 +732,81 @@ export default function HomeNavbar() {
error: error.message
});
}
}, [isAuthenticated, user?.id]); // 只依赖 user.id,避免 user 对象变化导致无限循环
}, [isAuthenticated, userId]); // ⚡ 使用 userId 而不是 user?.id
// 监听用户变化,重置检查标志(用户切换或退出登录时)
React.useEffect(() => {
if (!isAuthenticated || !user) {
// 用户退出登录,重置标志
hasCheckedCompleteness.current = false;
setProfileCompleteness(null);
setShowCompletenessAlert(false);
const userIdChanged = prevUserIdRef.current !== userId;
const authChanged = prevIsAuthenticatedRef.current !== isAuthenticated;
if (userIdChanged || authChanged) {
prevUserIdRef.current = userId;
prevIsAuthenticatedRef.current = isAuthenticated;
if (!isAuthenticated || !user) {
// 用户退出登录,重置标志
hasCheckedCompleteness.current = false;
setProfileCompleteness(null);
setShowCompletenessAlert(false);
}
}
}, [isAuthenticated, user?.id]); // 监听用户 ID 变化
}, [isAuthenticated, userId, user]); // ⚡ 使用 userId
// 用户登录后检查资料完整性
React.useEffect(() => {
if (isAuthenticated && user) {
const userIdChanged = prevUserIdRef.current !== userId;
const authChanged = prevIsAuthenticatedRef.current !== isAuthenticated;
if ((userIdChanged || authChanged) && isAuthenticated && user) {
// 延迟检查,避免过于频繁
const timer = setTimeout(checkProfileCompleteness, 1000);
return () => clearTimeout(timer);
}
}, [isAuthenticated, user?.id, checkProfileCompleteness]); // 只依赖 user.id,避免无限循环
}, [isAuthenticated, userId, checkProfileCompleteness, user]); // ⚡ 使用 userId
// 加载订阅信息
React.useEffect(() => {
if (isAuthenticated && user) {
const loadSubscriptionInfo = async () => {
try {
const base = getApiBase();
const response = await fetch(base + '/api/subscription/current', {
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
if (data.success && data.data) {
// 数据标准化处理确保type字段是小写的 'free', 'pro', 或 'max'
const normalizedData = {
type: (data.data.type || data.data.subscription_type || 'free').toLowerCase(),
status: data.data.status || 'active',
days_left: data.data.days_left || 0,
is_active: data.data.is_active !== false,
end_date: data.data.end_date || null
};
setSubscriptionInfo(normalizedData);
const userIdChanged = prevUserIdRef.current !== userId;
const authChanged = prevIsAuthenticatedRef.current !== isAuthenticated;
if (userIdChanged || authChanged) {
if (isAuthenticated && user) {
const loadSubscriptionInfo = async () => {
try {
const base = getApiBase();
const response = await fetch(base + '/api/subscription/current', {
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
if (data.success && data.data) {
// 数据标准化处理确保type字段是小写的 'free', 'pro', 或 'max'
const normalizedData = {
type: (data.data.type || data.data.subscription_type || 'free').toLowerCase(),
status: data.data.status || 'active',
days_left: data.data.days_left || 0,
is_active: data.data.is_active !== false,
end_date: data.data.end_date || null
};
setSubscriptionInfo(normalizedData);
}
}
} catch (error) {
logger.error('HomeNavbar', '加载订阅信息失败', error);
}
} catch (error) {
logger.error('HomeNavbar', '加载订阅信息失败', error);
}
};
loadSubscriptionInfo();
} else {
// 用户未登录时,重置为免费版
setSubscriptionInfo({
type: 'free',
status: 'active',
days_left: 0,
is_active: true
});
};
loadSubscriptionInfo();
} else {
// 用户未登录时,重置为免费版
setSubscriptionInfo({
type: 'free',
status: 'active',
days_left: 0,
is_active: true
});
}
}
}, [isAuthenticated, user?.id]); // 只依赖 user.id 而不是整个 user 对象
}, [isAuthenticated, userId, user]); // ⚡ 使用 userId防重复通过 ref 判断
return (
<>
@@ -889,6 +925,20 @@ export default function HomeNavbar() {
) : isAuthenticated && user ? (
// 已登录状态 - 用户菜单 + 功能菜单排列
<HStack spacing={{ base: 2, md: 3 }}>
{/* 投资日历 - 仅大屏显示 */}
{isDesktop && (
<Button
size="sm"
colorScheme="orange"
variant="solid"
borderRadius="full"
leftIcon={<FiCalendar />}
onClick={() => setCalendarModalOpen(true)}
>
投资日历
</Button>
)}
{/* 自选股 - 仅大屏显示 */}
{isDesktop && (
<Menu onOpen={loadWatchlistQuotes}>
@@ -1155,6 +1205,11 @@ export default function HomeNavbar() {
<MenuDivider />
{/* 投资日历 */}
<MenuItem icon={<FiCalendar />} onClick={() => navigate('/community')}>
<Text>投资日历</Text>
</MenuItem>
{/* 自选股 */}
<MenuItem icon={<FiStar />} onClick={() => navigate('/home/center')}>
<Flex justify="space-between" align="center" w="100%">
@@ -1352,7 +1407,7 @@ export default function HomeNavbar() {
fontWeight={location.pathname.includes('/community') ? 'bold' : 'normal'}
>
<HStack justify="space-between">
<Text fontSize="sm">新闻催化分析</Text>
<Text fontSize="sm">事件中心</Text>
<HStack spacing={1}>
<Badge size="xs" colorScheme="green">HOT</Badge>
<Badge size="xs" colorScheme="red">NEW</Badge>
@@ -1516,6 +1571,22 @@ export default function HomeNavbar() {
{/* 二级导航栏 - 显示当前页面所属的二级菜单 */}
{!isMobile && <SecondaryNav showCompletenessAlert={showCompletenessAlert} />}
{/* 投资日历 Modal */}
<Modal
isOpen={calendarModalOpen}
onClose={() => setCalendarModalOpen(false)}
size="6xl"
>
<ModalOverlay />
<ModalContent maxW="1200px">
<ModalHeader>投资日历</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<InvestmentCalendar />
</ModalBody>
</ModalContent>
</Modal>
</>
);
}

View File

@@ -299,8 +299,8 @@ const NotificationItem = React.memo(({ notification, onClose, isNewest = false }
const isDark = useColorModeValue(false, true);
const priorityBgOpacity = getPriorityBgOpacity(priority, isDark);
// 根据优先级调整背景色深度
const getPriorityBgColor = () => {
// 根据优先级调整背景色深度(使用 useMemo 缓存计算结果)
const priorityBgColor = useMemo(() => {
const colorScheme = typeConfig.colorScheme;
// 亮色模式:根据优先级使用不同深度的颜色
if (!isDark) {
@@ -323,31 +323,41 @@ const NotificationItem = React.memo(({ notification, onClose, isNewest = false }
return 'gray.800';
}
}
};
}, [isDark, priority, typeConfig]);
// 颜色配置 - 支持亮色/暗色模式(使用 useMemo 优化)
// 颜色配置 - 支持亮色/暗色模式
// ⚠️ 必须在组件顶层调用 useColorModeValue不能在 useMemo 内部调用
const borderColor = useColorModeValue(
typeConfig.borderColor,
typeConfig.darkBorderColor || `${typeConfig.colorScheme}.400`
);
const iconColor = useColorModeValue(
typeConfig.iconColor,
typeConfig.darkIconColor || `${typeConfig.colorScheme}.300`
);
const textColor = useColorModeValue('gray.800', 'gray.100');
const subTextColor = useColorModeValue('gray.600', 'gray.300');
const metaTextColor = useColorModeValue('gray.500', 'gray.500');
const hoverBgColor = useColorModeValue(
typeConfig.hoverBg,
typeConfig.darkHoverBg || `${typeConfig.colorScheme}.700`
);
const closeButtonHoverBgColor = useColorModeValue(
`${typeConfig.colorScheme}.200`,
`${typeConfig.colorScheme}.700`
);
// 使用 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]);
bg: priorityBgColor,
border: borderColor,
icon: iconColor,
text: textColor,
subText: subTextColor,
metaText: metaTextColor,
hoverBg: hoverBgColor,
closeButtonHoverBg: closeButtonHoverBgColor,
}), [priorityBgColor, borderColor, iconColor, textColor, subTextColor, metaTextColor, hoverBgColor, closeButtonHoverBgColor]);
// 点击处理(只有真正可点击时才执行)- 使用 useCallback 优化
const handleClick = useCallback(() => {
@@ -636,6 +646,11 @@ const NotificationContainer = () => {
}
}, [notifications]);
// ⚠️ 颜色配置 - 必须在条件return之前调用所有Hooks
const collapseBg = useColorModeValue('gray.100', 'gray.700');
const collapseHoverBg = useColorModeValue('gray.200', 'gray.600');
const collapseTextColor = useColorModeValue('gray.700', 'gray.200');
// 如果没有通知,不渲染
if (notifications.length === 0) {
return null;
@@ -647,11 +662,6 @@ const NotificationContainer = () => {
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');
// 构建无障碍描述
const containerAriaLabel = hasMore
? `通知中心,共有 ${notifications.length} 条通知,当前显示 ${visibleNotifications.length} 条,${isExpanded ? '已展开全部' : `还有 ${hiddenCount} 条折叠`}。使用Tab键导航Enter键或空格键查看详情。`

View File

@@ -4,7 +4,7 @@
* 用于手动测试4种通知类型
*/
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import {
Box,
Button,
@@ -16,8 +16,15 @@ import {
useDisclosure,
Badge,
Divider,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
Code,
UnorderedList,
ListItem,
} from '@chakra-ui/react';
import { MdNotifications, MdClose, MdVolumeOff, MdVolumeUp, MdCampaign, MdTrendingUp, MdArticle, MdAssessment } from 'react-icons/md';
import { MdNotifications, MdClose, MdVolumeOff, MdVolumeUp, MdCampaign, MdTrendingUp, MdArticle, MdAssessment, MdWarning } from 'react-icons/md';
import { useNotification } from '../../contexts/NotificationContext';
import { SOCKET_TYPE } from '../../services/socket';
import { NOTIFICATION_TYPES, PRIORITY_LEVELS } from '../../constants/notificationTypes';
@@ -27,6 +34,62 @@ const NotificationTestTool = () => {
const { addNotification, soundEnabled, toggleSound, isConnected, clearAllNotifications, notifications, browserPermission, requestBrowserPermission } = useNotification();
const [testCount, setTestCount] = useState(0);
// 测试状态
const [isTestingNotification, setIsTestingNotification] = useState(false);
const [testCountdown, setTestCountdown] = useState(0);
const [notificationShown, setNotificationShown] = useState(null); // null | true | false
// 系统环境检测
const [isFullscreen, setIsFullscreen] = useState(false);
const [isMacOS, setIsMacOS] = useState(false);
// 故障排查面板
const { isOpen: isTroubleshootOpen, onToggle: onTroubleshootToggle } = useDisclosure();
// 检测系统环境
useEffect(() => {
// 检测是否为 macOS
const platform = navigator.platform.toLowerCase();
setIsMacOS(platform.includes('mac'));
// 检测全屏状态
const checkFullscreen = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', checkFullscreen);
checkFullscreen();
return () => {
document.removeEventListener('fullscreenchange', checkFullscreen);
};
}, []);
// 倒计时逻辑
useEffect(() => {
if (testCountdown > 0) {
const timer = setTimeout(() => {
setTestCountdown(testCountdown - 1);
}, 1000);
return () => clearTimeout(timer);
} else if (testCountdown === 0 && isTestingNotification) {
// 倒计时结束,询问用户
setIsTestingNotification(false);
// 延迟一下再询问,确保用户有时间看到通知
setTimeout(() => {
const sawNotification = window.confirm('您是否看到了浏览器桌面通知?\n\n点击"确定"表示看到了\n点击"取消"表示没看到');
setNotificationShown(sawNotification);
if (!sawNotification) {
// 没看到通知,展开故障排查面板
if (!isTroubleshootOpen) {
onTroubleshootToggle();
}
}
}, 500);
}
}, [testCountdown, isTestingNotification, isTroubleshootOpen, onTroubleshootToggle]);
// 浏览器权限状态标签
const getPermissionLabel = () => {
switch (browserPermission) {
@@ -86,51 +149,6 @@ const NotificationTestTool = () => {
setTestCount(prev => prev + 1);
};
// 股票动向测试数据(涨)
const testStockAlertUp = () => {
addNotification({
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',
},
autoClose: 10000,
});
setTestCount(prev => prev + 1);
};
// 股票动向测试数据(跌)
const testStockAlertDown = () => {
addNotification({
type: NOTIFICATION_TYPES.STOCK_ALERT,
priority: PRIORITY_LEVELS.IMPORTANT,
title: '【测试】您关注的股票异常波动',
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 = () => {
@@ -179,30 +197,6 @@ const NotificationTestTool = () => {
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 = () => {
@@ -272,42 +266,11 @@ const NotificationTestTool = () => {
}, 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 (
<Box
position="fixed"
top="316px"
top="116px"
right={4}
zIndex={9998}
bg="white"
@@ -363,28 +326,6 @@ const NotificationTestTool = () => {
公告通知
</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"
@@ -396,27 +337,14 @@ const NotificationTestTool = () => {
</Button>
{/* 分析报告 */}
<HStack spacing={2}>
<Button
size="sm"
colorScheme="purple"
leftIcon={<MdAssessment />}
onClick={testAnalysisReport}
flex={1}
>
分析报告
</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="purple"
leftIcon={<MdAssessment />}
onClick={testAnalysisReport}
>
分析报告
</Button>
{/* 预测通知 */}
<Button
@@ -434,24 +362,6 @@ const NotificationTestTool = () => {
组合测试
</Text>
{/* 层叠测试 */}
<Button
size="sm"
colorScheme="teal"
onClick={testAllTypes}
>
层叠测试4种类型
</Button>
{/* 优先级测试 */}
<Button
size="sm"
colorScheme="pink"
onClick={testPriority}
>
优先级测试3个级别
</Button>
{/* 预测→详情流程测试 */}
<Button
size="sm"
@@ -479,6 +389,93 @@ const NotificationTestTool = () => {
</Button>
)}
{/* 测试浏览器通知按钮 */}
{browserPermission === 'granted' && (
<Button
size="sm"
colorScheme="green"
leftIcon={<MdNotifications />}
onClick={() => {
console.log('测试浏览器通知按钮被点击');
console.log('Notification support:', 'Notification' in window);
console.log('Notification permission:', Notification?.permission);
console.log('Platform:', navigator.platform);
console.log('Fullscreen:', !!document.fullscreenElement);
// 直接使用原生 Notification API 测试
if (!('Notification' in window)) {
alert('您的浏览器不支持桌面通知');
return;
}
if (Notification.permission !== 'granted') {
alert('浏览器通知权限未授予\n当前权限状态' + Notification.permission);
return;
}
// 重置状态
setNotificationShown(null);
setIsTestingNotification(true);
setTestCountdown(8); // 8秒倒计时
try {
console.log('正在创建浏览器通知...');
const notification = new Notification('【测试】浏览器通知测试', {
body: '如果您看到这条系统级通知,说明浏览器通知功能正常工作',
icon: '/logo192.png',
badge: '/badge.png',
tag: 'test_notification_' + Date.now(),
requireInteraction: false,
});
console.log('浏览器通知创建成功:', notification);
// 监听通知显示(成功显示)
notification.onshow = () => {
console.log('✅ 浏览器通知已显示onshow 事件触发)');
setNotificationShown(true);
};
// 监听通知错误
notification.onerror = (error) => {
console.error('❌ 浏览器通知错误:', error);
setNotificationShown(false);
};
// 监听通知关闭
notification.onclose = () => {
console.log('浏览器通知已关闭');
};
// 8秒后自动关闭
setTimeout(() => {
notification.close();
console.log('浏览器通知已自动关闭');
}, 8000);
// 点击通知时聚焦窗口
notification.onclick = () => {
console.log('浏览器通知被点击');
window.focus();
notification.close();
setNotificationShown(true);
};
setTestCount(prev => prev + 1);
} catch (error) {
console.error('创建浏览器通知失败:', error);
alert('创建浏览器通知失败:' + error.message);
setIsTestingNotification(false);
setNotificationShown(false);
}
}}
isLoading={isTestingNotification}
loadingText={`等待通知... ${testCountdown}s`}
>
{isTestingNotification ? `等待通知... ${testCountdown}s` : '测试浏览器通知(直接)'}
</Button>
)}
{/* 浏览器通知状态说明 */}
{browserPermission === 'granted' && (
<Text fontSize="xs" color="green.500">
@@ -491,6 +488,136 @@ const NotificationTestTool = () => {
</Text>
)}
{/* 实时权限状态 */}
<HStack spacing={2} justify="center">
<Text fontSize="xs" color="gray.500">
实际权限
</Text>
<Badge
colorScheme={
('Notification' in window && Notification.permission === 'granted') ? 'green' :
('Notification' in window && Notification.permission === 'denied') ? 'red' : 'gray'
}
>
{('Notification' in window) ? Notification.permission : '不支持'}
</Badge>
</HStack>
{/* 环境警告 */}
{isFullscreen && (
<Alert status="warning" size="sm" borderRadius="md">
<AlertIcon />
<Box fontSize="xs">
<Text fontWeight="bold">全屏模式</Text>
<Text>某些浏览器在全屏模式下不显示通知</Text>
</Box>
</Alert>
)}
{isMacOS && notificationShown === false && (
<Alert status="error" size="sm" borderRadius="md">
<AlertIcon />
<Box fontSize="xs">
<Text fontWeight="bold">未检测到通知显示</Text>
<Text>可能是专注模式阻止了通知</Text>
</Box>
</Alert>
)}
<Divider />
{/* 故障排查面板 */}
<VStack spacing={2} align="stretch">
<Button
size="sm"
variant="outline"
colorScheme="orange"
leftIcon={<MdWarning />}
onClick={onTroubleshootToggle}
>
{isTroubleshootOpen ? '收起' : '故障排查指南'}
</Button>
<Collapse in={isTroubleshootOpen} animateOpacity>
<VStack spacing={3} align="stretch" p={3} bg="orange.50" borderRadius="md">
<Text fontSize="xs" fontWeight="bold" color="orange.800">
如果看不到浏览器通知请检查
</Text>
{/* macOS 专注模式 */}
{isMacOS && (
<Alert status="warning" size="sm">
<AlertIcon />
<Box fontSize="xs">
<AlertTitle fontSize="xs">macOS 专注模式</AlertTitle>
<AlertDescription>
<UnorderedList spacing={1} mt={1}>
<ListItem>点击右上角控制中心</ListItem>
<ListItem>关闭专注模式勿扰模式</ListItem>
<ListItem>或者系统设置 专注模式 关闭</ListItem>
</UnorderedList>
</AlertDescription>
</Box>
</Alert>
)}
{/* macOS 系统通知设置 */}
{isMacOS && (
<Alert status="info" size="sm">
<AlertIcon />
<Box fontSize="xs">
<AlertTitle fontSize="xs">macOS 系统通知设置</AlertTitle>
<AlertDescription>
<UnorderedList spacing={1} mt={1}>
<ListItem>系统设置 通知</ListItem>
<ListItem>找到 <Code fontSize="xs">Google Chrome</Code> <Code fontSize="xs">Microsoft Edge</Code></ListItem>
<ListItem>确保允许通知已开启</ListItem>
<ListItem>通知样式设置为横幅提醒</ListItem>
</UnorderedList>
</AlertDescription>
</Box>
</Alert>
)}
{/* Chrome 浏览器设置 */}
<Alert status="info" size="sm">
<AlertIcon />
<Box fontSize="xs">
<AlertTitle fontSize="xs">Chrome 浏览器设置</AlertTitle>
<AlertDescription>
<UnorderedList spacing={1} mt={1}>
<ListItem>地址栏输入: <Code fontSize="xs">chrome://settings/content/notifications</Code></ListItem>
<ListItem>确保网站可以请求发送通知已开启</ListItem>
<ListItem>检查本站点是否在允许列表中</ListItem>
</UnorderedList>
</AlertDescription>
</Box>
</Alert>
{/* 全屏模式提示 */}
{isFullscreen && (
<Alert status="warning" size="sm">
<AlertIcon />
<Box fontSize="xs">
<AlertTitle fontSize="xs">退出全屏模式</AlertTitle>
<AlertDescription>
<Code fontSize="xs">ESC</Code> 退
</AlertDescription>
</Box>
</Alert>
)}
{/* 测试结果反馈 */}
{notificationShown === true && (
<Alert status="success" size="sm">
<AlertIcon />
<Text fontSize="xs"> 通知功能正常</Text>
</Alert>
)}
</VStack>
</Collapse>
</VStack>
<Divider />
{/* 功能按钮 */}

View File

@@ -1,5 +1,5 @@
// src/components/ProtectedRoute.js - 弹窗拦截版本
import React, { useEffect } from 'react';
import React, { useEffect, useRef } from 'react';
import { Box, VStack, Spinner, Text } from '@chakra-ui/react';
import { useAuth } from '../contexts/AuthContext';
import { useAuthModal } from '../contexts/AuthModalContext';
@@ -8,15 +8,17 @@ const ProtectedRoute = ({ children }) => {
const { isAuthenticated, isLoading, user } = useAuth();
const { openAuthModal, isAuthModalOpen } = useAuthModal();
// 记录当前路径,登录成功后可以跳转回来
const currentPath = window.location.pathname + window.location.search;
// ⚡ 使用 useRef 保存当前路径,避免每次渲染创建新字符串导致 useEffect 无限循环
const currentPathRef = useRef(window.location.pathname + window.location.search);
// 未登录时自动弹出认证窗口
useEffect(() => {
if (!isLoading && !isAuthenticated && !user && !isAuthModalOpen) {
openAuthModal(currentPath);
openAuthModal(currentPathRef.current);
}
}, [isAuthenticated, user, isLoading, isAuthModalOpen, currentPath, openAuthModal]);
// ⚠️ 移除 user 依赖,因为 user 对象每次从 API 返回都是新引用,会导致无限循环
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated, isLoading, isAuthModalOpen, openAuthModal]);
// 显示加载状态
if (isLoading) {

View File

@@ -0,0 +1,77 @@
// src/constants/importanceLevels.js
// 事件重要性等级配置
import {
WarningIcon,
WarningTwoIcon,
InfoIcon,
CheckCircleIcon,
} from '@chakra-ui/icons';
/**
* 重要性等级配置
* 用于事件列表展示和重要性说明
*/
export const IMPORTANCE_LEVELS = {
'S': {
level: 'S',
color: 'purple.600',
bgColor: 'purple.50',
borderColor: 'purple.200',
icon: WarningIcon,
label: '极高',
dotBg: 'purple.500',
description: '重大事件,市场影响深远',
antdColor: '#722ed1', // 对应 Ant Design 的紫色
},
'A': {
level: 'A',
color: 'red.600',
bgColor: 'red.50',
borderColor: 'red.200',
icon: WarningTwoIcon,
label: '高',
dotBg: 'red.500',
description: '重要事件,影响较大',
antdColor: '#ff4d4f', // 对应 Ant Design 的红色
},
'B': {
level: 'B',
color: 'orange.600',
bgColor: 'orange.50',
borderColor: 'orange.200',
icon: InfoIcon,
label: '中',
dotBg: 'orange.500',
description: '普通事件,有一定影响',
antdColor: '#faad14', // 对应 Ant Design 的橙色
},
'C': {
level: 'C',
color: 'green.600',
bgColor: 'green.50',
borderColor: 'green.200',
icon: CheckCircleIcon,
label: '低',
dotBg: 'green.500',
description: '参考事件,影响有限',
antdColor: '#52c41a', // 对应 Ant Design 的绿色
}
};
/**
* 获取重要性等级配置
* @param {string} importance - 重要性等级 (S/A/B/C)
* @returns {Object} 重要性配置对象
*/
export const getImportanceConfig = (importance) => {
return IMPORTANCE_LEVELS[importance] || IMPORTANCE_LEVELS['C'];
};
/**
* 获取所有等级配置(用于说明列表)
* @returns {Array} 所有等级配置数组
*/
export const getAllImportanceLevels = () => {
return Object.values(IMPORTANCE_LEVELS);
};

View File

@@ -3,6 +3,7 @@ import React, { createContext, useContext, useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useToast } from '@chakra-ui/react';
import { logger } from '../utils/logger';
import { useNotification } from '../contexts/NotificationContext';
// 创建认证上下文
const AuthContext = createContext();
@@ -23,6 +24,10 @@ export const AuthProvider = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const navigate = useNavigate();
const toast = useToast();
const { showWelcomeGuide } = useNotification();
// ⚡ 使用 ref 保存最新的 isAuthenticated 值,避免事件监听器重复注册
const isAuthenticatedRef = React.useRef(isAuthenticated);
// 检查Session状态
const checkSession = async () => {
@@ -55,19 +60,27 @@ export const AuthProvider = ({ children }) => {
});
if (data.isAuthenticated && data.user) {
setUser(data.user);
setIsAuthenticated(true);
// ⚡ 只在 user 数据真正变化时才更新状态,避免无限循环
setUser((prevUser) => {
// 比较用户 ID如果相同则不更新
if (prevUser && prevUser.id === data.user.id) {
return prevUser;
}
return data.user;
});
setIsAuthenticated((prev) => prev === true ? prev : true);
} else {
setUser(null);
setIsAuthenticated(false);
setUser((prev) => prev === null ? prev : null);
setIsAuthenticated((prev) => prev === false ? prev : false);
}
} catch (error) {
logger.error('AuthContext', 'checkSession', error);
// 网络错误或超时,设置为未登录状态
setUser(null);
setIsAuthenticated(false);
setUser((prev) => prev === null ? prev : null);
setIsAuthenticated((prev) => prev === false ? prev : false);
} finally {
setIsLoading(false);
// ⚡ 只在 isLoading 为 true 时才设置为 false避免不必要的状态更新
setIsLoading((prev) => prev === false ? prev : false);
}
};
@@ -77,11 +90,17 @@ export const AuthProvider = ({ children }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ⚡ 同步 isAuthenticated 到 ref
useEffect(() => {
isAuthenticatedRef.current = isAuthenticated;
}, [isAuthenticated]);
// 监听路由变化检查session处理微信登录回调
// ⚡ 移除 isAuthenticated 依赖,使用 ref 避免重复注册事件监听器
useEffect(() => {
const handleRouteChange = () => {
// 如果是从微信回调返回的重新检查session
if (window.location.pathname === '/home' && !isAuthenticated) {
// 使用 ref 获取最新的认证状态
if (window.location.pathname === '/home' && !isAuthenticatedRef.current) {
checkSession();
}
};
@@ -89,7 +108,7 @@ export const AuthProvider = ({ children }) => {
window.addEventListener('popstate', handleRouteChange);
return () => window.removeEventListener('popstate', handleRouteChange);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated]);
}, []); // ✅ 空依赖数组,只注册一次事件监听器
// 更新本地用户的便捷方法
const updateUser = (partial) => {
@@ -156,6 +175,11 @@ export const AuthProvider = ({ children }) => {
// isClosable: true,
// });
// ⚡ 登录成功后显示欢迎引导延迟2秒避免与登录Toast冲突
setTimeout(() => {
showWelcomeGuide();
}, 2000);
return { success: true };
} catch (error) {
@@ -203,6 +227,11 @@ export const AuthProvider = ({ children }) => {
isClosable: true,
});
// ⚡ 注册成功后显示欢迎引导延迟2秒
setTimeout(() => {
showWelcomeGuide();
}, 2000);
return { success: true };
} catch (error) {
@@ -252,6 +281,11 @@ export const AuthProvider = ({ children }) => {
isClosable: true,
});
// ⚡ 注册成功后显示欢迎引导延迟2秒
setTimeout(() => {
showWelcomeGuide();
}, 2000);
return { success: true };
} catch (error) {
@@ -299,6 +333,11 @@ export const AuthProvider = ({ children }) => {
isClosable: true,
});
// ⚡ 注册成功后显示欢迎引导延迟2秒
setTimeout(() => {
showWelcomeGuide();
}, 2000);
return { success: true };
} catch (error) {

View File

@@ -0,0 +1,51 @@
// src/contexts/IndustryContext.js
// 行业分类数据全局上下文 - 直接使用静态数据
import React, { createContext, useContext } from 'react';
import { industryData as staticIndustryData } from '../data/industryData';
import { logger } from '../utils/logger';
const IndustryContext = createContext();
/**
* useIndustry Hook
* 在任何组件中使用行业数据
*/
export const useIndustry = () => {
const context = useContext(IndustryContext);
if (!context) {
throw new Error('useIndustry must be used within IndustryProvider');
}
return context;
};
/**
* IndustryProvider 组件
* 提供全局行业数据管理 - 直接使用静态数据,无需加载
*/
export const IndustryProvider = ({ children }) => {
// 直接使用静态数据,无需状态管理
const industryData = staticIndustryData;
logger.debug('IndustryContext', '使用静态行业数据', {
count: industryData?.length || 0
});
const value = {
industryData, // 行业数据(静态)
loading: false, // 静态数据无需加载
error: null, // 静态数据无错误
loadIndustryData: () => { // 兼容旧接口,返回数据
return Promise.resolve(industryData);
},
refreshIndustryData: () => { // 兼容旧接口,返回数据
return Promise.resolve(industryData);
}
};
return (
<IndustryContext.Provider value={value}>
{children}
</IndustryContext.Provider>
);
};

View File

@@ -60,6 +60,8 @@ export const NotificationProvider = ({ children }) => {
const [maxReconnectAttempts, setMaxReconnectAttempts] = useState(Infinity);
const audioRef = useRef(null);
const reconnectedTimerRef = useRef(null); // 用于自动消失 RECONNECTED 状态
const processedEventIds = useRef(new Set()); // 用于Socket层去重记录已处理的事件ID
const MAX_PROCESSED_IDS = 1000; // 最多保留1000个ID避免内存泄漏
// ⚡ 使用权限引导管理 Hook
const { shouldShowGuide, markGuideAsShown } = usePermissionGuide();
@@ -435,12 +437,23 @@ export const NotificationProvider = ({ children }) => {
* @param {object} notification - 通知对象
*/
const addNotification = useCallback(async (notification) => {
// ========== 显示层去重检查 ==========
const notificationId = notification.id || `notif_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// 检查当前显示队列中是否已存在该通知
const isDuplicate = notifications.some(n => n.id === notificationId);
if (isDuplicate) {
logger.debug('NotificationContext', 'Duplicate notification ignored at display level', { id: notificationId });
return notificationId; // 返回ID但不显示
}
// ========== 显示层去重检查结束 ==========
// 根据优先级获取自动关闭时长
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)}`,
id: notificationId, // 使用预先生成的ID
type: notification.type || 'info',
severity: notification.severity || 'info',
title: notification.title || '通知',
@@ -453,94 +466,108 @@ export const NotificationProvider = ({ children }) => {
logger.info('NotificationContext', 'Adding notification', newNotification);
// ========== 智能权限请求策略 ==========
// 首次收到重要/紧急通知时,自动请求桌面通知权限
if (priority === PRIORITY_LEVELS.URGENT || priority === PRIORITY_LEVELS.IMPORTANT) {
if (browserPermission === 'default' && !hasRequestedPermission) {
logger.info('NotificationContext', 'First important notification, requesting browser permission');
await requestBrowserPermission();
}
// 如果权限被拒绝,提示用户可以开启
else if (browserPermission === 'denied' && hasRequestedPermission) {
// 显示带"开启"按钮的 Toast仅重要/紧急通知)
const toastId = 'enable-notification-toast';
if (!toast.isActive(toastId)) {
toast({
id: toastId,
title: newNotification.title,
description: '💡 开启桌面通知以便后台接收',
status: 'warning',
duration: 10000,
isClosable: true,
position: 'top',
render: ({ onClose }) => (
<Box
p={4}
bg="orange.500"
color="white"
borderRadius="md"
boxShadow="lg"
>
<HStack spacing={3} align="start">
<Box flex={1}>
<Text fontWeight="bold" mb={1}>
{newNotification.title}
</Text>
<Text fontSize="sm" opacity={0.9}>
💡 开启桌面通知以便后台接收
</Text>
</Box>
<Button
size="sm"
colorScheme="whiteAlpha"
onClick={() => {
requestBrowserPermission();
onClose();
}}
>
开启
</Button>
<CloseButton onClick={onClose} />
// ========== 增强权限请求策略 ==========
// 只要收到通知,就检查并提示用户授权
// 如果权限是default未授权自动请求
if (browserPermission === 'default' && !hasRequestedPermission) {
logger.info('NotificationContext', 'Auto-requesting browser permission on notification');
await requestBrowserPermission();
}
// 如果权限是denied已拒绝提供设置指引
else if (browserPermission === 'denied') {
const toastId = 'browser-permission-denied-guide';
if (!toast.isActive(toastId)) {
toast({
id: toastId,
duration: 12000,
isClosable: true,
position: 'top',
render: ({ onClose }) => (
<Box
p={4}
bg="orange.500"
color="white"
borderRadius="md"
boxShadow="lg"
maxW="400px"
>
<VStack spacing={3} align="stretch">
<HStack spacing={2}>
<Icon as={BellIcon} boxSize={5} />
<Text fontWeight="bold" fontSize="md">
浏览器通知已被拒绝
</Text>
</HStack>
</Box>
),
});
}
<Text fontSize="sm" opacity={0.9}>
{newNotification.title}
</Text>
<Text fontSize="xs" opacity={0.8}>
💡 如需接收桌面通知请在浏览器设置中允许通知权限
</Text>
<VStack spacing={1} align="start" fontSize="xs" opacity={0.7}>
<Text>Chrome: 地址栏左侧 🔒 网站设置 通知</Text>
<Text>Safari: 偏好设置 网站 通知</Text>
<Text>Edge: 地址栏右侧 网站权限 通知</Text>
</VStack>
<Button
size="sm"
variant="ghost"
colorScheme="whiteAlpha"
onClick={onClose}
alignSelf="flex-end"
>
知道了
</Button>
</VStack>
</Box>
),
});
}
}
const isPageHidden = document.hidden; // 页面是否在后台
// ========== 智能分发策略 ==========
// ========== 分发策略(按优先级区分)- 已废弃 ==========
// 策略 1: 紧急通知 - 双重保障(浏览器 + 网页)
if (priority === PRIORITY_LEVELS.URGENT) {
logger.info('NotificationContext', 'Urgent notification: sending browser + web');
// 总是发送浏览器通知
sendBrowserNotification(newNotification);
// 如果在前台,也显示网页通知
if (!isPageHidden) {
addWebNotification(newNotification);
}
}
// 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);
}
}
// 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');
// else {
// logger.info('NotificationContext', 'Normal notification: sending web only');
// addWebNotification(newNotification);
// }
// ========== 新分发策略(仅区分前后台) ==========
if (isPageHidden) {
// 页面在后台:发送浏览器通知
logger.info('NotificationContext', 'Page hidden: sending browser notification');
sendBrowserNotification(newNotification);
} else {
// 页面在前台:发送网页通知
logger.info('NotificationContext', 'Page visible: sending web notification');
addWebNotification(newNotification);
}
return newNotification.id;
}, [sendBrowserNotification, addWebNotification, browserPermission, hasRequestedPermission, requestBrowserPermission]);
}, [notifications, toast, sendBrowserNotification, addWebNotification, browserPermission, hasRequestedPermission, requestBrowserPermission]);
// 连接到 Socket 服务
useEffect(() => {
@@ -624,6 +651,27 @@ export const NotificationProvider = ({ children }) => {
socket.on('new_event', (data) => {
logger.info('NotificationContext', 'Received new event', data);
// ========== Socket层去重检查 ==========
const eventId = data.id || `${data.type}_${data.publishTime}`;
if (processedEventIds.current.has(eventId)) {
logger.debug('NotificationContext', 'Duplicate event ignored at socket level', { eventId });
return; // 重复事件,直接忽略
}
// 记录已处理的事件ID
processedEventIds.current.add(eventId);
// 限制Set大小避免内存泄漏
if (processedEventIds.current.size > MAX_PROCESSED_IDS) {
const idsArray = Array.from(processedEventIds.current);
processedEventIds.current = new Set(idsArray.slice(-MAX_PROCESSED_IDS));
logger.debug('NotificationContext', 'Cleaned up old processed event IDs', {
kept: MAX_PROCESSED_IDS
});
}
// ========== Socket层去重检查结束 ==========
// 使用适配器转换事件格式
const notification = adaptEventToNotification(data);
addNotification(notification);

4346
src/data/industryData.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -545,3 +545,236 @@ export function getEventRelatedStocks(eventId) {
const count = 3 + (parseInt(eventId) % 4);
return generateRelatedStocks(eventId, count);
}
// ==================== Mock 事件列表数据 ====================
// 事件类型池
const eventTypes = ['政策发布', '行业动向', '公司公告', '市场研判', '技术突破', '财报发布', '投融资', '高管变动'];
// 行业池
const industries = ['半导体', '新能源', '人工智能', '医药', '消费', '金融', '房地产', '通信', '互联网', '军工', '化工', '机械'];
// 事件标题模板
const eventTitleTemplates = [
'{industry}行业迎来重大政策利好',
'{company}发布{quarter}财报,业绩超预期',
'{industry}板块集体大涨,{company}涨停',
'央行宣布{policy},影响{industry}行业',
'{company}与{partner}达成战略合作',
'{industry}技术取得重大突破',
'{company}拟投资{amount}亿元布局{industry}',
'国家发改委:支持{industry}产业发展',
'{industry}龙头{company}涨价{percent}%',
'{company}回购股份,彰显信心',
];
// 生成随机公司名
function generateCompanyName(industry) {
const prefixes = ['华为', '中兴', '阿里', '腾讯', '比亚迪', '宁德时代', '隆基', '恒瑞', '茅台', '五粮液', '海康', '中芯'];
const suffixes = ['科技', '集团', '股份', '控股', '实业', ''];
const prefix = prefixes[Math.floor(Math.random() * prefixes.length)];
const suffix = suffixes[Math.floor(Math.random() * suffixes.length)];
return `${prefix}${suffix}`;
}
// 生成事件标题
function generateEventTitle(industry, seed) {
const template = eventTitleTemplates[seed % eventTitleTemplates.length];
return template
.replace('{industry}', industry)
.replace('{company}', generateCompanyName(industry))
.replace('{partner}', generateCompanyName(industry))
.replace('{quarter}', ['一季度', '半年度', '三季度', '年度'][seed % 4])
.replace('{policy}', ['降准0.5%', '降息25BP', 'MLF下调', '提高赤字率'][seed % 4])
.replace('{amount}', [50, 100, 200, 500][seed % 4])
.replace('{percent}', [5, 10, 15, 20][seed % 4]);
}
// 生成事件描述
function generateEventDescription(industry, importance, seed) {
const impacts = {
S: '重大利好预计将对行业格局产生深远影响相关概念股有望持续受益。机构预计该事件将带动行业整体估值提升15-20%,龙头企业市值增长空间广阔。',
A: '重要利好,市场情绪积极,短期内资金流入明显。分析师普遍认为该事件将推动行业景气度上行,相关公司业绩有望超预期增长。',
B: '中性偏好,对部分细分领域有一定促进作用。虽然不是行业性机会,但优质标的仍有结构性行情,建议关注业绩确定性强的公司。',
C: '影响有限,市场反应平淡,但长期来看仍有积极意义。事件对行业发展方向有指引作用,关注后续政策跟进和落地情况。',
};
const details = [
`根据最新消息,${industry}领域将获得新一轮政策支持,产业链相关企业订单饱满。`,
`${industry}板块近期表现活跃,多只个股创出年内新高,资金持续流入。`,
`行业专家指出,${industry}产业正处于高速发展期,市场空间广阔,龙头企业优势明显。`,
`券商研报显示,${industry}行业估值处于历史低位,当前具备较高配置价值。`,
];
return impacts[importance] + details[seed % details.length];
}
// 生成关键词
function generateKeywords(industry, seed) {
const commonKeywords = ['政策', '利好', '业绩', '涨停', '龙头', '突破', '合作', '投资'];
const industryKeywords = {
'半导体': ['芯片', '晶圆', '封测', 'AI芯片', '国产替代'],
'新能源': ['电池', '光伏', '储能', '新能源车', '锂电'],
'人工智能': ['大模型', 'AI应用', '算力', '数据', '机器学习'],
'医药': ['创新药', 'CRO', '医疗器械', '生物制药', '仿制药'],
'消费': ['白酒', '食品', '家电', '零售', '免税'],
};
const keywords = [
...commonKeywords.slice(seed % 3, seed % 3 + 3),
...(industryKeywords[industry] || []).slice(0, 2)
];
return keywords.slice(0, 5);
}
/**
* 生成 Mock 事件列表
* @param {Object} params - 查询参数
* @returns {Object} - {events: [], pagination: {}}
*/
export function generateMockEvents(params = {}) {
const {
page = 1,
per_page = 10,
sort = 'new',
importance = 'all',
date_range = '',
q = '',
industry_code = '',
} = params;
// 生成100个事件用于测试
const totalEvents = 100;
const allEvents = [];
const importanceLevels = ['S', 'A', 'B', 'C'];
const baseDate = new Date('2025-01-15');
for (let i = 0; i < totalEvents; i++) {
const industry = industries[i % industries.length];
const imp = importanceLevels[i % importanceLevels.length];
const eventType = eventTypes[i % eventTypes.length];
// 生成随机日期最近30天内
const createdAt = new Date(baseDate);
createdAt.setDate(createdAt.getDate() - (i % 30));
// 生成随机热度和收益率
const hotScore = Math.max(50, 100 - i);
const relatedAvgChg = (Math.random() * 20 - 5).toFixed(2); // -5% 到 15%
const relatedMaxChg = (Math.random() * 30).toFixed(2); // 0% 到 30%
allEvents.push({
id: i + 1,
title: generateEventTitle(industry, i),
description: generateEventDescription(industry, imp, i),
content: generateEventDescription(industry, imp, i),
event_type: eventType,
importance: imp,
status: 'published',
created_at: createdAt.toISOString(),
updated_at: createdAt.toISOString(),
hot_score: hotScore,
view_count: Math.floor(Math.random() * 10000),
related_avg_chg: parseFloat(relatedAvgChg),
related_max_chg: parseFloat(relatedMaxChg),
keywords: generateKeywords(industry, i),
is_ai_generated: i % 4 === 0, // 25% 的事件是AI生成
industry: industry,
});
}
// 筛选
let filteredEvents = allEvents;
// 重要性筛选
if (importance && importance !== 'all') {
filteredEvents = filteredEvents.filter(e => e.importance === importance);
}
// 关键词搜索
if (q) {
const query = q.toLowerCase();
filteredEvents = filteredEvents.filter(e =>
e.title.toLowerCase().includes(query) ||
e.description.toLowerCase().includes(query) ||
e.keywords.some(k => k.toLowerCase().includes(query))
);
}
// 行业筛选
if (industry_code) {
filteredEvents = filteredEvents.filter(e =>
e.industry.includes(industry_code) || e.keywords.includes(industry_code)
);
}
// 日期范围筛选
if (date_range) {
const [startStr, endStr] = date_range.split(' 至 ');
if (startStr && endStr) {
const start = new Date(startStr);
const end = new Date(endStr);
filteredEvents = filteredEvents.filter(e => {
const eventDate = new Date(e.created_at);
return eventDate >= start && eventDate <= end;
});
}
}
// 排序
if (sort === 'hot') {
filteredEvents.sort((a, b) => b.hot_score - a.hot_score);
} else if (sort === 'returns') {
filteredEvents.sort((a, b) => b.related_avg_chg - a.related_avg_chg);
} else {
// 默认按时间排序 (new)
filteredEvents.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
}
// 分页
const start = (page - 1) * per_page;
const end = start + per_page;
const paginatedEvents = filteredEvents.slice(start, end);
return {
events: paginatedEvents,
pagination: {
page: page,
per_page: per_page,
total: filteredEvents.length,
total_pages: Math.ceil(filteredEvents.length / per_page),
},
};
}
/**
* 生成热点事件
* @param {number} limit - 返回数量
* @returns {Array} - 热点事件列表
*/
export function generateHotEvents(limit = 5) {
const { events } = generateMockEvents({ sort: 'hot', per_page: limit });
return events;
}
/**
* 生成热门关键词
* @param {number} limit - 返回数量
* @returns {Array} - 热门关键词列表
*/
export function generatePopularKeywords(limit = 20) {
const allKeywords = [
'人工智能', '芯片', '新能源', '锂电池', '光伏', '储能',
'消费', '白酒', '医药', 'CRO', '半导体', '国产替代',
'军工', '航空', '5G', '通信', '互联网', '云计算',
'大数据', '区块链', '元宇宙', '新基建', '数字经济',
];
return allKeywords.slice(0, limit).map((keyword, index) => ({
keyword,
count: Math.max(10, 100 - index * 3),
trend: index % 3 === 0 ? 'up' : index % 3 === 1 ? 'down' : 'stable',
}));
}

View File

@@ -0,0 +1,554 @@
// src/mocks/data/industries.js
// 行业分类完整树形数据 Mock
/**
* 完整的行业分类树形结构
* 包含 5 个分类体系,层级深度 2-4 层不等
*/
export const industryTreeData = [
{
value: "新财富行业分类",
label: "新财富行业分类",
children: [
{
value: "XCF001",
label: "传播与文化",
children: [
{
value: "XCF001001",
label: "互联网传媒",
children: [
{ value: "XCF001001001", label: "数字媒体" },
{ value: "XCF001001002", label: "社交平台" },
{ value: "XCF001001003", label: "短视频平台" }
]
},
{
value: "XCF001002",
label: "影视娱乐",
children: [
{ value: "XCF001002001", label: "电影制作" },
{ value: "XCF001002002", label: "网络视频" }
]
},
{
value: "XCF001003",
label: "出版发行"
}
]
},
{
value: "XCF002",
label: "交通运输仓储",
children: [
{
value: "XCF002001",
label: "航空运输",
children: [
{ value: "XCF002001001", label: "航空客运" },
{ value: "XCF002001002", label: "航空货运" }
]
},
{
value: "XCF002002",
label: "铁路运输"
},
{
value: "XCF002003",
label: "公路运输",
children: [
{ value: "XCF002003001", label: "公路客运" },
{ value: "XCF002003002", label: "公路货运" },
{ value: "XCF002003003", label: "快递物流" }
]
}
]
},
{
value: "XCF003",
label: "农林牧渔",
children: [
{ value: "XCF003001", label: "种植业" },
{ value: "XCF003002", label: "林业" },
{ value: "XCF003003", label: "畜牧业" },
{ value: "XCF003004", label: "渔业" }
]
},
{
value: "XCF004",
label: "医药生物",
children: [
{
value: "XCF004001",
label: "化学制药",
children: [
{ value: "XCF004001001", label: "化学原料药" },
{ value: "XCF004001002", label: "化学制剂" }
]
},
{
value: "XCF004002",
label: "生物制品",
children: [
{ value: "XCF004002001", label: "疫苗" },
{ value: "XCF004002002", label: "血液制品" },
{ value: "XCF004002003", label: "诊断试剂" }
]
},
{ value: "XCF004003", label: "中药" },
{ value: "XCF004004", label: "医疗器械" }
]
},
{
value: "XCF005",
label: "基础化工",
children: [
{ value: "XCF005001", label: "化学原料" },
{ value: "XCF005002", label: "化学制品" },
{ value: "XCF005003", label: "塑料" },
{ value: "XCF005004", label: "橡胶" }
]
},
{
value: "XCF006",
label: "家电",
children: [
{ value: "XCF006001", label: "白色家电" },
{ value: "XCF006002", label: "黑色家电" },
{ value: "XCF006003", label: "小家电" }
]
},
{
value: "XCF007",
label: "电子",
children: [
{
value: "XCF007001",
label: "半导体",
children: [
{ value: "XCF007001001", label: "芯片设计" },
{ value: "XCF007001002", label: "芯片制造" },
{ value: "XCF007001003", label: "封装测试" }
]
},
{ value: "XCF007002", label: "元件" },
{ value: "XCF007003", label: "光学光电子" },
{ value: "XCF007004", label: "消费电子" }
]
},
{
value: "XCF008",
label: "计算机",
children: [
{
value: "XCF008001",
label: "计算机设备",
children: [
{ value: "XCF008001001", label: "PC" },
{ value: "XCF008001002", label: "服务器" }
]
},
{
value: "XCF008002",
label: "软件开发",
children: [
{ value: "XCF008002001", label: "应用软件" },
{ value: "XCF008002002", label: "系统软件" }
]
},
{ value: "XCF008003", label: "IT服务" }
]
}
]
},
{
value: "申银万国行业分类",
label: "申银万国行业分类",
children: [
{
value: "SW001",
label: "电子",
children: [
{
value: "SW001001",
label: "半导体",
children: [
{ value: "SW001001001", label: "半导体材料" },
{ value: "SW001001002", label: "半导体设备" },
{ value: "SW001001003", label: "集成电路" }
]
},
{
value: "SW001002",
label: "电子制造",
children: [
{ value: "SW001002001", label: "PCB" },
{ value: "SW001002002", label: "被动元件" }
]
},
{ value: "SW001003", label: "光学光电子" }
]
},
{
value: "SW002",
label: "计算机",
children: [
{ value: "SW002001", label: "计算机设备" },
{ value: "SW002002", label: "计算机应用" },
{ value: "SW002003", label: "通信设备" }
]
},
{
value: "SW003",
label: "传媒",
children: [
{ value: "SW003001", label: "互联网传媒" },
{ value: "SW003002", label: "营销传播" },
{ value: "SW003003", label: "文化传媒" }
]
},
{
value: "SW004",
label: "医药生物",
children: [
{ value: "SW004001", label: "化学制药" },
{ value: "SW004002", label: "中药" },
{ value: "SW004003", label: "生物制品" },
{ value: "SW004004", label: "医疗器械" },
{ value: "SW004005", label: "医药商业" }
]
},
{
value: "SW005",
label: "汽车",
children: [
{
value: "SW005001",
label: "乘用车",
children: [
{ value: "SW005001001", label: "燃油车" },
{ value: "SW005001002", label: "新能源车" }
]
},
{ value: "SW005002", label: "商用车" },
{ value: "SW005003", label: "汽车零部件" }
]
},
{
value: "SW006",
label: "机械设备",
children: [
{ value: "SW006001", label: "通用设备" },
{ value: "SW006002", label: "专用设备" },
{ value: "SW006003", label: "仪器仪表" }
]
},
{
value: "SW007",
label: "食品饮料",
children: [
{ value: "SW007001", label: "白酒" },
{ value: "SW007002", label: "啤酒" },
{ value: "SW007003", label: "软饮料" },
{ value: "SW007004", label: "食品加工" }
]
},
{
value: "SW008",
label: "银行",
children: [
{ value: "SW008001", label: "国有银行" },
{ value: "SW008002", label: "股份制银行" },
{ value: "SW008003", label: "城商行" }
]
},
{
value: "SW009",
label: "非银金融",
children: [
{ value: "SW009001", label: "证券" },
{ value: "SW009002", label: "保险" },
{ value: "SW009003", label: "多元金融" }
]
},
{
value: "SW010",
label: "房地产",
children: [
{ value: "SW010001", label: "房地产开发" },
{ value: "SW010002", label: "房地产服务" }
]
}
]
},
{
value: "证监会行业分类2001",
label: "证监会行业分类2001",
children: [
{
value: "CSRC_A",
label: "A 农、林、牧、渔业",
children: [
{ value: "CSRC_A01", label: "A01 农业" },
{ value: "CSRC_A02", label: "A02 林业" },
{ value: "CSRC_A03", label: "A03 畜牧业" },
{ value: "CSRC_A04", label: "A04 渔业" }
]
},
{
value: "CSRC_B",
label: "B 采矿业",
children: [
{ value: "CSRC_B06", label: "B06 煤炭开采和洗选业" },
{ value: "CSRC_B07", label: "B07 石油和天然气开采业" },
{ value: "CSRC_B08", label: "B08 黑色金属矿采选业" },
{ value: "CSRC_B09", label: "B09 有色金属矿采选业" }
]
},
{
value: "CSRC_C",
label: "C 制造业",
children: [
{
value: "CSRC_C13",
label: "C13 农副食品加工业",
children: [
{ value: "CSRC_C1310", label: "C1310 肉制品加工" },
{ value: "CSRC_C1320", label: "C1320 水产品加工" }
]
},
{
value: "CSRC_C27",
label: "C27 医药制造业",
children: [
{ value: "CSRC_C2710", label: "C2710 化学药品原料药制造" },
{ value: "CSRC_C2720", label: "C2720 化学药品制剂制造" },
{ value: "CSRC_C2730", label: "C2730 中药饮片加工" }
]
},
{ value: "CSRC_C35", label: "C35 专用设备制造业" },
{ value: "CSRC_C39", label: "C39 计算机、通信和其他电子设备制造业" }
]
},
{
value: "CSRC_I",
label: "I 信息传输、软件和信息技术服务业",
children: [
{ value: "CSRC_I63", label: "I63 电信、广播电视和卫星传输服务" },
{ value: "CSRC_I64", label: "I64 互联网和相关服务" },
{ value: "CSRC_I65", label: "I65 软件和信息技术服务业" }
]
},
{
value: "CSRC_J",
label: "J 金融业",
children: [
{ value: "CSRC_J66", label: "J66 货币金融服务" },
{ value: "CSRC_J67", label: "J67 资本市场服务" },
{ value: "CSRC_J68", label: "J68 保险业" }
]
},
{
value: "CSRC_K",
label: "K 房地产业",
children: [
{ value: "CSRC_K70", label: "K70 房地产业" }
]
}
]
},
{
value: "中银国际行业分类",
label: "中银国际行业分类",
children: [
{
value: "BOC001",
label: "能源",
children: [
{ value: "BOC001001", label: "石油天然气" },
{ value: "BOC001002", label: "煤炭" },
{ value: "BOC001003", label: "新能源" }
]
},
{
value: "BOC002",
label: "原材料",
children: [
{ value: "BOC002001", label: "化工" },
{ value: "BOC002002", label: "钢铁" },
{ value: "BOC002003", label: "有色金属" },
{ value: "BOC002004", label: "建材" }
]
},
{
value: "BOC003",
label: "工业",
children: [
{ value: "BOC003001", label: "机械" },
{ value: "BOC003002", label: "电气设备" },
{ value: "BOC003003", label: "国防军工" }
]
},
{
value: "BOC004",
label: "消费",
children: [
{
value: "BOC004001",
label: "可选消费",
children: [
{ value: "BOC004001001", label: "汽车" },
{ value: "BOC004001002", label: "家电" },
{ value: "BOC004001003", label: "纺织服装" }
]
},
{
value: "BOC004002",
label: "必需消费",
children: [
{ value: "BOC004002001", label: "食品饮料" },
{ value: "BOC004002002", label: "农林牧渔" }
]
}
]
},
{
value: "BOC005",
label: "医疗保健",
children: [
{ value: "BOC005001", label: "医药" },
{ value: "BOC005002", label: "医疗器械" },
{ value: "BOC005003", label: "医疗服务" }
]
},
{
value: "BOC006",
label: "金融",
children: [
{ value: "BOC006001", label: "银行" },
{ value: "BOC006002", label: "非银金融" }
]
},
{
value: "BOC007",
label: "科技",
children: [
{
value: "BOC007001",
label: "信息技术",
children: [
{ value: "BOC007001001", label: "半导体" },
{ value: "BOC007001002", label: "电子" },
{ value: "BOC007001003", label: "计算机" },
{ value: "BOC007001004", label: "通信" }
]
},
{ value: "BOC007002", label: "传媒" }
]
}
]
},
{
value: "巨潮行业分类",
label: "巨潮行业分类",
children: [
{
value: "JC01",
label: "制造业",
children: [
{
value: "JC0101",
label: "电气机械及器材制造业",
children: [
{ value: "JC010101", label: "电机制造" },
{ value: "JC010102", label: "输配电及控制设备制造" },
{ value: "JC010103", label: "电池制造" }
]
},
{
value: "JC0102",
label: "医药制造业",
children: [
{ value: "JC010201", label: "化学药品原药制造" },
{ value: "JC010202", label: "化学药品制剂制造" },
{ value: "JC010203", label: "中成药制造" },
{ value: "JC010204", label: "生物、生化制品制造" }
]
},
{ value: "JC0103", label: "食品制造业" },
{ value: "JC0104", label: "纺织业" }
]
},
{
value: "JC02",
label: "信息传输、软件和信息技术服务业",
children: [
{ value: "JC0201", label: "互联网和相关服务" },
{ value: "JC0202", label: "软件和信息技术服务业" }
]
},
{
value: "JC03",
label: "批发和零售业",
children: [
{ value: "JC0301", label: "批发业" },
{ value: "JC0302", label: "零售业" }
]
},
{
value: "JC04",
label: "房地产业",
children: [
{ value: "JC0401", label: "房地产开发经营" },
{ value: "JC0402", label: "物业管理" }
]
},
{
value: "JC05",
label: "金融业",
children: [
{ value: "JC0501", label: "货币金融服务" },
{ value: "JC0502", label: "资本市场服务" },
{ value: "JC0503", label: "保险业" }
]
},
{
value: "JC06",
label: "交通运输、仓储和邮政业",
children: [
{ value: "JC0601", label: "道路运输业" },
{ value: "JC0602", label: "航空运输业" },
{ value: "JC0603", label: "水上运输业" }
]
},
{
value: "JC07",
label: "采矿业",
children: [
{ value: "JC0701", label: "煤炭开采和洗选业" },
{ value: "JC0702", label: "石油和天然气开采业" },
{ value: "JC0703", label: "有色金属矿采选业" }
]
},
{
value: "JC08",
label: "农、林、牧、渔业",
children: [
{ value: "JC0801", label: "农业" },
{ value: "JC0802", label: "林业" },
{ value: "JC0803", label: "畜牧业" },
{ value: "JC0804", label: "渔业" }
]
},
{
value: "JC09",
label: "建筑业",
children: [
{ value: "JC0901", label: "房屋建筑业" },
{ value: "JC0902", label: "土木工程建筑业" },
{ value: "JC0903", label: "建筑装饰和其他建筑业" }
]
}
]
}
];

View File

@@ -2,13 +2,116 @@
// 事件相关的 Mock API Handlers
import { http, HttpResponse } from 'msw';
import { getEventRelatedStocks } from '../data/events';
import { getEventRelatedStocks, generateMockEvents, generateHotEvents, generatePopularKeywords } from '../data/events';
import { getMockFutureEvents, getMockEventCountsForMonth } from '../data/account';
// 模拟网络延迟
const delay = (ms = 300) => new Promise(resolve => setTimeout(resolve, ms));
export const eventHandlers = [
// ==================== 事件列表相关 ====================
// 获取事件列表
http.get('/api/events/', async ({ request }) => {
await delay(500);
const url = new URL(request.url);
const params = {
page: parseInt(url.searchParams.get('page') || '1'),
per_page: parseInt(url.searchParams.get('per_page') || '10'),
sort: url.searchParams.get('sort') || 'new',
importance: url.searchParams.get('importance') || 'all',
date_range: url.searchParams.get('date_range') || '',
q: url.searchParams.get('q') || '',
industry_code: url.searchParams.get('industry_code') || '',
industry_classification: url.searchParams.get('industry_classification') || '',
};
console.log('[Mock] 获取事件列表:', params);
try {
const result = generateMockEvents(params);
return HttpResponse.json({
success: true,
data: result,
message: '获取成功'
});
} catch (error) {
console.error('[Mock] 获取事件列表失败:', error);
return HttpResponse.json(
{
success: false,
error: '获取事件列表失败',
data: { events: [], pagination: {} }
},
{ status: 500 }
);
}
}),
// 获取热点事件
http.get('/api/events/hot', async ({ request }) => {
await delay(300);
const url = new URL(request.url);
const limit = parseInt(url.searchParams.get('limit') || '5');
console.log('[Mock] 获取热点事件, limit:', limit);
try {
const hotEvents = generateHotEvents(limit);
return HttpResponse.json({
success: true,
data: hotEvents,
message: '获取成功'
});
} catch (error) {
console.error('[Mock] 获取热点事件失败:', error);
return HttpResponse.json(
{
success: false,
error: '获取热点事件失败',
data: []
},
{ status: 500 }
);
}
}),
// 获取热门关键词
http.get('/api/events/keywords/popular', async ({ request }) => {
await delay(300);
const url = new URL(request.url);
const limit = parseInt(url.searchParams.get('limit') || '20');
console.log('[Mock] 获取热门关键词, limit:', limit);
try {
const keywords = generatePopularKeywords(limit);
return HttpResponse.json({
success: true,
data: keywords,
message: '获取成功'
});
} catch (error) {
console.error('[Mock] 获取热门关键词失败:', error);
return HttpResponse.json(
{
success: false,
error: '获取热门关键词失败',
data: []
},
{ status: 500 }
);
}
}),
// ==================== 事件详情相关 ====================
// 获取事件相关股票
http.get('/api/events/:eventId/stocks', async ({ params }) => {
await delay(300);

View File

@@ -6,6 +6,7 @@ import { accountHandlers } from './account';
import { simulationHandlers } from './simulation';
import { eventHandlers } from './event';
import { paymentHandlers } from './payment';
import { industryHandlers } from './industry';
// 可以在这里添加更多的 handlers
// import { userHandlers } from './user';
@@ -16,5 +17,6 @@ export const handlers = [
...simulationHandlers,
...eventHandlers,
...paymentHandlers,
...industryHandlers,
// ...userHandlers,
];

View File

@@ -0,0 +1,44 @@
// src/mocks/handlers/industry.js
// 行业分类相关的 Mock API Handlers
import { http, HttpResponse } from 'msw';
import { industryData } from '../../data/industryData';
// 模拟网络延迟
const delay = (ms = 300) => new Promise(resolve => setTimeout(resolve, ms));
export const industryHandlers = [
// 获取行业分类完整树形结构
http.get('/api/classifications', async ({ request }) => {
await delay(500);
const url = new URL(request.url);
const classification = url.searchParams.get('classification');
console.log('[Mock] 获取行业分类树形数据(真实数据)', { classification });
try {
let data = industryData;
// 如果指定了分类体系,只返回该体系的数据
if (classification) {
data = industryData.filter(item => item.value === classification);
}
return HttpResponse.json({
success: true,
data: data
});
} catch (error) {
console.error('[Mock] 获取行业分类失败:', error);
return HttpResponse.json(
{
success: false,
error: '获取行业分类失败',
data: []
},
{ status: 500 }
);
}
})
];

View File

@@ -5,25 +5,25 @@ import axios from 'axios';
// 判断当前是否是生产环境
const isProduction = process.env.NODE_ENV === 'production';
const API_BASE_URL = getApiBase();
// 配置 axios 默认包含 credentials
axios.defaults.withCredentials = true;
export const industryService = {
// 获取所有行业分类体系
async getClassifications() {
const res = await axios.get(`${API_BASE_URL}/api/classifications`);
return res.data;
},
// 获取指定体系下的多级行业
async getLevels({ classification, level = 1, level1_name, level2_name, level3_name }) {
let url = `${API_BASE_URL}/api/levels?classification=${encodeURIComponent(classification)}&level=${level}`;
if (level1_name) url += `&level1_name=${encodeURIComponent(level1_name)}`;
if (level2_name) url += `&level2_name=${encodeURIComponent(level2_name)}`;
if (level3_name) url += `&level3_name=${encodeURIComponent(level3_name)}`;
/**
* 获取行业分类完整树形结构
* @param {string} classification - 可选,指定分类体系名称,不传则返回所有
* @returns {Promise} 返回树形结构数据
*/
async getClassifications(classification) {
let url = `${API_BASE_URL}/api/classifications`;
if (classification) {
url += `?classification=${encodeURIComponent(classification)}`;
}
const res = await axios.get(url);
return res.data;
}
// 注意getLevels 接口已废弃,使用 getClassifications 替代
};

View File

@@ -4,7 +4,7 @@ import { Card, Row, Col, DatePicker, Button, Select, Form, Input, Cascader } fro
import { FilterOutlined } from '@ant-design/icons';
import moment from 'moment';
import locale from 'antd/es/date-picker/locale/zh_CN';
import { industryService } from '../../../services/industryService';
import { useIndustry } from '../../../contexts/IndustryContext';
import { logger } from '../../../utils/logger';
const { RangePicker } = DatePicker;
@@ -160,6 +160,7 @@ const EventFilters = ({ filters, onFilterChange, loading }) => {
</Col>
</Row>
{/* 行业分类级联选择器 - 替换原来的 5 个独立 Select */}
<Row gutter={16}>
<Col span={24}>
<Form.Item label="申银万国行业分类" name="industry_cascade">

View File

@@ -55,6 +55,7 @@ import moment from 'moment';
import { logger } from '../../../utils/logger';
import { getApiBase } from '../../../utils/apiConfig';
import { useEventNotifications } from '../../../hooks/useEventNotifications';
import { getImportanceConfig, getAllImportanceLevels } from '../../../constants/importanceLevels';
// ========== 工具函数定义在组件外部 ==========
// 涨跌颜色配置中国A股配色红涨绿跌- 分档次显示
@@ -115,45 +116,7 @@ const getPriceChangeBorderColor = (value) => {
return 'gray.300';
};
// 重要性等级配置 - 金融配色方案
const importanceLevels = {
'S': {
color: 'purple.600',
bgColor: 'purple.50',
borderColor: 'purple.200',
icon: WarningIcon,
label: '极高',
dotBg: 'purple.500',
},
'A': {
color: 'red.600',
bgColor: 'red.50',
borderColor: 'red.200',
icon: WarningTwoIcon,
label: '高',
dotBg: 'red.500',
},
'B': {
color: 'orange.600',
bgColor: 'orange.50',
borderColor: 'orange.200',
icon: InfoIcon,
label: '中',
dotBg: 'orange.500',
},
'C': {
color: 'green.600',
bgColor: 'green.50',
borderColor: 'green.200',
icon: CheckCircleIcon,
label: '低',
dotBg: 'green.500',
}
};
const getImportanceConfig = (importance) => {
return importanceLevels[importance] || importanceLevels['C'];
};
// 重要性等级配置已移至 src/constants/importanceLevels.js
// 自定义的涨跌箭头组件(修复颜色问题)
const PriceArrow = ({ value }) => {
@@ -558,15 +521,54 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
{event.title}
</Heading>
</Tooltip>
<Badge
colorScheme={importance.color.split('.')[0]}
px={3}
py={1}
borderRadius="full"
fontSize="sm"
<Tooltip
label={
<VStack align="start" spacing={1} maxW="320px">
<Text fontWeight="bold" fontSize="sm" mb={1}>
重要性等级说明
</Text>
<Divider borderColor="gray.300" />
{getAllImportanceLevels().map((level) => (
<HStack key={level.level} spacing={2} align="center" w="full" py={0.5}>
<Circle
size="8px"
bg={level.dotBg}
flexShrink={0}
/>
<Text fontSize="xs" color="gray.700" lineHeight="1.5">
<Text as="span" fontWeight="bold">{level.level}</Text>
{level.description}
</Text>
</HStack>
))}
</VStack>
}
placement="left"
hasArrow
bg="white"
color="gray.800"
fontSize="md"
p={3}
borderRadius="lg"
borderWidth="1px"
borderColor="gray.200"
boxShadow="lg"
>
{importance.label}优先级
</Badge>
<Badge
colorScheme={importance.color.split('.')[0]}
px={3}
py={1}
borderRadius="full"
fontSize="sm"
cursor="help"
display="flex"
alignItems="center"
gap={1}
>
<InfoIcon boxSize={3} />
{importance.label}优先级
</Badge>
</Tooltip>
</Flex>
{/* 元信息 */}

View File

@@ -1,5 +1,5 @@
// src/views/Community/components/InvestmentCalendar.js
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import {
Card, Calendar, Badge, Modal, Table, Tabs, Tag, Button, List, Spin, Empty,
Drawer, Typography, Divider, Space, Tooltip, message, Alert
@@ -48,7 +48,7 @@ const InvestmentCalendar = () => {
const [expandedReasons, setExpandedReasons] = useState({}); // 跟踪每个股票关联理由的展开状态
// 加载月度事件统计
const loadEventCounts = async (date) => {
const loadEventCounts = useCallback(async (date) => {
try {
const year = date.year();
const month = date.month() + 1;
@@ -63,7 +63,7 @@ const InvestmentCalendar = () => {
month: date.month() + 1
});
}
};
}, []); // eventService 是外部导入的稳定引用,不需要作为依赖
// 加载指定日期的事件
const loadDateEvents = async (date) => {
@@ -131,7 +131,7 @@ const InvestmentCalendar = () => {
useEffect(() => {
loadEventCounts(currentMonth);
}, [currentMonth]);
}, [currentMonth, loadEventCounts]);
// 自定义日期单元格渲染
const dateCellRender = (value) => {
@@ -700,6 +700,7 @@ const InvestmentCalendar = () => {
width={1200}
footer={null}
bodyStyle={{ padding: '24px' }}
zIndex={1500}
>
<Spin spinning={loading}>
<Tabs defaultActiveKey="event">
@@ -734,6 +735,7 @@ const InvestmentCalendar = () => {
width={600}
onClose={() => setDetailDrawerVisible(false)}
visible={detailDrawerVisible}
zIndex={1500}
>
{selectedDetail?.content?.type === 'citation' ? (
<CitedContent
@@ -770,6 +772,7 @@ const InvestmentCalendar = () => {
关闭
</Button>
}
zIndex={1500}
>
{hasFeatureAccess('related_stocks') ? (
<Table

View File

@@ -1,6 +1,7 @@
// src/views/Community/index.js
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { debounce } from 'lodash';
import {
Box,
Container,
@@ -272,18 +273,37 @@ const Community = () => {
return { key, label: filterLabelMap[key] ? filterLabelMap[key](value) : `${key}: ${value}` };
});
// 创建防抖的 loadEvents 函数500ms 防抖延迟)
const debouncedLoadEvents = useRef(
debounce((page) => {
logger.debug('Community', '防抖后执行 loadEvents', { page });
loadEvents(page);
}, 500)
).current;
// 初始化加载
// 注意: 只监听 searchParams 变化,不监听 loadEvents 等函数
// 这是为了避免 StockDetailPanel 打开时触发不必要的重新加载
// 如果未来 loadEvents 添加了新的状态依赖,需要在此处同步更新
// 防抖优化:用户快速切换筛选条件时,只执行最后一次请求
useEffect(() => {
logger.debug('Community', 'useEffect 触发searchParams 变化', {
params: searchParams.toString()
});
const page = parseInt(searchParams.get('page') || '1', 10);
loadEvents(page);
loadPopularKeywords();
loadHotEvents();
// 使用防抖加载事件
debouncedLoadEvents(page);
// 热门关键词和热点事件不需要防抖(初次加载)
if (searchParams.get('page') === null || searchParams.get('page') === '1') {
loadPopularKeywords();
loadHotEvents();
}
// 组件卸载时取消防抖
return () => {
debouncedLoadEvents.cancel();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]); // 只监听 URL 参数变化

View File

@@ -578,7 +578,7 @@ const StockOverview = () => {
bgGradient={colorMode === 'dark' ? `linear(to-r, ${goldColor}, white)` : 'none'}
bgClip={colorMode === 'dark' ? 'text' : 'none'}
>
票概览中心
股中心
</Heading>
</HStack>

View File

@@ -56,7 +56,12 @@ export default function TradingSimulation() {
const { user, isAuthenticated } = useAuth();
const [activeTab, setActiveTab] = useState(0);
const [assetHistory, setAssetHistory] = useState([]); // 移到这里!
// ⚡ 提取 userId 为独立变量,避免 user 对象引用变化导致无限循环
const userId = user?.id;
const prevUserIdRef = React.useRef(userId);
const prevIsAuthenticatedRef = React.useRef(isAuthenticated);
// 使用模拟账户管理 Hook
const {
account,
@@ -87,12 +92,20 @@ export default function TradingSimulation() {
// 调试:观察认证状态变化
useEffect(() => {
logger.debug('TradingSimulation', '组件挂载,认证状态检查', {
isAuthenticated,
userId: user?.id,
userName: user?.name
});
}, [isAuthenticated, user?.id]); // 只依赖 user.id,避免无限循环
const userIdChanged = prevUserIdRef.current !== userId;
const authChanged = prevIsAuthenticatedRef.current !== isAuthenticated;
if (userIdChanged || authChanged) {
prevUserIdRef.current = userId;
prevIsAuthenticatedRef.current = isAuthenticated;
logger.debug('TradingSimulation', '组件挂载,认证状态检查', {
isAuthenticated,
userId,
userName: user?.name
});
}
}, [isAuthenticated, userId, user]); // ⚡ 使用 userId防重复通过 ref 判断
// 获取资产历史数据的 useEffect
useEffect(() => {