Compare commits
15 Commits
62d6487cbb
...
9429eb0559
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9429eb0559 | ||
|
|
e69f822150 | ||
|
|
13c3c74b92 | ||
|
|
bcf81f4d47 | ||
|
|
f0d30244d2 | ||
|
|
f2cdc0756c | ||
|
|
e91656d332 | ||
|
|
5eb4227e29 | ||
|
|
34a6c402c4 | ||
|
|
6ad38594bb | ||
|
|
1ba8b8fd2f | ||
|
|
45b88309b3 | ||
|
|
28975f74e9 | ||
|
|
4eaeab521f | ||
|
|
9dcd4bfbf3 |
@@ -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": []
|
||||
|
||||
23
src/App.js
23
src/App.js
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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键或空格键查看详情。`
|
||||
|
||||
@@ -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 />
|
||||
|
||||
{/* 功能按钮 */}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
77
src/constants/importanceLevels.js
Normal file
77
src/constants/importanceLevels.js
Normal 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);
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
51
src/contexts/IndustryContext.js
Normal file
51
src/contexts/IndustryContext.js
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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
4346
src/data/industryData.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
}));
|
||||
}
|
||||
|
||||
554
src/mocks/data/industries.js
Normal file
554
src/mocks/data/industries.js
Normal 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: "建筑装饰和其他建筑业" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
44
src/mocks/handlers/industry.js
Normal file
44
src/mocks/handlers/industry.js
Normal 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 }
|
||||
);
|
||||
}
|
||||
})
|
||||
];
|
||||
@@ -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 替代
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 元信息 */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 参数变化
|
||||
|
||||
|
||||
@@ -578,7 +578,7 @@ const StockOverview = () => {
|
||||
bgGradient={colorMode === 'dark' ? `linear(to-r, ${goldColor}, white)` : 'none'}
|
||||
bgClip={colorMode === 'dark' ? 'text' : 'none'}
|
||||
>
|
||||
股票概览中心
|
||||
个股中心
|
||||
</Heading>
|
||||
</HStack>
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user