更新ios

This commit is contained in:
2026-01-22 16:01:01 +08:00
parent cd90769cab
commit 3661b9b4ba
7 changed files with 736 additions and 15 deletions

View File

@@ -84,19 +84,29 @@ const MobileDrawer = memo(({
{isAuthenticated && user && ( {isAuthenticated && user && (
<> <>
<Box p={3} bg={userBgColor} borderRadius="md"> <Box p={3} bg={userBgColor} borderRadius="md">
<HStack> <HStack justify="space-between">
<Avatar <HStack>
size="sm" <Avatar
name={getDisplayName()} size="sm"
src={user.avatar_url} name={getDisplayName()}
bg="blue.500" src={user.avatar_url}
/> bg="blue.500"
<Box> />
<Text fontSize="sm" fontWeight="bold">{getDisplayName()}</Text> <Box>
{typeof user.phone === 'string' && user.phone && ( <Text fontSize="sm" fontWeight="bold">{getDisplayName()}</Text>
<Text fontSize="xs" color={emailTextColor}>{user.phone}</Text> {typeof user.phone === 'string' && user.phone && (
)} <Text fontSize="xs" color={emailTextColor}>{user.phone}</Text>
</Box> )}
</Box>
</HStack>
<Button
size="xs"
variant="outline"
colorScheme="purple"
onClick={() => handleNavigate('/inbox')}
>
站内信
</Button>
</HStack> </HStack>
</Box> </Box>
<Divider /> <Divider />

View File

@@ -15,7 +15,7 @@ import {
Button, Button,
useDisclosure useDisclosure
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { Settings, LogOut } from 'lucide-react'; import { Settings, LogOut, Bell } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import UserAvatar from './UserAvatar'; import UserAvatar from './UserAvatar';
import { useSubscription } from '../../../../hooks/useSubscription'; import { useSubscription } from '../../../../hooks/useSubscription';
@@ -134,6 +134,12 @@ const DesktopUserMenu = memo(({ user, handleLogout }) => {
navigate('/home/settings'); navigate('/home/settings');
}; };
// 跳转到站内信
const handleNavigateToInbox = () => {
onClose();
navigate('/inbox');
};
// 退出登录 // 退出登录
const handleLogoutClick = () => { const handleLogoutClick = () => {
onClose(); onClose();
@@ -208,6 +214,13 @@ const DesktopUserMenu = memo(({ user, handleLogout }) => {
<MenuDivider my={0} /> <MenuDivider my={0} />
{/* 列表区:快捷功能 */} {/* 列表区:快捷功能 */}
<MenuItem
icon={<Bell size={16} />}
onClick={handleNavigateToInbox}
py={3}
>
站内信
</MenuItem>
<MenuItem <MenuItem
icon={<Settings size={16} />} icon={<Settings size={16} />}
onClick={handleNavigateToSettings} onClick={handleNavigateToSettings}

View File

@@ -14,7 +14,7 @@ import {
Badge, Badge,
useColorModeValue useColorModeValue
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { Star, Calendar, User, Settings, Home, LogOut, Crown } from 'lucide-react'; import { Star, Calendar, User, Settings, Home, LogOut, Crown, Bell } from 'lucide-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import UserAvatar from './UserAvatar'; import UserAvatar from './UserAvatar';
import { useSubscription } from '../../../../hooks/useSubscription'; import { useSubscription } from '../../../../hooks/useSubscription';
@@ -111,6 +111,9 @@ const TabletUserMenu = memo(({
<MenuItem icon={<Home size={16} />} onClick={() => navigate('/home/center')}> <MenuItem icon={<Home size={16} />} onClick={() => navigate('/home/center')}>
个人中心 个人中心
</MenuItem> </MenuItem>
<MenuItem icon={<Bell size={16} />} onClick={() => navigate('/inbox')}>
站内信
</MenuItem>
<MenuItem icon={<User size={16} />} onClick={() => navigate('/home/profile')}> <MenuItem icon={<User size={16} />} onClick={() => navigate('/home/profile')}>
个人资料 个人资料
</MenuItem> </MenuItem>

View File

@@ -56,6 +56,9 @@ export const lazyComponents = {
// 数据浏览器模块 // 数据浏览器模块
DataBrowser: React.lazy(() => import('@views/DataBrowser')), DataBrowser: React.lazy(() => import('@views/DataBrowser')),
// 站内信模块
Inbox: React.lazy(() => import('@views/Inbox')),
}; };
/** /**
@@ -88,4 +91,5 @@ export const {
ForumPostDetail, ForumPostDetail,
StockCommunity, StockCommunity,
DataBrowser, DataBrowser,
Inbox,
} = lazyComponents; } = lazyComponents;

View File

@@ -237,6 +237,18 @@ export const routeConfig = [
description: '超炫酷的 AI 投研聊天助手 - 基于 Hero UI' description: '超炫酷的 AI 投研聊天助手 - 基于 Hero UI'
} }
}, },
// ==================== 站内信模块 ====================
{
path: 'inbox',
component: lazyComponents.Inbox,
protection: PROTECTION_MODES.REDIRECT, // 需要登录才能查看
layout: 'main',
meta: {
title: '站内信',
description: '官方公告和互动消息'
}
},
]; ];
/** /**

View File

@@ -0,0 +1,160 @@
/**
* 站内信服务
* 获取公告和社交通知
*/
import { getApiBase } from './api';
const API_BASE = getApiBase();
/**
* 获取公告列表
* @param {object} options - 分页选项
* @param {number} options.page - 页码
* @param {number} options.limit - 每页数量
* @param {boolean} options.unreadOnly - 是否只看未读
*/
export const getAnnouncements = async (options = {}) => {
const { page = 1, limit = 20, unreadOnly = false } = options;
const params = new URLSearchParams({
page: String(page),
limit: String(limit),
unread_only: String(unreadOnly),
});
const response = await fetch(`${API_BASE}/api/social/announcements?${params}`, {
method: 'GET',
credentials: 'include',
});
if (!response.ok) {
throw new Error('获取公告失败');
}
const result = await response.json();
return result.data || { list: [], hasMore: false, unreadCount: 0 };
};
/**
* 标记公告已读
* @param {number} announcementId - 公告 ID
*/
export const markAnnouncementRead = async (announcementId) => {
const response = await fetch(`${API_BASE}/api/social/announcements/${announcementId}/read`, {
method: 'POST',
credentials: 'include',
});
if (!response.ok) {
throw new Error('标记已读失败');
}
return true;
};
/**
* 标记所有公告已读
*/
export const markAllAnnouncementsRead = async () => {
const response = await fetch(`${API_BASE}/api/social/announcements/read-all`, {
method: 'POST',
credentials: 'include',
});
if (!response.ok) {
throw new Error('标记全部已读失败');
}
return true;
};
/**
* 获取社交通知列表
* @param {object} options - 分页选项
*/
export const getNotifications = async (options = {}) => {
const { page = 1, limit = 20, unreadOnly = false } = options;
const params = new URLSearchParams({
page: String(page),
limit: String(limit),
unread_only: String(unreadOnly),
});
const response = await fetch(`${API_BASE}/api/social/notifications?${params}`, {
method: 'GET',
credentials: 'include',
});
if (!response.ok) {
throw new Error('获取通知失败');
}
const result = await response.json();
return result.data || { notifications: [], hasMore: false, unreadCount: 0 };
};
/**
* 标记通知已读
* @param {string} notificationId - 通知 ID
*/
export const markNotificationRead = async (notificationId) => {
const response = await fetch(`${API_BASE}/api/social/notifications/${notificationId}/read`, {
method: 'POST',
credentials: 'include',
});
if (!response.ok) {
throw new Error('标记已读失败');
}
return true;
};
/**
* 标记所有通知已读
*/
export const markAllNotificationsRead = async () => {
const response = await fetch(`${API_BASE}/api/social/notifications/read-all`, {
method: 'POST',
credentials: 'include',
});
if (!response.ok) {
throw new Error('标记全部已读失败');
}
return true;
};
/**
* 获取未读数量汇总
*/
export const getUnreadCounts = async () => {
try {
const [announcementData, notificationData] = await Promise.all([
getAnnouncements({ page: 1, limit: 1 }),
getNotifications({ page: 1, limit: 1 }),
]);
return {
announcements: announcementData.unreadCount || 0,
notifications: notificationData.unreadCount || 0,
total: (announcementData.unreadCount || 0) + (notificationData.unreadCount || 0),
};
} catch (error) {
console.error('获取未读数量失败:', error);
return { announcements: 0, notifications: 0, total: 0 };
}
};
export default {
getAnnouncements,
markAnnouncementRead,
markAllAnnouncementsRead,
getNotifications,
markNotificationRead,
markAllNotificationsRead,
getUnreadCounts,
};

519
src/views/Inbox/index.js Normal file
View File

@@ -0,0 +1,519 @@
/**
* 站内信页面
* 显示官方公告和社交通知
*/
import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
VStack,
HStack,
Text,
Heading,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Badge,
Card,
CardBody,
Icon,
Spinner,
Center,
Button,
useColorModeValue,
Divider,
Avatar,
} from '@chakra-ui/react';
import {
Megaphone,
Bell,
MessageCircle,
Heart,
AtSign,
UserPlus,
Info,
CheckCheck,
ChevronRight,
} from 'lucide-react';
import {
getAnnouncements,
getNotifications,
markAnnouncementRead,
markAllAnnouncementsRead,
markNotificationRead,
markAllNotificationsRead,
} from '../../services/inboxService';
// 通知类型配置
const NOTIFICATION_CONFIG = {
announcement: { icon: Megaphone, color: 'yellow.500', label: '官方公告' },
reply: { icon: MessageCircle, color: 'purple.500', label: '回复了你的帖子' },
mention: { icon: AtSign, color: 'green.500', label: '提到了你' },
like: { icon: Heart, color: 'red.500', label: '赞了你的内容' },
follow: { icon: UserPlus, color: 'blue.500', label: '关注了你' },
message: { icon: MessageCircle, color: 'cyan.500', label: '发来消息' },
system: { icon: Info, color: 'gray.500', label: '系统通知' },
};
// 格式化相对时间
const formatRelativeTime = (dateStr) => {
if (!dateStr) return '';
const date = new Date(dateStr);
const now = new Date();
const diff = now - date;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 7) return `${days}天前`;
const month = date.getMonth() + 1;
const day = date.getDate();
return `${month}${day}`;
};
// 公告列表组件
const AnnouncementList = () => {
const [announcements, setAnnouncements] = useState([]);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(true);
const [page, setPage] = useState(1);
const [unreadCount, setUnreadCount] = useState(0);
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.100', 'gray.700');
const unreadBg = useColorModeValue('yellow.50', 'rgba(236, 201, 75, 0.1)');
const loadAnnouncements = useCallback(async (pageNum = 1, append = false) => {
try {
setLoading(true);
const data = await getAnnouncements({ page: pageNum, limit: 20 });
if (append) {
setAnnouncements(prev => [...prev, ...(data.list || [])]);
} else {
setAnnouncements(data.list || []);
}
setHasMore(data.hasMore);
setUnreadCount(data.unreadCount || 0);
} catch (error) {
console.error('加载公告失败:', error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadAnnouncements(1);
}, [loadAnnouncements]);
const handleMarkRead = async (announcement) => {
if (announcement.isRead) return;
try {
await markAnnouncementRead(announcement.id);
setAnnouncements(prev =>
prev.map(a => a.id === announcement.id ? { ...a, isRead: true } : a)
);
setUnreadCount(prev => Math.max(0, prev - 1));
} catch (error) {
console.error('标记已读失败:', error);
}
};
const handleMarkAllRead = async () => {
try {
await markAllAnnouncementsRead();
setAnnouncements(prev => prev.map(a => ({ ...a, isRead: true })));
setUnreadCount(0);
} catch (error) {
console.error('标记全部已读失败:', error);
}
};
const handleLoadMore = () => {
const nextPage = page + 1;
setPage(nextPage);
loadAnnouncements(nextPage, true);
};
if (loading && announcements.length === 0) {
return (
<Center py={12}>
<Spinner size="lg" color="yellow.500" />
</Center>
);
}
if (announcements.length === 0) {
return (
<Center py={12} flexDirection="column">
<Icon as={Megaphone} boxSize={12} color="gray.400" mb={4} />
<Text color="gray.500">暂无公告</Text>
</Center>
);
}
return (
<VStack spacing={0} align="stretch">
{/* 标记全部已读按钮 */}
{unreadCount > 0 && (
<HStack justify="flex-end" mb={4}>
<Button
size="sm"
variant="ghost"
colorScheme="yellow"
leftIcon={<CheckCheck size={16} />}
onClick={handleMarkAllRead}
>
全部已读
</Button>
</HStack>
)}
{/* 公告列表 */}
{announcements.map((announcement, index) => (
<Box key={announcement.id}>
<Card
bg={announcement.isRead ? cardBg : unreadBg}
borderWidth="1px"
borderColor={announcement.isRead ? borderColor : 'yellow.200'}
cursor="pointer"
onClick={() => handleMarkRead(announcement)}
_hover={{ shadow: 'md' }}
transition="all 0.2s"
>
<CardBody py={4}>
<HStack spacing={4} align="flex-start">
<Box
p={2}
borderRadius="full"
bg="yellow.100"
color="yellow.600"
>
<Icon as={Megaphone} boxSize={5} />
</Box>
<VStack flex={1} align="stretch" spacing={1}>
<HStack justify="space-between">
<HStack spacing={2}>
<Text fontWeight="bold" color="yellow.600" fontSize="sm">
官方公告
</Text>
{announcement.title && (
<Text fontWeight="semibold" noOfLines={1}>
{announcement.title}
</Text>
)}
{!announcement.isRead && (
<Badge colorScheme="yellow" size="sm">未读</Badge>
)}
</HStack>
<Text fontSize="xs" color="gray.500">
{formatRelativeTime(announcement.createdAt)}
</Text>
</HStack>
{announcement.content && (
<Text color="gray.600" fontSize="sm" noOfLines={2}>
{announcement.content}
</Text>
)}
</VStack>
<Icon as={ChevronRight} color="gray.400" />
</HStack>
</CardBody>
</Card>
{index < announcements.length - 1 && <Box h={2} />}
</Box>
))}
{/* 加载更多 */}
{hasMore && (
<Center pt={4}>
<Button
variant="ghost"
size="sm"
onClick={handleLoadMore}
isLoading={loading}
>
加载更多
</Button>
</Center>
)}
</VStack>
);
};
// 通知列表组件
const NotificationList = () => {
const [notifications, setNotifications] = useState([]);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(true);
const [page, setPage] = useState(1);
const [unreadCount, setUnreadCount] = useState(0);
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.100', 'gray.700');
const unreadBg = useColorModeValue('purple.50', 'rgba(124, 58, 237, 0.1)');
const loadNotifications = useCallback(async (pageNum = 1, append = false) => {
try {
setLoading(true);
const data = await getNotifications({ page: pageNum, limit: 20 });
if (append) {
setNotifications(prev => [...prev, ...(data.notifications || [])]);
} else {
setNotifications(data.notifications || []);
}
setHasMore(data.hasMore);
setUnreadCount(data.unreadCount || 0);
} catch (error) {
console.error('加载通知失败:', error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadNotifications(1);
}, [loadNotifications]);
const handleMarkRead = async (notification) => {
if (notification.isRead) return;
try {
await markNotificationRead(notification.id);
setNotifications(prev =>
prev.map(n => n.id === notification.id ? { ...n, isRead: true } : n)
);
setUnreadCount(prev => Math.max(0, prev - 1));
} catch (error) {
console.error('标记已读失败:', error);
}
};
const handleMarkAllRead = async () => {
try {
await markAllNotificationsRead();
setNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
setUnreadCount(0);
} catch (error) {
console.error('标记全部已读失败:', error);
}
};
const handleLoadMore = () => {
const nextPage = page + 1;
setPage(nextPage);
loadNotifications(nextPage, true);
};
if (loading && notifications.length === 0) {
return (
<Center py={12}>
<Spinner size="lg" color="purple.500" />
</Center>
);
}
if (notifications.length === 0) {
return (
<Center py={12} flexDirection="column">
<Icon as={Bell} boxSize={12} color="gray.400" mb={4} />
<Text color="gray.500">暂无通知</Text>
<Text color="gray.400" fontSize="sm" mt={1}>
当有人回复或提及你时会收到通知
</Text>
</Center>
);
}
return (
<VStack spacing={0} align="stretch">
{/* 标记全部已读按钮 */}
{unreadCount > 0 && (
<HStack justify="flex-end" mb={4}>
<Button
size="sm"
variant="ghost"
colorScheme="purple"
leftIcon={<CheckCheck size={16} />}
onClick={handleMarkAllRead}
>
全部已读
</Button>
</HStack>
)}
{/* 通知列表 */}
{notifications.map((notification, index) => {
const config = NOTIFICATION_CONFIG[notification.type] || NOTIFICATION_CONFIG.system;
return (
<Box key={notification.id}>
<Card
bg={notification.isRead ? cardBg : unreadBg}
borderWidth="1px"
borderColor={notification.isRead ? borderColor : 'purple.200'}
cursor="pointer"
onClick={() => handleMarkRead(notification)}
_hover={{ shadow: 'md' }}
transition="all 0.2s"
>
<CardBody py={4}>
<HStack spacing={4} align="flex-start">
{notification.senderAvatar ? (
<Avatar size="sm" src={notification.senderAvatar} name={notification.senderName} />
) : (
<Box
p={2}
borderRadius="full"
bg={`${config.color.split('.')[0]}.100`}
color={config.color}
>
<Icon as={config.icon} boxSize={5} />
</Box>
)}
<VStack flex={1} align="stretch" spacing={1}>
<HStack justify="space-between">
<HStack spacing={2}>
{notification.senderName && (
<Text fontWeight="semibold" fontSize="sm">
{notification.senderName}
</Text>
)}
<Text color="gray.500" fontSize="sm">
{config.label}
</Text>
{!notification.isRead && (
<Badge colorScheme="purple" size="sm">未读</Badge>
)}
</HStack>
<Text fontSize="xs" color="gray.500">
{formatRelativeTime(notification.createdAt)}
</Text>
</HStack>
{notification.content && (
<Text color="gray.600" fontSize="sm" noOfLines={2}>
{notification.content}
</Text>
)}
</VStack>
<Icon as={ChevronRight} color="gray.400" />
</HStack>
</CardBody>
</Card>
{index < notifications.length - 1 && <Box h={2} />}
</Box>
);
})}
{/* 加载更多 */}
{hasMore && (
<Center pt={4}>
<Button
variant="ghost"
size="sm"
onClick={handleLoadMore}
isLoading={loading}
>
加载更多
</Button>
</Center>
)}
</VStack>
);
};
// 主页面组件
const InboxPage = () => {
const [announcementUnread, setAnnouncementUnread] = useState(0);
const [notificationUnread, setNotificationUnread] = useState(0);
const bgColor = useColorModeValue('gray.50', 'gray.900');
const cardBg = useColorModeValue('white', 'gray.800');
// 初始加载未读数量
useEffect(() => {
const loadUnreadCounts = async () => {
try {
const [announcementData, notificationData] = await Promise.all([
getAnnouncements({ page: 1, limit: 1 }),
getNotifications({ page: 1, limit: 1 }),
]);
setAnnouncementUnread(announcementData.unreadCount || 0);
setNotificationUnread(notificationData.unreadCount || 0);
} catch (error) {
console.error('加载未读数量失败:', error);
}
};
loadUnreadCounts();
}, []);
return (
<Box minH="100vh" bg={bgColor} py={8}>
<Box maxW="800px" mx="auto" px={4}>
{/* 页面标题 */}
<HStack mb={6}>
<Icon as={Bell} boxSize={6} color="purple.500" />
<Heading size="lg">站内信</Heading>
</HStack>
{/* 标签页 */}
<Card bg={cardBg} shadow="sm">
<CardBody p={0}>
<Tabs variant="enclosed" colorScheme="purple">
<TabList borderBottomWidth="1px" px={4} pt={4}>
<Tab fontWeight="medium">
<HStack spacing={2}>
<Icon as={Megaphone} boxSize={4} />
<Text>官方公告</Text>
{announcementUnread > 0 && (
<Badge colorScheme="yellow" borderRadius="full">
{announcementUnread}
</Badge>
)}
</HStack>
</Tab>
<Tab fontWeight="medium">
<HStack spacing={2}>
<Icon as={MessageCircle} boxSize={4} />
<Text>互动消息</Text>
{notificationUnread > 0 && (
<Badge colorScheme="purple" borderRadius="full">
{notificationUnread}
</Badge>
)}
</HStack>
</Tab>
</TabList>
<TabPanels>
<TabPanel p={4}>
<AnnouncementList />
</TabPanel>
<TabPanel p={4}>
<NotificationList />
</TabPanel>
</TabPanels>
</Tabs>
</CardBody>
</Card>
</Box>
</Box>
);
};
export default InboxPage;