**背景**
继 Phase 1-4 后,进一步优化 HomeNavbar 的移动端菜单结构
**重构内容**
1. **新增组件目录** `src/components/Navbars/components/MobileDrawer/`
- MobileDrawer.js (314行) - 移动端完整抽屉菜单
* 用户信息展示
* 日夜模式切换
* 完整导航菜单(高频跟踪、行情复盘、AGENT社群、联系我们)
* 登录/退出登录按钮
- index.js - 统一导出
2. **HomeNavbar.js 优化**
- 删除 ~262 行移动端 Drawer JSX 代码
- 精简 Chakra UI 导入(移除 Drawer、DrawerBody、DrawerHeader 等 12 个组件)
- 替换为 MobileDrawer 组件调用
- 1065 → 815 行 (-250行, -23%)
**技术亮点**
- React.memo 优化渲染性能
- 封装导航点击逻辑(handleNavigate)
- 独立管理主题切换状态
- 响应式颜色模式(useColorModeValue)
- 完整的用户状态判断和 UI 展示
**累计成果** (Phase 1-5)
- 原始: 1623 行
- 当前: 815 行
- 减少: 808 行 (-50%)
- 提取: 11 个组件
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
816 lines
44 KiB
JavaScript
816 lines
44 KiB
JavaScript
import React, { useCallback, useState } from 'react';
|
||
import {
|
||
Box,
|
||
Flex,
|
||
Text,
|
||
Button,
|
||
Container,
|
||
useDisclosure,
|
||
HStack,
|
||
Icon,
|
||
Menu,
|
||
MenuButton,
|
||
MenuList,
|
||
MenuItem,
|
||
Badge,
|
||
Grid,
|
||
IconButton,
|
||
useBreakpointValue,
|
||
Spinner,
|
||
useColorMode,
|
||
useColorModeValue,
|
||
useToast,
|
||
} 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';
|
||
|
||
// Phase 4 优化: 提取的导航菜单组件
|
||
import { DesktopNav, MoreMenu, PersonalCenterMenu } from './components/Navigation';
|
||
|
||
// Phase 5 优化: 提取的移动端抽屉菜单组件
|
||
import { MobileDrawer } from './components/MobileDrawer';
|
||
|
||
/** 二级导航栏组件 - 显示当前一级菜单下的所有二级菜单项 */
|
||
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>
|
||
);
|
||
};
|
||
|
||
/** 中屏"更多"菜单 - 用于平板和小笔记本 */
|
||
// Phase 4: MoreNavMenu 和 NavItems 组件已提取到 Navigation 目录
|
||
|
||
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 />
|
||
|
||
{/* 中间导航区域 - 响应式 (Phase 4 优化) */}
|
||
{isMobile ? (
|
||
// 移动端:汉堡菜单
|
||
<IconButton
|
||
icon={<HamburgerIcon />}
|
||
variant="ghost"
|
||
onClick={onOpen}
|
||
aria-label="Open menu"
|
||
/>
|
||
) : isTablet ? (
|
||
// 中屏(平板):"更多"下拉菜单
|
||
<MoreMenu isAuthenticated={isAuthenticated} user={user} />
|
||
) : (
|
||
// 大屏(桌面):完整导航菜单
|
||
<DesktopNav 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}
|
||
/>
|
||
)}
|
||
|
||
{/* 个人中心下拉菜单 - 仅大屏显示 (Phase 4 优化) */}
|
||
{isDesktop && (
|
||
<PersonalCenterMenu user={user} handleLogout={handleLogout} />
|
||
)}
|
||
</HStack>
|
||
) : (
|
||
// 未登录状态 - 单一按钮
|
||
<LoginButton />
|
||
)}
|
||
</HStack>
|
||
</Flex>
|
||
</Container>
|
||
|
||
{/* 移动端抽屉菜单 (Phase 5 优化) */}
|
||
<MobileDrawer
|
||
isOpen={isOpen}
|
||
onClose={onClose}
|
||
isAuthenticated={isAuthenticated}
|
||
user={user}
|
||
handleLogout={handleLogout}
|
||
openAuthModal={openAuthModal}
|
||
/>
|
||
</Box>
|
||
|
||
{/* 二级导航栏 - 显示当前页面所属的二级菜单 */}
|
||
{!isMobile && <SecondaryNav showCompletenessAlert={showCompletenessAlert} />}
|
||
|
||
{/* 投资日历 Modal - 已移至 CalendarButton 组件内部 */}
|
||
</>
|
||
);
|
||
} |