Files
vf_react/src/components/Navbars/HomeNavbar.js
zdl 77440f78a7 refactor(HomeNavbar): Phase 3 - 提取用户菜单组件
**背景**
继 Phase 1 (静态组件) 和 Phase 2 (Redux订阅) 后,进一步优化 HomeNavbar

**重构内容**
1. **新增组件目录** `src/components/Navbars/components/UserMenu/`
   - UserAvatar.js (101行) - 头像 + 皇冠图标 + 订阅边框
   - DesktopUserMenu.js (93行) - 桌面版 Tooltip + 订阅弹窗
   - TabletUserMenu.js (166行) - 平板版下拉菜单 (含所有功能)
   - index.js - 统一导出

2. **HomeNavbar.js 优化**
   - 删除 ~150 行用户菜单 JSX 代码
   - 移除未使用的 Tooltip 导入
   - 替换为 DesktopUserMenu / TabletUserMenu 组件调用
   - 1533 → 1394 行 (-139行, -9%)

**技术亮点**
- React.memo 优化渲染性能
- 复用 Redux subscriptionSlice (Phase 2)
- 响应式设计 (isDesktop vs isTablet)
- 组件内聚,降低父组件耦合

**累计成果** (Phase 1-3)
- 原始: 1623 行
- 当前: 1394 行
- 减少: 229 行 (-14%)
- 提取: 7 个组件 (4 静态 + 3 用户菜单)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 17:01:01 +08:00

1394 lines
75 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useCallback, useState } from 'react';
import {
Box,
Flex,
Text,
Button,
Container,
useDisclosure,
Drawer,
DrawerBody,
DrawerHeader,
DrawerOverlay,
DrawerContent,
DrawerCloseButton,
VStack,
HStack,
Icon,
Menu,
MenuButton,
MenuList,
MenuItem,
MenuDivider,
Badge,
Grid,
IconButton,
useBreakpointValue,
Link,
Divider,
Avatar,
Spinner,
useColorMode,
useColorModeValue,
useToast,
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';
import { FaCrown } from 'react-icons/fa';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { useAuthModal } from '../../hooks/useAuthModal';
import { logger } from '../../utils/logger';
import { getApiBase } from '../../utils/apiConfig';
import SubscriptionButton from '../Subscription/SubscriptionButton';
import { useNavigationEvents } from '../../hooks/useNavigationEvents';
// Phase 1 优化: 提取的子组件
import BrandLogo from './components/BrandLogo';
import LoginButton from './components/LoginButton';
import CalendarButton from './components/CalendarButton';
// Phase 2 优化: 使用 Redux 管理订阅数据
import { useSubscription } from '../../hooks/useSubscription';
// Phase 3 优化: 提取的用户菜单组件
import { DesktopUserMenu, TabletUserMenu } from './components/UserMenu';
/** 二级导航栏组件 - 显示当前一级菜单下的所有二级菜单项 */
const SecondaryNav = ({ showCompletenessAlert }) => {
const navigate = useNavigate();
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');
// 🎯 初始化导航埋点Hook
const navEvents = useNavigationEvents({ component: 'secondary_nav' });
// 定义二级导航结构
const secondaryNavConfig = {
'/community': {
title: '高频跟踪',
items: [
{ 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: '/concepts', label: '概念中心', badges: [{ text: 'NEW', colorScheme: 'red' }] }
]
},
'/limit-analyse': {
title: '行情复盘',
items: [
{ path: '/limit-analyse', label: '涨停分析', badges: [{ text: 'FREE', colorScheme: 'blue' }] },
{ path: '/stocks', label: '个股中心', badges: [{ text: 'HOT', colorScheme: 'green' }] },
{ path: '/trading-simulation', label: '模拟盘', badges: [{ text: 'NEW', colorScheme: 'red' }] }
]
},
'/stocks': {
title: '行情复盘',
items: [
{ path: '/limit-analyse', label: '涨停分析', badges: [{ text: 'FREE', colorScheme: 'blue' }] },
{ path: '/stocks', label: '个股中心', badges: [{ text: 'HOT', colorScheme: 'green' }] },
{ path: '/trading-simulation', label: '模拟盘', badges: [{ text: 'NEW', colorScheme: 'red' }] }
]
},
'/trading-simulation': {
title: '行情复盘',
items: [
{ path: '/limit-analyse', label: '涨停分析', badges: [{ text: 'FREE', colorScheme: 'blue' }] },
{ path: '/stocks', label: '个股中心', badges: [{ text: 'HOT', colorScheme: 'green' }] },
{ path: '/trading-simulation', label: '模拟盘', badges: [{ text: 'NEW', colorScheme: 'red' }] }
]
}
};
// 找到当前路径对应的二级导航配置
const currentConfig = Object.keys(secondaryNavConfig).find(key =>
location.pathname.includes(key)
);
// 如果没有匹配的二级导航,不显示
if (!currentConfig) return null;
const config = secondaryNavConfig[currentConfig];
return (
<Box
bg={navbarBg}
borderBottom="1px"
borderColor={borderColorValue}
py={2}
position="sticky"
top={showCompletenessAlert ? "120px" : "60px"}
zIndex={100}
>
<Container maxW="container.xl" px={4}>
<HStack spacing={1}>
{/* 显示一级菜单标题 */}
<Text fontSize="sm" color="gray.500" mr={2}>
{config.title}:
</Text>
{/* 二级菜单项 */}
{config.items.map((item, index) => {
const isActive = location.pathname.includes(item.path);
return item.external ? (
<Button
key={index}
as="a"
href={item.path}
size="sm"
variant="ghost"
bg="transparent"
color="inherit"
fontWeight="normal"
_hover={{ bg: itemHoverBg }}
borderRadius="md"
px={3}
>
<Flex align="center" gap={2}>
<Text>{item.label}</Text>
{item.badges && item.badges.length > 0 && (
<HStack spacing={1}>
{item.badges.map((badge, bIndex) => (
<Badge key={bIndex} size="xs" colorScheme={badge.colorScheme}>
{badge.text}
</Badge>
))}
</HStack>
)}
</Flex>
</Button>
) : (
<Button
key={index}
onClick={() => {
// 🎯 追踪侧边栏菜单点击
navEvents.trackSidebarMenuClicked(item.label, item.path, 2, false);
navigate(item.path);
}}
size="sm"
variant="ghost"
bg={isActive ? 'blue.50' : 'transparent'}
color={isActive ? 'blue.600' : 'inherit'}
fontWeight={isActive ? 'bold' : 'normal'}
borderBottom={isActive ? '2px solid' : 'none'}
borderColor="blue.600"
borderRadius={isActive ? '0' : 'md'}
_hover={{ bg: isActive ? 'blue.100' : itemHoverBg }}
px={3}
>
<Flex align="center" gap={2}>
<Text>{item.label}</Text>
{item.badges && item.badges.length > 0 && (
<HStack spacing={1}>
{item.badges.map((badge, bIndex) => (
<Badge key={bIndex} size="xs" colorScheme={badge.colorScheme}>
{badge.text}
</Badge>
))}
</HStack>
)}
</Flex>
</Button>
);
})}
</HStack>
</Container>
</Box>
);
};
/** 中屏"更多"菜单 - 用于平板和小笔记本 */
const MoreNavMenu = ({ isAuthenticated, user }) => {
const navigate = useNavigate();
const location = useLocation();
// 辅助函数:判断导航项是否激活
const isActive = useCallback((paths) => {
return paths.some(path => location.pathname.includes(path));
}, [location.pathname]);
if (!isAuthenticated || !user) return null;
return (
<Menu>
<MenuButton
as={Button}
variant="ghost"
rightIcon={<ChevronDownIcon />}
fontWeight="medium"
>
更多
</MenuButton>
<MenuList minW="300px" p={2}>
{/* 高频跟踪组 */}
<Text fontSize="xs" fontWeight="bold" px={3} py={2} color="gray.500">高频跟踪</Text>
<MenuItem
onClick={() => navigate('/community')}
borderRadius="md"
bg={location.pathname.includes('/community') ? 'blue.50' : 'transparent'}
>
<Flex justify="space-between" align="center" w="100%">
<Text fontSize="sm">事件中心</Text>
<HStack spacing={1}>
<Badge size="sm" colorScheme="green">HOT</Badge>
<Badge size="sm" colorScheme="red">NEW</Badge>
</HStack>
</Flex>
</MenuItem>
<MenuItem
onClick={() => navigate('/concepts')}
borderRadius="md"
bg={location.pathname.includes('/concepts') ? 'blue.50' : 'transparent'}
>
<Flex justify="space-between" align="center" w="100%">
<Text fontSize="sm">概念中心</Text>
<Badge size="sm" colorScheme="red">NEW</Badge>
</Flex>
</MenuItem>
<MenuDivider />
{/* 行情复盘组 */}
<Text fontSize="xs" fontWeight="bold" px={3} py={2} color="gray.500">行情复盘</Text>
<MenuItem
onClick={() => navigate('/limit-analyse')}
borderRadius="md"
bg={location.pathname.includes('/limit-analyse') ? 'blue.50' : 'transparent'}
>
<Flex justify="space-between" align="center" w="100%">
<Text fontSize="sm">涨停分析</Text>
<Badge size="sm" colorScheme="blue">FREE</Badge>
</Flex>
</MenuItem>
<MenuItem
onClick={() => navigate('/stocks')}
borderRadius="md"
bg={location.pathname.includes('/stocks') ? 'blue.50' : 'transparent'}
>
<Flex justify="space-between" align="center" w="100%">
<Text fontSize="sm">个股中心</Text>
<Badge size="sm" colorScheme="green">HOT</Badge>
</Flex>
</MenuItem>
<MenuItem
onClick={() => navigate('/trading-simulation')}
borderRadius="md"
bg={location.pathname.includes('/trading-simulation') ? 'blue.50' : 'transparent'}
>
<Flex justify="space-between" align="center" w="100%">
<Text fontSize="sm">模拟盘</Text>
<Badge size="sm" colorScheme="red">NEW</Badge>
</Flex>
</MenuItem>
<MenuDivider />
{/* AGENT社群组 */}
<Text fontSize="xs" fontWeight="bold" px={3} py={2} color="gray.500">AGENT社群</Text>
<MenuItem isDisabled cursor="not-allowed" color="gray.400">
<Text fontSize="sm" color="gray.400">今日热议</Text>
</MenuItem>
<MenuItem isDisabled cursor="not-allowed" color="gray.400">
<Text fontSize="sm" color="gray.400">个股社区</Text>
</MenuItem>
<MenuDivider />
{/* 联系我们 */}
<MenuItem isDisabled cursor="not-allowed" color="gray.400">
<Text fontSize="sm" color="gray.400">联系我们</Text>
</MenuItem>
</MenuList>
</Menu>
);
};
/** 桌面端导航 - 完全按照原网站
* @TODO 添加逻辑 不展示导航case
* 1.未登陆状态 && 是首页
* 2. !isMobile
*/
const NavItems = ({ isAuthenticated, user }) => {
const navigate = useNavigate();
const location = useLocation();
// ⚠️ 必须在组件顶层调用所有Hooks不能在JSX中调用
const contactTextColor = useColorModeValue('gray.500', 'gray.300');
// 🎯 初始化导航埋点Hook
const navEvents = useNavigationEvents({ component: 'top_nav' });
// 辅助函数:判断导航项是否激活
const isActive = useCallback((paths) => {
return paths.some(path => location.pathname.includes(path));
}, [location.pathname]);
if (isAuthenticated && user) {
return (
<HStack spacing={8}>
<Menu>
<MenuButton
as={Button}
variant="ghost"
rightIcon={<ChevronDownIcon />}
bg={isActive(['/community', '/concepts']) ? 'blue.50' : 'transparent'}
color={isActive(['/community', '/concepts']) ? 'blue.600' : 'inherit'}
fontWeight={isActive(['/community', '/concepts']) ? 'bold' : 'normal'}
borderBottom={isActive(['/community', '/concepts']) ? '2px solid' : 'none'}
borderColor="blue.600"
_hover={{ bg: isActive(['/community', '/concepts']) ? 'blue.100' : 'gray.50' }}
>
高频跟踪
</MenuButton>
<MenuList minW="260px" p={2}>
<MenuItem
onClick={() => {
// 🎯 追踪菜单项点击
navEvents.trackMenuItemClicked('事件中心', 'dropdown', '/community');
navigate('/community');
}}
borderRadius="md"
bg={location.pathname.includes('/community') ? 'blue.50' : 'transparent'}
borderLeft={location.pathname.includes('/community') ? '3px solid' : 'none'}
borderColor="blue.600"
fontWeight={location.pathname.includes('/community') ? 'bold' : 'normal'}
>
<Flex justify="space-between" align="center" w="100%">
<Text fontSize="sm">事件中心</Text>
<HStack spacing={1}>
<Badge size="sm" colorScheme="green">HOT</Badge>
<Badge size="sm" colorScheme="red">NEW</Badge>
</HStack>
</Flex>
</MenuItem>
<MenuItem
onClick={() => {
// 🎯 追踪菜单项点击
navEvents.trackMenuItemClicked('概念中心', 'dropdown', '/concepts');
navigate('/concepts');
}}
borderRadius="md"
bg={location.pathname.includes('/concepts') ? 'blue.50' : 'transparent'}
borderLeft={location.pathname.includes('/concepts') ? '3px solid' : 'none'}
borderColor="blue.600"
fontWeight={location.pathname.includes('/concepts') ? 'bold' : 'normal'}
>
<Flex justify="space-between" align="center" w="100%">
<Text fontSize="sm">概念中心</Text>
<Badge size="sm" colorScheme="red">NEW</Badge>
</Flex>
</MenuItem>
</MenuList>
</Menu>
<Menu>
<MenuButton
as={Button}
variant="ghost"
rightIcon={<ChevronDownIcon />}
bg={isActive(['/limit-analyse', '/stocks']) ? 'blue.50' : 'transparent'}
color={isActive(['/limit-analyse', '/stocks']) ? 'blue.600' : 'inherit'}
fontWeight={isActive(['/limit-analyse', '/stocks']) ? 'bold' : 'normal'}
borderBottom={isActive(['/limit-analyse', '/stocks']) ? '2px solid' : 'none'}
borderColor="blue.600"
_hover={{ bg: isActive(['/limit-analyse', '/stocks']) ? 'blue.100' : 'gray.50' }}
>
行情复盘
</MenuButton>
<MenuList minW="260px" p={2}>
<MenuItem
onClick={() => navigate('/limit-analyse')}
borderRadius="md"
bg={location.pathname.includes('/limit-analyse') ? 'blue.50' : 'transparent'}
borderLeft={location.pathname.includes('/limit-analyse') ? '3px solid' : 'none'}
borderColor="blue.600"
fontWeight={location.pathname.includes('/limit-analyse') ? 'bold' : 'normal'}
>
<Flex justify="space-between" align="center" w="100%">
<Text fontSize="sm">涨停分析</Text>
<Badge size="sm" colorScheme="blue">FREE</Badge>
</Flex>
</MenuItem>
<MenuItem
onClick={() => navigate('/stocks')}
borderRadius="md"
bg={location.pathname.includes('/stocks') ? 'blue.50' : 'transparent'}
borderLeft={location.pathname.includes('/stocks') ? '3px solid' : 'none'}
borderColor="blue.600"
fontWeight={location.pathname.includes('/stocks') ? 'bold' : 'normal'}
>
<Flex justify="space-between" align="center" w="100%">
<Text fontSize="sm">个股中心</Text>
<Badge size="sm" colorScheme="green">HOT</Badge>
</Flex>
</MenuItem>
<MenuItem
onClick={() => navigate('/trading-simulation')}
borderRadius="md"
bg={location.pathname.includes('/trading-simulation') ? 'blue.50' : 'transparent'}
borderLeft={location.pathname.includes('/trading-simulation') ? '3px solid' : 'none'}
borderColor="blue.600"
fontWeight={location.pathname.includes('/trading-simulation') ? 'bold' : 'normal'}
>
<Flex justify="space-between" align="center" w="100%">
<Text fontSize="sm">模拟盘</Text>
<Badge size="sm" colorScheme="red">NEW</Badge>
</Flex>
</MenuItem>
</MenuList>
</Menu>
{false && isAuthenticated && (
<Menu onOpen={loadWatchlistQuotes} closeOnSelect={false}>
<MenuButton as={Button} variant="ghost" rightIcon={<ChevronDownIcon />}>自选股</MenuButton>
</Menu>
)}
<Menu>
<MenuButton as={Button} variant="ghost" rightIcon={<ChevronDownIcon />}>
AGENT社群
</MenuButton>
<MenuList minW="300px" p={4}>
<MenuItem
isDisabled
cursor="not-allowed"
color="gray.400"
>
<Text fontSize="sm" color="gray.400">今日热议</Text>
</MenuItem>
<MenuItem
isDisabled
cursor="not-allowed"
color="gray.400"
>
<Text fontSize="sm" color="gray.400">个股社区</Text>
</MenuItem>
</MenuList>
</Menu>
{false && isAuthenticated && (
<Menu onOpen={loadFollowingEvents} closeOnSelect={false}>
<MenuButton as={Button} variant="ghost" rightIcon={<ChevronDownIcon />}>关注的事件</MenuButton>
</Menu>
)}
<Menu>
<MenuButton as={Button} variant="ghost" rightIcon={<ChevronDownIcon />}>
联系我们
</MenuButton>
<MenuList minW="260px" p={4}>
<Text fontSize="sm" color={contactTextColor}>敬请期待</Text>
</MenuList>
</Menu>
</HStack>
)
} else {
return null;
}
};
export default function HomeNavbar() {
const { isOpen, onOpen, onClose } = useDisclosure();
const navigate = useNavigate();
const isMobile = useBreakpointValue({ base: true, md: false });
const isTablet = useBreakpointValue({ base: false, md: true, lg: false });
const isDesktop = useBreakpointValue({ base: false, md: false, lg: true });
const { user, isAuthenticated, logout, isLoading } = useAuth();
const { openAuthModal } = useAuthModal();
const { colorMode, toggleColorMode } = useColorMode();
const navbarBg = useColorModeValue('white', 'gray.800');
const navbarBorder = useColorModeValue('gray.200', 'gray.700');
const brandText = useColorModeValue('gray.800', 'white');
const brandHover = useColorModeValue('blue.600', 'blue.300');
const toast = useToast();
// 🎯 初始化导航埋点Hook
const navEvents = useNavigationEvents({ component: 'main_navbar' });
// ⚡ 提取 userId 为独立变量,避免 user 对象引用变化导致无限循环
const userId = user?.id;
const prevUserIdRef = React.useRef(userId);
const prevIsAuthenticatedRef = React.useRef(isAuthenticated);
// 添加调试信息 - 暂时注释以减少日志噪音
// logger.debug('HomeNavbar', '组件渲染状态', {
// hasUser: !!user,
// isAuthenticated,
// isLoading,
// userId: user?.id
// });
// 获取显示名称的函数
const getDisplayName = () => {
if (!user) return '';
return user.nickname || user.username || user.name || user.email || '用户';
};
// 处理登出
const handleLogout = async () => {
try {
await logout();
// 重置资料完整性检查标志
hasCheckedCompleteness.current = false;
setProfileCompleteness(null);
setShowCompletenessAlert(false);
// logout函数已经包含了跳转逻辑这里不需要额外处理
} catch (error) {
logger.error('HomeNavbar', 'handleLogout', error, {
userId: user?.id
});
}
};
// 检查是否为禁用的链接没有NEW标签的链接
// const isDisabledLink = true;
// 自选股 / 关注事件 下拉所需状态
const [watchlistQuotes, setWatchlistQuotes] = useState([]);
const [watchlistLoading, setWatchlistLoading] = useState(false);
const [followingEvents, setFollowingEvents] = useState([]);
const [eventsLoading, setEventsLoading] = useState(false);
const [watchlistPage, setWatchlistPage] = useState(1);
const [eventsPage, setEventsPage] = useState(1);
const WATCHLIST_PAGE_SIZE = 10;
const EVENTS_PAGE_SIZE = 8;
// 投资日历 Modal 状态 - 已移至 CalendarButton 组件内部管理
// const [calendarModalOpen, setCalendarModalOpen] = useState(false);
// 用户信息完整性状态
const [profileCompleteness, setProfileCompleteness] = useState(null);
const [showCompletenessAlert, setShowCompletenessAlert] = useState(false);
// 添加标志位:追踪是否已经检查过资料完整性(避免重复请求)
const hasCheckedCompleteness = React.useRef(false);
// Phase 2: 使用 Redux 订阅数据
const {
subscriptionInfo,
isSubscriptionModalOpen,
openSubscriptionModal,
closeSubscriptionModal
} = useSubscription();
const loadWatchlistQuotes = useCallback(async () => {
try {
setWatchlistLoading(true);
const base = getApiBase(); // 使用外部函数
const resp = await fetch(base + '/api/account/watchlist/realtime', {
credentials: 'include',
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache' }
});
if (resp.ok) {
const data = await resp.json();
if (data && data.success && Array.isArray(data.data)) {
setWatchlistQuotes(data.data);
} else {
setWatchlistQuotes([]);
}
} else {
setWatchlistQuotes([]);
}
} catch (e) {
logger.warn('HomeNavbar', '加载自选股实时行情失败', {
error: e.message
});
setWatchlistQuotes([]);
} finally {
setWatchlistLoading(false);
}
}, []); // getApiBase 是外部函数,不需要作为依赖
const loadFollowingEvents = useCallback(async () => {
try {
setEventsLoading(true);
const base = getApiBase();
const resp = await fetch(base + '/api/account/events/following', {
credentials: 'include',
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache' }
});
if (resp.ok) {
const data = await resp.json();
if (data && data.success && Array.isArray(data.data)) {
const ids = data.data.map((e) => e.id).filter(Boolean);
if (ids.length === 0) {
setFollowingEvents([]);
} else {
// 并行请求详情以获取涨幅字段
const detailResponses = await Promise.all(ids.map((id) => fetch(base + `/api/events/${id}`, {
credentials: 'include',
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache' }
})));
const detailJsons = await Promise.all(detailResponses.map((r) => r.ok ? r.json() : Promise.resolve({ success: false })));
const details = detailJsons
.filter((j) => j && j.success && j.data)
.map((j) => j.data);
// 以原顺序合并,缺失则回退基础信息
const merged = ids.map((id) => {
const d = details.find((x) => x.id === id);
const baseItem = (data.data || []).find((x) => x.id === id) || {};
return d ? d : baseItem;
});
setFollowingEvents(merged);
}
} else {
setFollowingEvents([]);
}
} else {
setFollowingEvents([]);
}
} catch (e) {
logger.warn('HomeNavbar', '加载关注事件失败', {
error: e.message
});
setFollowingEvents([]);
} finally {
setEventsLoading(false);
}
}, []); // getApiBase 是外部函数,不需要作为依赖
// 从自选股移除
const handleRemoveFromWatchlist = useCallback(async (stockCode) => {
try {
const base = getApiBase();
const resp = await fetch(base + `/api/account/watchlist/${stockCode}`, {
method: 'DELETE',
credentials: 'include'
});
const data = await resp.json().catch(() => ({}));
if (resp.ok && data && data.success !== false) {
setWatchlistQuotes((prev) => {
const normalize6 = (code) => {
const m = String(code || '').match(/(\d{6})/);
return m ? m[1] : String(code || '');
};
const target = normalize6(stockCode);
const updated = (prev || []).filter((x) => normalize6(x.stock_code) !== target);
const newMaxPage = Math.max(1, Math.ceil((updated.length || 0) / WATCHLIST_PAGE_SIZE));
setWatchlistPage((p) => Math.min(p, newMaxPage));
return updated;
});
toast({ title: '已从自选股移除', status: 'info', duration: 1500 });
} else {
toast({ title: '移除失败', status: 'error', duration: 2000 });
}
} catch (e) {
toast({ title: '网络错误,移除失败', status: 'error', duration: 2000 });
}
}, [toast]); // WATCHLIST_PAGE_SIZE 是常量getApiBase 是外部函数,不需要作为依赖
// 取消关注事件
const handleUnfollowEvent = useCallback(async (eventId) => {
try {
const base = getApiBase();
const resp = await fetch(base + `/api/events/${eventId}/follow`, {
method: 'POST',
credentials: 'include'
});
const data = await resp.json().catch(() => ({}));
if (resp.ok && data && data.success !== false) {
setFollowingEvents((prev) => {
const updated = (prev || []).filter((x) => x.id !== eventId);
const newMaxPage = Math.max(1, Math.ceil((updated.length || 0) / EVENTS_PAGE_SIZE));
setEventsPage((p) => Math.min(p, newMaxPage));
return updated;
});
toast({ title: '已取消关注该事件', status: 'info', duration: 1500 });
} else {
toast({ title: '操作失败', status: 'error', duration: 2000 });
}
} catch (e) {
toast({ title: '网络错误,操作失败', status: 'error', duration: 2000 });
}
}, [toast]); // EVENTS_PAGE_SIZE 是常量getApiBase 是外部函数,不需要作为依赖
// 检查用户资料完整性
const checkProfileCompleteness = useCallback(async () => {
if (!isAuthenticated || !user) return;
// 如果已经检查过,跳过(避免重复请求)
if (hasCheckedCompleteness.current) {
logger.debug('HomeNavbar', '已检查过资料完整性,跳过重复请求', {
userId: user?.id
});
return;
}
try {
logger.debug('HomeNavbar', '开始检查资料完整性', {
userId: user?.id
});
const base = getApiBase();
const resp = await fetch(base + '/api/account/profile-completeness', {
credentials: 'include'
});
if (resp.ok) {
const data = await resp.json();
if (data.success) {
setProfileCompleteness(data.data);
// 只有微信用户且资料不完整时才显示提醒
setShowCompletenessAlert(data.data.needsAttention);
// 标记为已检查
hasCheckedCompleteness.current = true;
logger.debug('HomeNavbar', '资料完整性检查完成', {
userId: user?.id,
completeness: data.data.completenessPercentage
});
}
}
} catch (error) {
logger.warn('HomeNavbar', '检查资料完整性失败', {
userId: user?.id,
error: error.message
});
}
}, [isAuthenticated, userId]); // ⚡ 使用 userId 而不是 user?.id
// 监听用户变化,重置检查标志(用户切换或退出登录时)
React.useEffect(() => {
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, userId, user]); // ⚡ 使用 userId
// 用户登录后检查资料完整性
React.useEffect(() => {
const userIdChanged = prevUserIdRef.current !== userId;
const authChanged = prevIsAuthenticatedRef.current !== isAuthenticated;
if ((userIdChanged || authChanged) && isAuthenticated && user) {
// 延迟检查,避免过于频繁
const timer = setTimeout(checkProfileCompleteness, 1000);
return () => clearTimeout(timer);
}
}, [isAuthenticated, userId, checkProfileCompleteness, user]); // ⚡ 使用 userId
// Phase 2: 加载订阅信息逻辑已移至 useSubscriptionData Hook
return (
<>
{/* 资料完整性提醒横幅 */}
{showCompletenessAlert && profileCompleteness && (
<Box
bg="linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
color="white"
py={{ base: 2, md: 2 }}
px={{ base: 2, md: 4 }}
position="sticky"
top={0}
zIndex={1001}
>
<Container maxW="container.xl">
<HStack justify="space-between" align="center" spacing={{ base: 2, md: 4 }}>
<HStack spacing={{ base: 2, md: 3 }} flex={1} minW={0}>
<Icon as={FiStar} display={{ base: 'none', sm: 'block' }} />
<VStack spacing={0} align="start" flex={1} minW={0}>
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="bold" noOfLines={1}>
完善资料享受更好服务
</Text>
<Text fontSize={{ base: '2xs', md: 'xs' }} opacity={0.9} noOfLines={1}>
您还需要设置{profileCompleteness.missingItems.join('、')}
</Text>
</VStack>
<Text fontSize="2xs" bg="whiteAlpha.300" px={2} py={1} borderRadius="full" display={{ base: 'none', md: 'block' }}>
{profileCompleteness.completenessPercentage}% 完成
</Text>
</HStack>
<HStack spacing={{ base: 1, md: 2 }}>
<Button
size={{ base: 'xs', md: 'sm' }}
colorScheme="whiteAlpha"
variant="ghost"
onClick={() => navigate('/home/settings')}
minH={{ base: '32px', md: '40px' }}
>
立即完善
</Button>
<IconButton
size={{ base: 'xs', md: 'sm' }}
variant="ghost"
colorScheme="whiteAlpha"
icon={<Text fontSize={{ base: 'xl', md: '2xl' }}>×</Text>}
onClick={() => setShowCompletenessAlert(false)}
aria-label="关闭提醒"
minW={{ base: '32px', md: '40px' }}
/>
</HStack>
</HStack>
</Container>
</Box>
)}
<Box
position="sticky"
top={showCompletenessAlert ? "60px" : 0}
zIndex={1000}
bg={navbarBg}
backdropFilter="blur(10px)"
borderBottom="1px"
borderColor={navbarBorder}
py={{ base: 2, md: 3 }}
>
<Container maxW="container.xl" px={{ base: 3, md: 4 }}>
<Flex justify="space-between" align="center">
{/* Logo - 价小前投研 */}
<BrandLogo />
{/* 中间导航区域 - 响应式 */}
{isMobile ? (
// 移动端:汉堡菜单
<IconButton
icon={<HamburgerIcon />}
variant="ghost"
onClick={onOpen}
aria-label="Open menu"
/>
) : isTablet ? (
// 中屏(平板):"更多"下拉菜单
<MoreNavMenu isAuthenticated={isAuthenticated} user={user} />
) : (
// 大屏(桌面):完整导航菜单
<NavItems isAuthenticated={isAuthenticated} user={user} />
)}
{/* 右侧:日夜模式切换 + 登录/用户区 */}
<HStack spacing={{ base: 2, md: 4 }}>
<IconButton
aria-label="切换主题"
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
onClick={() => {
// 🎯 追踪主题切换
const fromTheme = colorMode;
const toTheme = colorMode === 'light' ? 'dark' : 'light';
navEvents.trackThemeChanged(fromTheme, toTheme);
toggleColorMode();
}}
variant="ghost"
size="sm"
minW={{ base: '36px', md: '40px' }}
minH={{ base: '36px', md: '40px' }}
/>
{/* 显示加载状态 */}
{isLoading ? (
<Spinner size="sm" color="blue.500" />
) : isAuthenticated && user ? (
// 已登录状态 - 用户菜单 + 功能菜单排列
<HStack spacing={{ base: 2, md: 3 }}>
{/* 投资日历 - 仅大屏显示 */}
{isDesktop && <CalendarButton />}
{/* 自选股 - 仅大屏显示 */}
{isDesktop && (
<Menu onOpen={loadWatchlistQuotes}>
<MenuButton
as={Button}
size="sm"
colorScheme="teal"
variant="solid"
borderRadius="full"
rightIcon={<ChevronDownIcon />}
leftIcon={<FiStar />}
>
自选股
{watchlistQuotes && watchlistQuotes.length > 0 && (
<Badge ml={2} colorScheme="whiteAlpha">{watchlistQuotes.length}</Badge>
)}
</MenuButton>
<MenuList minW="380px">
<Box px={4} py={2}>
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.300')}>我的自选股</Text>
</Box>
{watchlistLoading ? (
<Box px={4} py={3}>
<HStack>
<Spinner size="sm" />
<Text fontSize="sm" color={useColorModeValue('gray.500', 'gray.300')}>加载中...</Text>
</HStack>
</Box>
) : (
<>
{(!watchlistQuotes || watchlistQuotes.length === 0) ? (
<Box px={4} py={3}>
<Text fontSize="sm" color={useColorModeValue('gray.500', 'gray.300')}>暂无自选股</Text>
</Box>
) : (
<VStack align="stretch" spacing={1} px={2} py={1}>
{watchlistQuotes
.slice((watchlistPage - 1) * WATCHLIST_PAGE_SIZE, watchlistPage * WATCHLIST_PAGE_SIZE)
.map((item) => (
<MenuItem key={item.stock_code} _hover={{ bg: 'gray.50' }} onClick={() => navigate(`/company?scode=${item.stock_code}`)}>
<HStack justify="space-between" w="100%">
<Box>
<Text fontSize="sm" fontWeight="medium">{item.stock_name || item.stock_code}</Text>
<Text fontSize="xs" color={useColorModeValue('gray.500', 'gray.400')}>{item.stock_code}</Text>
</Box>
<HStack>
<Badge
colorScheme={(item.change_percent || 0) > 0 ? 'red' : ((item.change_percent || 0) < 0 ? 'green' : 'gray')}
fontSize="xs"
>
{(item.change_percent || 0) > 0 ? '+' : ''}{(item.change_percent || 0).toFixed(2)}%
</Badge>
<Text fontSize="sm">{item.current_price?.toFixed ? item.current_price.toFixed(2) : (item.current_price || '-')}</Text>
<Button size="xs" variant="ghost" colorScheme="red" onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleRemoveFromWatchlist(item.stock_code); }}>取消</Button>
</HStack>
</HStack>
</MenuItem>
))}
</VStack>
)}
<MenuDivider />
<HStack justify="space-between" px={3} py={2}>
<HStack>
<Button size="xs" variant="outline" onClick={() => setWatchlistPage((p) => Math.max(1, p - 1))} isDisabled={watchlistPage <= 1}>上一页</Button>
<Text fontSize="xs" color={useColorModeValue('gray.600', 'gray.400')}>{watchlistPage} / {Math.max(1, Math.ceil((watchlistQuotes?.length || 0) / WATCHLIST_PAGE_SIZE))}</Text>
<Button size="xs" variant="outline" onClick={() => setWatchlistPage((p) => Math.min(Math.ceil((watchlistQuotes?.length || 0) / WATCHLIST_PAGE_SIZE) || 1, p + 1))} isDisabled={watchlistPage >= Math.ceil((watchlistQuotes?.length || 0) / WATCHLIST_PAGE_SIZE)}>下一页</Button>
</HStack>
<HStack>
<Button size="xs" variant="ghost" onClick={loadWatchlistQuotes}>刷新</Button>
<Button size="xs" colorScheme="teal" variant="ghost" onClick={() => navigate('/home/center')}>查看全部</Button>
</HStack>
</HStack>
</>
)}
</MenuList>
</Menu>
)}
{/* 关注的事件 - 仅大屏显示 */}
{isDesktop && (
<Menu onOpen={loadFollowingEvents}>
<MenuButton
as={Button}
size="sm"
colorScheme="purple"
variant="solid"
borderRadius="full"
rightIcon={<ChevronDownIcon />}
leftIcon={<FiCalendar />}
>
自选事件
{followingEvents && followingEvents.length > 0 && (
<Badge ml={2} colorScheme="whiteAlpha">{followingEvents.length}</Badge>
)}
</MenuButton>
<MenuList minW="460px">
<Box px={4} py={2}>
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.300')}>我关注的事件</Text>
</Box>
{eventsLoading ? (
<Box px={4} py={3}>
<HStack>
<Spinner size="sm" />
<Text fontSize="sm" color={useColorModeValue('gray.500', 'gray.300')}>加载中...</Text>
</HStack>
</Box>
) : (
<>
{(!followingEvents || followingEvents.length === 0) ? (
<Box px={4} py={3}>
<Text fontSize="sm" color={useColorModeValue('gray.500', 'gray.300')}>暂未关注任何事件</Text>
</Box>
) : (
<VStack align="stretch" spacing={1} px={2} py={1}>
{followingEvents
.slice((eventsPage - 1) * EVENTS_PAGE_SIZE, eventsPage * EVENTS_PAGE_SIZE)
.map((ev) => (
<MenuItem key={ev.id} _hover={{ bg: 'gray.50' }} onClick={() => navigate(`/event-detail/${ev.id}`)}>
<HStack justify="space-between" w="100%">
<Box>
<Text fontSize="sm" fontWeight="medium" noOfLines={1}>{ev.title}</Text>
<HStack spacing={2}>
{ev.event_type && (
<Badge colorScheme="blue" fontSize="xs">{ev.event_type}</Badge>
)}
{ev.start_time && (
<Text fontSize="xs" color={useColorModeValue('gray.500', 'gray.400')}>{new Date(ev.start_time).toLocaleString('zh-CN')}</Text>
)}
</HStack>
</Box>
<HStack>
{typeof ev.related_avg_chg === 'number' && (
<Badge colorScheme={ev.related_avg_chg > 0 ? 'red' : (ev.related_avg_chg < 0 ? 'green' : 'gray')} fontSize="xs">日均 {ev.related_avg_chg > 0 ? '+' : ''}{ev.related_avg_chg.toFixed(2)}%</Badge>
)}
{typeof ev.related_week_chg === 'number' && (
<Badge colorScheme={ev.related_week_chg > 0 ? 'red' : (ev.related_week_chg < 0 ? 'green' : 'gray')} fontSize="xs">周涨 {ev.related_week_chg > 0 ? '+' : ''}{ev.related_week_chg.toFixed(2)}%</Badge>
)}
<Button size="xs" variant="ghost" colorScheme="red" onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleUnfollowEvent(ev.id); }}>取消</Button>
</HStack>
</HStack>
</MenuItem>
))}
</VStack>
)}
<MenuDivider />
<HStack justify="space-between" px={3} py={2}>
<HStack>
<Button size="xs" variant="outline" onClick={() => setEventsPage((p) => Math.max(1, p - 1))} isDisabled={eventsPage <= 1}>上一页</Button>
<Text fontSize="xs" color={useColorModeValue('gray.600', 'gray.400')}>{eventsPage} / {Math.max(1, Math.ceil((followingEvents?.length || 0) / EVENTS_PAGE_SIZE))}</Text>
<Button size="xs" variant="outline" onClick={() => setEventsPage((p) => Math.min(Math.ceil((followingEvents?.length || 0) / EVENTS_PAGE_SIZE) || 1, p + 1))} isDisabled={eventsPage >= Math.ceil((followingEvents?.length || 0) / EVENTS_PAGE_SIZE)}>下一页</Button>
</HStack>
<HStack>
<Button size="xs" variant="ghost" onClick={loadFollowingEvents}>刷新</Button>
<Button size="xs" colorScheme="purple" variant="ghost" onClick={() => navigate('/home/center')}>前往个人中心</Button>
</HStack>
</HStack>
</>
)}
</MenuList>
</Menu>
)}
{/* 头像区域 - 响应式 (Phase 3 优化) */}
{isDesktop ? (
<DesktopUserMenu user={user} />
) : (
<TabletUserMenu
user={user}
handleLogout={handleLogout}
watchlistQuotes={watchlistQuotes}
followingEvents={followingEvents}
/>
)}
{/* 个人中心下拉菜单 - 仅大屏显示 */}
{isDesktop && (
<Menu>
<MenuButton
as={Button}
size="sm"
variant="ghost"
rightIcon={<ChevronDownIcon />}
_hover={{ bg: useColorModeValue('gray.100', 'gray.700') }}
>
个人中心
</MenuButton>
<MenuList>
<Box px={3} py={2} borderBottom="1px" borderColor="gray.200">
<Text fontSize="sm" fontWeight="bold">{getDisplayName()}</Text>
<Text fontSize="xs" color="gray.500">{user.email}</Text>
{user.phone && (
<Text fontSize="xs" color="gray.500">{user.phone}</Text>
)}
{user.has_wechat && (
<Badge size="sm" colorScheme="green" mt={1}>微信已绑定</Badge>
)}
</Box>
{/* 前往个人中心 */}
<MenuItem icon={<FiHome />} onClick={() => navigate('/home/center')}>
前往个人中心
</MenuItem>
<MenuDivider />
{/* 账户管理组 */}
<MenuItem icon={<FiUser />} onClick={() => navigate('/home/profile')}>
个人资料
</MenuItem>
<MenuItem icon={<FiSettings />} onClick={() => navigate('/home/settings')}>
账户设置
</MenuItem>
<MenuDivider />
{/* 功能入口组 */}
<MenuItem icon={<FaCrown />} onClick={() => navigate('/home/pages/account/subscription')}>
订阅管理
</MenuItem>
<MenuDivider />
{/* 退出 */}
<MenuItem icon={<FiLogOut />} onClick={handleLogout} color="red.500">
退出登录
</MenuItem>
</MenuList>
</Menu>
)}
</HStack>
) : (
// 未登录状态 - 单一按钮
<LoginButton />
)}
</HStack>
</Flex>
</Container>
{/* 移动端抽屉菜单 */}
<Drawer isOpen={isOpen} placement="right" onClose={onClose}>
<DrawerOverlay />
<DrawerContent>
<DrawerCloseButton />
<DrawerHeader>
<HStack>
<Text>菜单</Text>
{isAuthenticated && user && (
<Badge colorScheme="green" ml={2}>已登录</Badge>
)}
</HStack>
</DrawerHeader>
<DrawerBody>
<VStack spacing={4} align="stretch">
{/* 移动端:日夜模式切换 */}
<Button
leftIcon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
variant="ghost"
justifyContent="flex-start"
onClick={toggleColorMode}
size="sm"
>
切换到{colorMode === 'light' ? '深色' : '浅色'}模式
</Button>
{/* 移动端用户信息 */}
{isAuthenticated && user && (
<>
<Box p={3} bg={useColorModeValue('gray.50', 'whiteAlpha.100')} borderRadius="md">
<HStack>
<Avatar
size="sm"
name={getDisplayName()}
src={user.avatar_url}
bg="blue.500"
/>
<Box>
<Text fontSize="sm" fontWeight="bold">{getDisplayName()}</Text>
<Text fontSize="xs" color={useColorModeValue('gray.500', 'gray.300')}>{user.email}</Text>
</Box>
</HStack>
</Box>
<Divider />
</>
)}
{/* 首页链接 */}
<Link
onClick={() => {
navigate('/home');
onClose();
}}
py={2}
px={3}
borderRadius="md"
_hover={{ bg: 'gray.100' }}
cursor="pointer"
color="blue.500"
fontWeight="bold"
bg={location.pathname === '/home' ? 'blue.50' : 'transparent'}
borderLeft={location.pathname === '/home' ? '3px solid' : 'none'}
borderColor="blue.600"
>
<Text fontSize="md">🏠 首页</Text>
</Link>
<Divider />
<Box>
<Text fontWeight="bold" mb={2}>高频跟踪</Text>
<VStack spacing={2} align="stretch">
<Link
onClick={() => {
navigate('/community');
onClose();
}}
py={1}
px={3}
borderRadius="md"
_hover={{ bg: 'gray.100' }}
cursor="pointer"
bg={location.pathname.includes('/community') ? 'blue.50' : 'transparent'}
borderLeft={location.pathname.includes('/community') ? '3px solid' : 'none'}
borderColor="blue.600"
fontWeight={location.pathname.includes('/community') ? 'bold' : 'normal'}
>
<HStack justify="space-between">
<Text fontSize="sm">事件中心</Text>
<HStack spacing={1}>
<Badge size="xs" colorScheme="green">HOT</Badge>
<Badge size="xs" colorScheme="red">NEW</Badge>
</HStack>
</HStack>
</Link>
<Link
onClick={() => {
navigate('/concepts');
onClose();
}}
py={1}
px={3}
borderRadius="md"
_hover={{ bg: 'gray.100' }}
cursor="pointer"
bg={location.pathname.includes('/concepts') ? 'blue.50' : 'transparent'}
borderLeft={location.pathname.includes('/concepts') ? '3px solid' : 'none'}
borderColor="blue.600"
fontWeight={location.pathname.includes('/concepts') ? 'bold' : 'normal'}
>
<HStack justify="space-between">
<Text fontSize="sm">概念中心</Text>
<Badge size="xs" colorScheme="red">NEW</Badge>
</HStack>
</Link>
</VStack>
</Box>
<Divider />
<Box>
<Text fontWeight="bold" mb={2}>行情复盘</Text>
<VStack spacing={2} align="stretch">
<Link
onClick={() => {
navigate('/limit-analyse');
onClose();
}}
py={1}
px={3}
borderRadius="md"
_hover={{ bg: 'gray.100' }}
cursor="pointer"
bg={location.pathname.includes('/limit-analyse') ? 'blue.50' : 'transparent'}
borderLeft={location.pathname.includes('/limit-analyse') ? '3px solid' : 'none'}
borderColor="blue.600"
fontWeight={location.pathname.includes('/limit-analyse') ? 'bold' : 'normal'}
>
<HStack justify="space-between">
<Text fontSize="sm">涨停分析</Text>
<Badge size="xs" colorScheme="blue">FREE</Badge>
</HStack>
</Link>
<Link
onClick={() => {
navigate('/stocks');
onClose();
}}
py={1}
px={3}
borderRadius="md"
_hover={{ bg: 'gray.100' }}
cursor="pointer"
bg={location.pathname.includes('/stocks') ? 'blue.50' : 'transparent'}
borderLeft={location.pathname.includes('/stocks') ? '3px solid' : 'none'}
borderColor="blue.600"
fontWeight={location.pathname.includes('/stocks') ? 'bold' : 'normal'}
>
<HStack justify="space-between">
<Text fontSize="sm">个股中心</Text>
<Badge size="xs" colorScheme="green">HOT</Badge>
</HStack>
</Link>
<Link
onClick={() => {
navigate('/trading-simulation');
onClose();
}}
py={1}
px={3}
borderRadius="md"
_hover={{ bg: 'gray.100' }}
cursor="pointer"
bg={location.pathname.includes('/trading-simulation') ? 'blue.50' : 'transparent'}
borderLeft={location.pathname.includes('/trading-simulation') ? '3px solid' : 'none'}
borderColor="blue.600"
fontWeight={location.pathname.includes('/trading-simulation') ? 'bold' : 'normal'}
>
<HStack justify="space-between">
<Text fontSize="sm">模拟盘</Text>
<Badge size="xs" colorScheme="red">NEW</Badge>
</HStack>
</Link>
</VStack>
</Box>
<Divider />
<Box>
<Text fontWeight="bold" mb={2}>AGENT社群</Text>
<VStack spacing={2} align="stretch">
<Link
py={1}
px={3}
borderRadius="md"
_hover={{}}
cursor="not-allowed"
color="gray.400"
pointerEvents="none"
>
<Text fontSize="sm" color="gray.400">今日热议</Text>
</Link>
<Link
py={1}
px={3}
borderRadius="md"
_hover={{}}
cursor="not-allowed"
color="gray.400"
pointerEvents="none"
>
<Text fontSize="sm" color="gray.400">个股社区</Text>
</Link>
</VStack>
</Box>
<Divider />
<Box>
<Text fontWeight="bold" mb={2}>联系我们</Text>
<Text fontSize="sm" color={useColorModeValue('gray.500', 'gray.300')}>敬请期待</Text>
</Box>
{/* 移动端登录/登出按钮 */}
<Divider />
{isAuthenticated && user ? (
<Button
colorScheme="red"
variant="outline"
size="sm"
onClick={() => {
handleLogout();
onClose();
}}
>
🚪 退出登录
</Button>
) : (
<Button
colorScheme="blue"
size="sm"
onClick={() => {
openAuthModal();
onClose();
}}
>
🔐 登录 / 注册
</Button>
)}
</VStack>
</DrawerBody>
</DrawerContent>
</Drawer>
</Box>
{/* 二级导航栏 - 显示当前页面所属的二级菜单 */}
{!isMobile && <SecondaryNav showCompletenessAlert={showCompletenessAlert} />}
{/* 投资日历 Modal - 已移至 CalendarButton 组件内部 */}
</>
);
}