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>
This commit is contained in:
zdl
2025-10-30 17:01:01 +08:00
parent 4496d00e82
commit 77440f78a7
5 changed files with 377 additions and 151 deletions

View File

@@ -31,7 +31,6 @@ import {
useColorMode, useColorMode,
useColorModeValue, useColorModeValue,
useToast, useToast,
Tooltip,
Modal, Modal,
ModalOverlay, ModalOverlay,
ModalContent, ModalContent,
@@ -48,8 +47,6 @@ import { useAuthModal } from '../../hooks/useAuthModal';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { getApiBase } from '../../utils/apiConfig'; import { getApiBase } from '../../utils/apiConfig';
import SubscriptionButton from '../Subscription/SubscriptionButton'; import SubscriptionButton from '../Subscription/SubscriptionButton';
import SubscriptionModal from '../Subscription/SubscriptionModal';
import { CrownIcon, TooltipContent } from '../Subscription/CrownTooltip';
import { useNavigationEvents } from '../../hooks/useNavigationEvents'; import { useNavigationEvents } from '../../hooks/useNavigationEvents';
// Phase 1 优化: 提取的子组件 // Phase 1 优化: 提取的子组件
@@ -60,6 +57,9 @@ import CalendarButton from './components/CalendarButton';
// Phase 2 优化: 使用 Redux 管理订阅数据 // Phase 2 优化: 使用 Redux 管理订阅数据
import { useSubscription } from '../../hooks/useSubscription'; import { useSubscription } from '../../hooks/useSubscription';
// Phase 3 优化: 提取的用户菜单组件
import { DesktopUserMenu, TabletUserMenu } from './components/UserMenu';
/** 二级导航栏组件 - 显示当前一级菜单下的所有二级菜单项 */ /** 二级导航栏组件 - 显示当前一级菜单下的所有二级菜单项 */
const SecondaryNav = ({ showCompletenessAlert }) => { const SecondaryNav = ({ showCompletenessAlert }) => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -1069,156 +1069,16 @@ export default function HomeNavbar() {
</Menu> </Menu>
)} )}
{/* 头像区域 - 响应式 */} {/* 头像区域 - 响应式 (Phase 3 优化) */}
{isDesktop ? ( {isDesktop ? (
// 大屏:头像点击打开订阅弹窗 <DesktopUserMenu user={user} />
<>
<Tooltip
label={<TooltipContent subscriptionInfo={subscriptionInfo} />}
placement="bottom"
hasArrow
bg={useColorModeValue('white', 'gray.800')}
borderRadius="lg"
border="1px solid"
borderColor={useColorModeValue('gray.200', 'gray.600')}
boxShadow="lg"
p={3}
>
<Box
position="relative"
cursor="pointer"
onClick={openSubscriptionModal}
>
<CrownIcon subscriptionInfo={subscriptionInfo} />
<Avatar
size="sm"
name={getDisplayName()}
src={user.avatar_url}
bg="blue.500"
border={subscriptionInfo.type !== 'free' ? '2.5px solid' : 'none'}
borderColor={
subscriptionInfo.type === 'max' ? '#667eea' :
subscriptionInfo.type === 'pro' ? '#667eea' : 'transparent'
}
_hover={{
transform: 'scale(1.05)',
boxShadow: subscriptionInfo.type !== 'free'
? '0 4px 12px rgba(102, 126, 234, 0.4)'
: 'md',
}}
transition="all 0.2s"
/>
</Box>
</Tooltip>
{isSubscriptionModalOpen && (
<SubscriptionModal
isOpen={isSubscriptionModalOpen}
onClose={closeSubscriptionModal}
subscriptionInfo={subscriptionInfo}
/>
)}
</>
) : ( ) : (
// 中屏:头像作为下拉菜单,包含所有功能 <TabletUserMenu
<Menu> user={user}
<MenuButton> handleLogout={handleLogout}
<Box position="relative"> watchlistQuotes={watchlistQuotes}
<CrownIcon subscriptionInfo={subscriptionInfo} /> followingEvents={followingEvents}
<Avatar
size="sm"
name={getDisplayName()}
src={user.avatar_url}
bg="blue.500"
border={subscriptionInfo.type !== 'free' ? '2.5px solid' : 'none'}
borderColor={
subscriptionInfo.type === 'max' ? '#667eea' :
subscriptionInfo.type === 'pro' ? '#667eea' : 'transparent'
}
_hover={{ transform: 'scale(1.05)' }}
transition="all 0.2s"
/> />
</Box>
</MenuButton>
<MenuList minW="320px">
{/* 用户信息区 */}
<Box px={3} py={2} borderBottom="1px" borderColor={useColorModeValue('gray.200', 'gray.600')}>
<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={<FaCrown />} onClick={openSubscriptionModal}>
<Flex justify="space-between" align="center" w="100%">
<Text>订阅管理</Text>
<Badge colorScheme={subscriptionInfo.type === 'free' ? 'gray' : 'purple'}>
{subscriptionInfo.type === 'max' ? 'MAX' :
subscriptionInfo.type === 'pro' ? 'PRO' : '免费版'}
</Badge>
</Flex>
</MenuItem>
{isSubscriptionModalOpen && (
<SubscriptionModal
isOpen={isSubscriptionModalOpen}
onClose={closeSubscriptionModal}
subscriptionInfo={subscriptionInfo}
/>
)}
<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%">
<Text>我的自选股</Text>
{watchlistQuotes && watchlistQuotes.length > 0 && (
<Badge>{watchlistQuotes.length}</Badge>
)}
</Flex>
</MenuItem>
{/* 自选事件 */}
<MenuItem icon={<FiCalendar />} onClick={() => navigate('/home/center')}>
<Flex justify="space-between" align="center" w="100%">
<Text>我的自选事件</Text>
{followingEvents && followingEvents.length > 0 && (
<Badge>{followingEvents.length}</Badge>
)}
</Flex>
</MenuItem>
<MenuDivider />
{/* 个人中心 */}
<MenuItem icon={<FiHome />} onClick={() => navigate('/home/center')}>
个人中心
</MenuItem>
<MenuItem icon={<FiUser />} onClick={() => navigate('/home/profile')}>
个人资料
</MenuItem>
<MenuItem icon={<FiSettings />} onClick={() => navigate('/home/settings')}>
账户设置
</MenuItem>
<MenuDivider />
{/* 退出登录 */}
<MenuItem icon={<FiLogOut />} onClick={handleLogout} color="red.500">
退出登录
</MenuItem>
</MenuList>
</Menu>
)} )}
{/* 个人中心下拉菜单 - 仅大屏显示 */} {/* 个人中心下拉菜单 - 仅大屏显示 */}

View File

@@ -0,0 +1,93 @@
// src/components/Navbars/components/UserMenu/DesktopUserMenu.js
// 桌面版用户菜单 - 头像 + Tooltip + 订阅弹窗
import React, { memo } from 'react';
import { Tooltip, useColorModeValue } from '@chakra-ui/react';
import UserAvatar from './UserAvatar';
import SubscriptionModal from '../../../Subscription/SubscriptionModal';
import { useSubscription } from '../../../../hooks/useSubscription';
/**
* Tooltip 内容组件
* 显示用户订阅信息和剩余天数
*/
const TooltipContent = memo(({ subscriptionInfo }) => {
const getSubscriptionBadgeText = () => {
if (!subscriptionInfo || !subscriptionInfo.type) {
return '免费版';
}
const type = subscriptionInfo.type.toLowerCase();
switch (type) {
case 'max':
return subscriptionInfo.is_active
? `Max版 (剩余 ${subscriptionInfo.days_left || 0} 天)`
: 'Max版 (已过期)';
case 'pro':
return subscriptionInfo.is_active
? `Pro版 (剩余 ${subscriptionInfo.days_left || 0} 天)`
: 'Pro版 (已过期)';
case 'free':
default:
return '免费版 (点击升级)';
}
};
return getSubscriptionBadgeText();
});
TooltipContent.displayName = 'TooltipContent';
/**
* 桌面版用户菜单组件
* 大屏幕 (md+) 显示,头像点击打开订阅弹窗
*
* @param {Object} props
* @param {Object} props.user - 用户信息
*/
const DesktopUserMenu = memo(({ user }) => {
const {
subscriptionInfo,
isSubscriptionModalOpen,
openSubscriptionModal,
closeSubscriptionModal
} = useSubscription();
const tooltipBg = useColorModeValue('white', 'gray.800');
const tooltipBorderColor = useColorModeValue('gray.200', 'gray.600');
return (
<>
<Tooltip
label={<TooltipContent subscriptionInfo={subscriptionInfo} />}
placement="bottom"
hasArrow
bg={tooltipBg}
borderRadius="lg"
border="1px solid"
borderColor={tooltipBorderColor}
boxShadow="lg"
p={3}
>
<UserAvatar
user={user}
subscriptionInfo={subscriptionInfo}
onClick={openSubscriptionModal}
/>
</Tooltip>
{isSubscriptionModalOpen && (
<SubscriptionModal
isOpen={isSubscriptionModalOpen}
onClose={closeSubscriptionModal}
subscriptionInfo={subscriptionInfo}
/>
)}
</>
);
});
DesktopUserMenu.displayName = 'DesktopUserMenu';
export default DesktopUserMenu;

View File

@@ -0,0 +1,166 @@
// src/components/Navbars/components/UserMenu/TabletUserMenu.js
// 平板版用户菜单 - 头像作为下拉菜单,包含所有功能
import React, { memo } from 'react';
import {
Menu,
MenuButton,
MenuList,
MenuItem,
MenuDivider,
Box,
Text,
Badge,
Flex,
useColorModeValue
} from '@chakra-ui/react';
import { FiStar, FiCalendar, FiUser, FiSettings, FiHome, FiLogOut } from 'react-icons/fi';
import { FaCrown } from 'react-icons/fa';
import { useNavigate } from 'react-router-dom';
import UserAvatar from './UserAvatar';
import SubscriptionModal from '../../../Subscription/SubscriptionModal';
import { useSubscription } from '../../../../hooks/useSubscription';
/**
* 平板版用户菜单组件
* 中屏幕 (sm-md) 显示,头像作为下拉菜单,包含所有功能
*
* @param {Object} props
* @param {Object} props.user - 用户信息
* @param {Function} props.handleLogout - 退出登录回调
* @param {Array} props.watchlistQuotes - 自选股列表
* @param {Array} props.followingEvents - 自选事件列表
*/
const TabletUserMenu = memo(({
user,
handleLogout,
watchlistQuotes,
followingEvents
}) => {
const navigate = useNavigate();
const {
subscriptionInfo,
isSubscriptionModalOpen,
openSubscriptionModal,
closeSubscriptionModal
} = useSubscription();
const borderColor = useColorModeValue('gray.200', 'gray.600');
// 获取显示名称
const getDisplayName = () => {
if (user.nickname) return user.nickname;
if (user.username) return user.username;
if (user.email) return user.email.split('@')[0];
if (user.phone) return user.phone;
return '用户';
};
// 获取订阅标签
const getSubscriptionBadge = () => {
if (subscriptionInfo.type === 'max') return 'MAX';
if (subscriptionInfo.type === 'pro') return 'PRO';
return '免费版';
};
// 获取订阅标签颜色
const getSubscriptionBadgeColor = () => {
return subscriptionInfo.type === 'free' ? 'gray' : 'purple';
};
return (
<>
<Menu>
<MenuButton>
<UserAvatar
user={user}
subscriptionInfo={subscriptionInfo}
/>
</MenuButton>
<MenuList minW="320px">
{/* 用户信息区 */}
<Box px={3} py={2} borderBottom="1px" borderColor={borderColor}>
<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={<FaCrown />} onClick={openSubscriptionModal}>
<Flex justify="space-between" align="center" w="100%">
<Text>订阅管理</Text>
<Badge colorScheme={getSubscriptionBadgeColor()}>
{getSubscriptionBadge()}
</Badge>
</Flex>
</MenuItem>
<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%">
<Text>我的自选股</Text>
{watchlistQuotes && watchlistQuotes.length > 0 && (
<Badge>{watchlistQuotes.length}</Badge>
)}
</Flex>
</MenuItem>
{/* 自选事件 */}
<MenuItem icon={<FiCalendar />} onClick={() => navigate('/home/center')}>
<Flex justify="space-between" align="center" w="100%">
<Text>我的自选事件</Text>
{followingEvents && followingEvents.length > 0 && (
<Badge>{followingEvents.length}</Badge>
)}
</Flex>
</MenuItem>
<MenuDivider />
{/* 个人中心 */}
<MenuItem icon={<FiHome />} onClick={() => navigate('/home/center')}>
个人中心
</MenuItem>
<MenuItem icon={<FiUser />} onClick={() => navigate('/home/profile')}>
个人资料
</MenuItem>
<MenuItem icon={<FiSettings />} onClick={() => navigate('/home/settings')}>
账户设置
</MenuItem>
<MenuDivider />
{/* 退出登录 */}
<MenuItem icon={<FiLogOut />} onClick={handleLogout} color="red.500">
退出登录
</MenuItem>
</MenuList>
</Menu>
{/* 订阅弹窗 */}
{isSubscriptionModalOpen && (
<SubscriptionModal
isOpen={isSubscriptionModalOpen}
onClose={closeSubscriptionModal}
subscriptionInfo={subscriptionInfo}
/>
)}
</>
);
});
TabletUserMenu.displayName = 'TabletUserMenu';
export default TabletUserMenu;

View File

@@ -0,0 +1,101 @@
// src/components/Navbars/components/UserMenu/UserAvatar.js
// 用户头像组件 - 带皇冠图标和订阅边框
import React, { memo } from 'react';
import { Box, Avatar } from '@chakra-ui/react';
import { FaCrown } from 'react-icons/fa';
/**
* 皇冠图标组件
* @param {Object} props.subscriptionInfo - 订阅信息
*/
const CrownIcon = memo(({ subscriptionInfo }) => {
if (!subscriptionInfo || subscriptionInfo.type === 'free') {
return null;
}
const crownColor = subscriptionInfo.type === 'max'
? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
: '#667eea';
return (
<Box
position="absolute"
top="-4px"
right="-4px"
zIndex={2}
fontSize="14px"
background={crownColor}
borderRadius="full"
p="3px"
boxShadow="0 2px 8px rgba(102, 126, 234, 0.4)"
>
<FaCrown color="white" />
</Box>
);
});
CrownIcon.displayName = 'CrownIcon';
/**
* 用户头像组件
* 包含皇冠图标和订阅边框样式
*
* @param {Object} props
* @param {Object} props.user - 用户信息
* @param {Object} props.subscriptionInfo - 订阅信息
* @param {string} props.size - 头像大小 (默认 'sm')
* @param {Function} props.onClick - 点击回调
* @param {Object} props.hoverStyle - 悬停样式
*/
const UserAvatar = memo(({
user,
subscriptionInfo,
size = 'sm',
onClick,
hoverStyle = {}
}) => {
// 获取显示名称
const getDisplayName = () => {
if (user.nickname) return user.nickname;
if (user.username) return user.username;
if (user.email) return user.email.split('@')[0];
if (user.phone) return user.phone;
return '用户';
};
// 边框颜色
const getBorderColor = () => {
if (subscriptionInfo.type === 'max') return '#667eea';
if (subscriptionInfo.type === 'pro') return '#667eea';
return 'transparent';
};
// 默认悬停样式
const defaultHoverStyle = {
transform: 'scale(1.05)',
boxShadow: subscriptionInfo.type !== 'free'
? '0 4px 12px rgba(102, 126, 234, 0.4)'
: 'md',
};
return (
<Box position="relative" cursor={onClick ? 'pointer' : 'default'} onClick={onClick}>
<CrownIcon subscriptionInfo={subscriptionInfo} />
<Avatar
size={size}
name={getDisplayName()}
src={user.avatar_url}
bg="blue.500"
border={subscriptionInfo.type !== 'free' ? '2.5px solid' : 'none'}
borderColor={getBorderColor()}
_hover={onClick ? { ...defaultHoverStyle, ...hoverStyle } : undefined}
transition="all 0.2s"
/>
</Box>
);
});
UserAvatar.displayName = 'UserAvatar';
export default UserAvatar;

View File

@@ -0,0 +1,6 @@
// src/components/Navbars/components/UserMenu/index.js
// 用户菜单组件统一导出
export { default as UserAvatar } from './UserAvatar';
export { default as DesktopUserMenu } from './DesktopUserMenu';
export { default as TabletUserMenu } from './TabletUserMenu';