977 lines
53 KiB
JavaScript
977 lines
53 KiB
JavaScript
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,
|
||
} from '@chakra-ui/react';
|
||
import { ChevronDownIcon, HamburgerIcon, SunIcon, MoonIcon } from '@chakra-ui/icons';
|
||
import { FiStar, FiCalendar } from 'react-icons/fi';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { useAuth } from '../../contexts/AuthContext';
|
||
|
||
/** 桌面端导航 - 完全按照原网站
|
||
* @TODO 添加逻辑 不展示导航case
|
||
* 1.未登陆状态 && 是首页
|
||
* 2. !isMobile
|
||
*/
|
||
const NavItems = ({ isAuthenticated, user }) => {
|
||
const navigate = useNavigate();
|
||
|
||
if (!isAuthenticated && !user) {
|
||
return (
|
||
<HStack spacing={8}>
|
||
<Menu>
|
||
<MenuButton as={Button} variant="ghost" rightIcon={<ChevronDownIcon />}>
|
||
高频跟踪
|
||
</MenuButton>
|
||
<MenuList minW="260px" p={2}>
|
||
<VStack spacing={1} align="stretch">
|
||
<Link
|
||
onClick={() => navigate('/community')}
|
||
py={2}
|
||
px={3}
|
||
borderRadius="md"
|
||
_hover={{ bg: 'gray.100' }}
|
||
cursor="pointer"
|
||
>
|
||
<Flex justify="space-between" align="center">
|
||
<Text fontSize="sm">新闻催化分析</Text>
|
||
<HStack spacing={1}>
|
||
<Badge size="sm" colorScheme="green">HOT</Badge>
|
||
<Badge size="sm" colorScheme="red">NEW</Badge>
|
||
</HStack>
|
||
</Flex>
|
||
</Link>
|
||
<Link
|
||
onClick={() => navigate('/concepts')}
|
||
py={2}
|
||
px={3}
|
||
borderRadius="md"
|
||
_hover={{ bg: 'gray.100' }}
|
||
cursor="pointer"
|
||
>
|
||
<Flex justify="space-between" align="center">
|
||
<Text fontSize="sm">概念中心</Text>
|
||
<Badge size="sm" colorScheme="red">NEW</Badge>
|
||
</Flex>
|
||
</Link>
|
||
</VStack>
|
||
</MenuList>
|
||
</Menu>
|
||
|
||
<Menu>
|
||
<MenuButton as={Button} variant="ghost" rightIcon={<ChevronDownIcon />}>
|
||
行情复盘
|
||
</MenuButton>
|
||
<MenuList minW="260px" p={2}>
|
||
<VStack spacing={1} align="stretch">
|
||
<Link
|
||
onClick={() => navigate('/limit-analyse')}
|
||
py={2}
|
||
px={3}
|
||
borderRadius="md"
|
||
_hover={{ bg: 'gray.100' }}
|
||
cursor="pointer"
|
||
>
|
||
<Flex justify="space-between" align="center">
|
||
<Text fontSize="sm">涨停分析</Text>
|
||
<Badge size="sm" colorScheme="blue">FREE</Badge>
|
||
</Flex>
|
||
</Link>
|
||
<Link
|
||
onClick={() => navigate('/stocks')}
|
||
py={2}
|
||
px={3}
|
||
borderRadius="md"
|
||
_hover={{ bg: 'gray.100' }}
|
||
cursor="pointer"
|
||
>
|
||
<Flex justify="space-between" align="center">
|
||
<Text fontSize="sm">个股中心</Text>
|
||
<Badge size="sm" colorScheme="green">HOT</Badge>
|
||
</Flex>
|
||
</Link>
|
||
<Link
|
||
href="https://valuefrontier.cn/trading-simulation"
|
||
isExternal
|
||
py={2}
|
||
px={3}
|
||
borderRadius="md"
|
||
_hover={{ bg: 'gray.100' }}
|
||
>
|
||
<Flex justify="space-between" align="center">
|
||
<Text fontSize="sm">模拟盘</Text>
|
||
<Badge size="sm" colorScheme="red">NEW</Badge>
|
||
</Flex>
|
||
</Link>
|
||
</VStack>
|
||
</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}>
|
||
<VStack spacing={2} align="stretch">
|
||
<Link
|
||
py={2}
|
||
px={3}
|
||
borderRadius="md"
|
||
_hover={{}}
|
||
cursor="not-allowed"
|
||
color="gray.400"
|
||
pointerEvents="none"
|
||
>
|
||
<Text fontSize="sm" color="gray.400">今日热议</Text>
|
||
</Link>
|
||
<Link
|
||
py={2}
|
||
px={3}
|
||
borderRadius="md"
|
||
_hover={{}}
|
||
cursor="not-allowed"
|
||
color="gray.400"
|
||
pointerEvents="none"
|
||
>
|
||
<Text fontSize="sm" color="gray.400">个股社区</Text>
|
||
</Link>
|
||
</VStack>
|
||
</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={useColorModeValue('gray.500', 'gray.300')}>敬请期待</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 { user, isAuthenticated, logout, isLoading } = useAuth();
|
||
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();
|
||
|
||
// 添加调试信息
|
||
console.log('HomeNavbar Debug:', {
|
||
user,
|
||
isAuthenticated,
|
||
isLoading,
|
||
userKeys: user ? Object.keys(user) : 'no user'
|
||
});
|
||
|
||
// 获取显示名称的函数
|
||
const getDisplayName = () => {
|
||
if (!user) return '';
|
||
return user.nickname || user.username || user.name || user.email || '用户';
|
||
};
|
||
|
||
// 处理登出
|
||
const handleLogout = async () => {
|
||
try {
|
||
await logout();
|
||
// logout函数已经包含了跳转逻辑,这里不需要额外处理
|
||
} catch (error) {
|
||
console.error('Logout error:', error);
|
||
}
|
||
};
|
||
|
||
// 处理登录按钮点击
|
||
const handleLoginClick = () => {
|
||
navigate('/auth/signin');
|
||
};
|
||
|
||
// 检查是否为禁用的链接(没有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;
|
||
|
||
// 用户信息完整性状态
|
||
const [profileCompleteness, setProfileCompleteness] = useState(null);
|
||
const [showCompletenessAlert, setShowCompletenessAlert] = useState(false);
|
||
|
||
// 计算 API 基础地址(与 Center.js 一致的策略)
|
||
const getApiBase = () => (process.env.NODE_ENV === 'production' ? '' : (process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001'));
|
||
|
||
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) {
|
||
console.warn('加载自选股实时行情失败:', e);
|
||
setWatchlistQuotes([]);
|
||
} finally {
|
||
setWatchlistLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
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) {
|
||
console.warn('加载关注事件失败:', e);
|
||
setFollowingEvents([]);
|
||
} finally {
|
||
setEventsLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
// 从自选股移除
|
||
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 });
|
||
}
|
||
}, [getApiBase, toast, WATCHLIST_PAGE_SIZE]);
|
||
|
||
// 取消关注事件
|
||
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 });
|
||
}
|
||
}, [getApiBase, toast, EVENTS_PAGE_SIZE]);
|
||
|
||
// 检查用户资料完整性
|
||
const checkProfileCompleteness = useCallback(async () => {
|
||
if (!isAuthenticated || !user) return;
|
||
|
||
try {
|
||
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);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.warn('检查资料完整性失败:', error);
|
||
}
|
||
}, [isAuthenticated, user, getApiBase]);
|
||
|
||
// 用户登录后检查资料完整性
|
||
React.useEffect(() => {
|
||
if (isAuthenticated && user) {
|
||
// 延迟检查,避免过于频繁
|
||
const timer = setTimeout(checkProfileCompleteness, 1000);
|
||
return () => clearTimeout(timer);
|
||
}
|
||
}, [isAuthenticated, user, checkProfileCompleteness]);
|
||
|
||
return (
|
||
<>
|
||
{/* 资料完整性提醒横幅 */}
|
||
{showCompletenessAlert && profileCompleteness && (
|
||
<Box
|
||
bg="linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
||
color="white"
|
||
py={2}
|
||
px={4}
|
||
position="sticky"
|
||
top={0}
|
||
zIndex={1001}
|
||
>
|
||
<Container maxW="container.xl">
|
||
<HStack justify="space-between" align="center">
|
||
<HStack spacing={3}>
|
||
<Icon as={FiStar} />
|
||
<VStack spacing={0} align="start">
|
||
<Text fontSize="sm" fontWeight="bold">
|
||
完善资料,享受更好服务
|
||
</Text>
|
||
<Text fontSize="xs" opacity={0.9}>
|
||
您还需要设置:{profileCompleteness.missingItems.join('、')}
|
||
</Text>
|
||
</VStack>
|
||
<Text fontSize="xs" bg="whiteAlpha.300" px={2} py={1} borderRadius="full">
|
||
{profileCompleteness.completenessPercentage}% 完成
|
||
</Text>
|
||
</HStack>
|
||
<HStack spacing={2}>
|
||
<Button
|
||
size="sm"
|
||
colorScheme="whiteAlpha"
|
||
variant="ghost"
|
||
onClick={() => navigate('/home/settings')}
|
||
>
|
||
立即完善
|
||
</Button>
|
||
<IconButton
|
||
size="sm"
|
||
variant="ghost"
|
||
colorScheme="whiteAlpha"
|
||
icon={<Text>×</Text>}
|
||
onClick={() => setShowCompletenessAlert(false)}
|
||
aria-label="关闭提醒"
|
||
/>
|
||
</HStack>
|
||
</HStack>
|
||
</Container>
|
||
</Box>
|
||
)}
|
||
|
||
<Box
|
||
position="sticky"
|
||
top={showCompletenessAlert ? "60px" : 0}
|
||
zIndex={1000}
|
||
bg={navbarBg}
|
||
backdropFilter="blur(10px)"
|
||
borderBottom="1px"
|
||
borderColor={navbarBorder}
|
||
py={3}
|
||
>
|
||
<Container maxW="container.xl" px={4}>
|
||
<Flex justify="space-between" align="center">
|
||
{/* Logo - 价小前投研 */}
|
||
<HStack spacing={6}>
|
||
<Text
|
||
fontSize="xl"
|
||
fontWeight="bold"
|
||
color={brandText}
|
||
cursor="pointer"
|
||
_hover={{ color: brandHover }}
|
||
onClick={() => navigate('/home')}
|
||
style={{ minWidth: '140px' }}
|
||
>
|
||
价小前投研
|
||
</Text>
|
||
</HStack>
|
||
|
||
{/* 移动端菜单按钮 */}
|
||
{isMobile ? (
|
||
<IconButton
|
||
icon={<HamburgerIcon />}
|
||
variant="ghost"
|
||
onClick={onOpen}
|
||
aria-label="Open menu"
|
||
/>
|
||
) : <NavItems isAuthenticated={isAuthenticated} user={user} />}
|
||
|
||
{/* 右侧:日夜模式切换 + 登录/用户区 */}
|
||
<HStack spacing={4}>
|
||
<IconButton
|
||
aria-label="切换主题"
|
||
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
|
||
onClick={toggleColorMode}
|
||
variant="ghost"
|
||
size="sm"
|
||
/>
|
||
{/* 显示加载状态 */}
|
||
{isLoading ? (
|
||
<HStack spacing={2}>
|
||
<Spinner size="sm" />
|
||
<Text fontSize="sm" color="gray.500">检查登录状态...</Text>
|
||
</HStack>
|
||
) : isAuthenticated && user ? (
|
||
// 已登录状态 - 用户菜单 + 功能菜单排列
|
||
<HStack spacing={3}>
|
||
<Menu>
|
||
<MenuButton
|
||
as={Button}
|
||
bg="gray.800"
|
||
color="white"
|
||
size="sm"
|
||
borderRadius="full"
|
||
_hover={{ bg: 'gray.700' }}
|
||
leftIcon={
|
||
<Avatar
|
||
size="xs"
|
||
name={getDisplayName()}
|
||
src={user.avatar_url}
|
||
bg="blue.500"
|
||
/>
|
||
}
|
||
>
|
||
{getDisplayName()}
|
||
</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 onClick={() => navigate('/home/profile')}>
|
||
👤 个人资料
|
||
</MenuItem>
|
||
<MenuItem onClick={() => navigate('/home/pages/account/subscription')}>
|
||
💎 订阅管理
|
||
</MenuItem>
|
||
<MenuItem onClick={() => navigate('/home/settings')}>
|
||
⚙️ 账户设置
|
||
</MenuItem>
|
||
<MenuItem onClick={() => navigate('/home/center')}>
|
||
🏠 个人中心
|
||
</MenuItem>
|
||
<MenuDivider />
|
||
<MenuItem onClick={handleLogout} color="red.500">
|
||
🚪 退出登录
|
||
</MenuItem>
|
||
</MenuList>
|
||
</Menu>
|
||
|
||
{/* 自选股 - 头像右侧 */}
|
||
<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>
|
||
|
||
{/* 关注的事件 - 头像右侧 */}
|
||
<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>
|
||
</HStack>
|
||
) : (
|
||
// 未登录状态
|
||
<Button
|
||
colorScheme="blue"
|
||
variant="solid"
|
||
size="sm"
|
||
borderRadius="full"
|
||
onClick={handleLoginClick}
|
||
_hover={{
|
||
transform: "translateY(-1px)",
|
||
boxShadow: "md"
|
||
}}
|
||
>
|
||
登录 / 注册
|
||
</Button>
|
||
)}
|
||
</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"
|
||
>
|
||
<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"
|
||
>
|
||
<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"
|
||
>
|
||
<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"
|
||
>
|
||
<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"
|
||
>
|
||
<HStack justify="space-between">
|
||
<Text fontSize="sm">个股中心</Text>
|
||
<Badge size="xs" colorScheme="green">HOT</Badge>
|
||
</HStack>
|
||
</Link>
|
||
<Link
|
||
href="/trading-simulation"
|
||
isExternal
|
||
py={1}
|
||
px={3}
|
||
borderRadius="md"
|
||
_hover={{ bg: 'gray.100' }}
|
||
>
|
||
<HStack justify="space之间">
|
||
<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={() => {
|
||
handleLoginClick();
|
||
onClose();
|
||
}}
|
||
>
|
||
🔐 登录 / 注册
|
||
</Button>
|
||
)}
|
||
</VStack>
|
||
</DrawerBody>
|
||
</DrawerContent>
|
||
</Drawer>
|
||
</Box>
|
||
</>
|
||
);
|
||
} |